diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index fafcc95744..5dbfbe00df 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -501,6 +501,7 @@ public final class ContactSelectionControllerParams { public enum ChatListSearchFilter: Equatable { case chats case media + case downloads case links case files case music @@ -514,14 +515,16 @@ public enum ChatListSearchFilter: Equatable { return 0 case .media: return 1 - case .links: + case .downloads: return 2 - case .files: + case .links: return 3 - case .music: + case .files: return 4 - case .voice: + case .music: return 5 + case .voice: + return 6 case let .peer(peerId, _, _, _): return peerId.id._internalGetInt64Value() case let .date(_, date, _): @@ -616,6 +619,7 @@ public protocol SharedAccountContext: AnyObject { func makeChatRecentActionsController(context: AccountContext, peer: Peer, adminPeerId: PeerId?) -> ViewController func makePrivacyAndSecurityController(context: AccountContext) -> ViewController func navigateToChatController(_ params: NavigateToChatControllerParams) + func openStorageUsage(context: AccountContext) func openLocationScreen(context: AccountContext, messageId: MessageId, navigationController: NavigationController) func openExternalUrl(context: AccountContext, urlContext: OpenURLContext, url: String, forceExternal: Bool, presentationData: PresentationData, navigationController: NavigationController?, dismissInput: @escaping () -> Void) func chatAvailableMessageActions(postbox: Postbox, accountPeerId: EnginePeer.Id, messageIds: Set) -> Signal diff --git a/submodules/AccountContext/Sources/FetchManager.swift b/submodules/AccountContext/Sources/FetchManager.swift index f53df05fac..b0dd2de54f 100644 --- a/submodules/AccountContext/Sources/FetchManager.swift +++ b/submodules/AccountContext/Sources/FetchManager.swift @@ -155,6 +155,9 @@ public protocol FetchManager { func interactivelyFetched(category: FetchManagerCategory, location: FetchManagerLocation, locationKey: FetchManagerLocationKey, mediaReference: AnyMediaReference?, resourceReference: MediaResourceReference, ranges: IndexSet, statsCategory: MediaResourceStatsCategory, elevatedPriority: Bool, userInitiated: Bool, priority: FetchManagerPriority, storeToDownloadsPeerType: MediaAutoDownloadPeerType?) -> Signal func cancelInteractiveFetches(category: FetchManagerCategory, location: FetchManagerLocation, locationKey: FetchManagerLocationKey, resource: MediaResource) + func cancelInteractiveFetches(resourceId: String) + func toggleInteractiveFetchPaused(resourceId: String, isPaused: Bool) + func raisePriority(resourceId: String) func fetchStatus(category: FetchManagerCategory, location: FetchManagerLocation, locationKey: FetchManagerLocationKey, resource: MediaResource) -> Signal } diff --git a/submodules/AccountContext/Sources/FetchMediaUtils.swift b/submodules/AccountContext/Sources/FetchMediaUtils.swift index 2a11ec0d06..b693b63b07 100644 --- a/submodules/AccountContext/Sources/FetchMediaUtils.swift +++ b/submodules/AccountContext/Sources/FetchMediaUtils.swift @@ -96,7 +96,7 @@ public func messageMediaFileStatus(context: AccountContext, messageId: MessageId public func messageMediaImageStatus(context: AccountContext, messageId: MessageId, image: TelegramMediaImage) -> Signal { guard let representation = image.representations.last else { - return .single(.Remote) + return .single(.Remote(progress: 0.0)) } return context.fetchManager.fetchStatus(category: .image, location: .chat(messageId.peerId), locationKey: .messageId(messageId), resource: representation.resource) } diff --git a/submodules/AnimatedStickerNode/BUILD b/submodules/AnimatedStickerNode/BUILD index 6298b2706d..0485aeaa3d 100644 --- a/submodules/AnimatedStickerNode/BUILD +++ b/submodules/AnimatedStickerNode/BUILD @@ -35,6 +35,7 @@ swift_library( "//submodules/ManagedFile:ManagedFile", "//submodules/TelegramCore:TelegramCore", "//submodules/AnimationCompression:AnimationCompression", + "//submodules/Components/MetalImageView:MetalImageView", ], visibility = [ "//visibility:public", diff --git a/submodules/AnimatedStickerNode/Sources/AnimatedStickerFrameSource.swift b/submodules/AnimatedStickerNode/Sources/AnimatedStickerFrameSource.swift index f90c0769c9..3d62dec40b 100644 --- a/submodules/AnimatedStickerNode/Sources/AnimatedStickerFrameSource.swift +++ b/submodules/AnimatedStickerNode/Sources/AnimatedStickerFrameSource.swift @@ -245,6 +245,12 @@ public final class AnimatedStickerCachedFrameSource: AnimatedStickerFrameSource } } +private func alignUp(size: Int, align: Int) -> Int { + precondition(((align - 1) & align) == 0, "Align must be a power of two") + + let alignmentMask = align - 1 + return (size + alignmentMask) & ~alignmentMask +} private final class AnimatedStickerDirectFrameSourceCache { private enum FrameRangeResult { @@ -274,8 +280,8 @@ private final class AnimatedStickerDirectFrameSourceCache { self.storeQueue = sharedStoreQueue self.frameCount = frameCount - self.width = width - self.height = height + self.width = alignUp(size: width, align: 8) + self.height = alignUp(size: height, align: 8) self.useHardware = useHardware let suffix : String diff --git a/submodules/AnimatedStickerNode/Sources/AnimatedStickerNode.swift b/submodules/AnimatedStickerNode/Sources/AnimatedStickerNode.swift index 3b517ba006..9f7fb2ec8f 100644 --- a/submodules/AnimatedStickerNode/Sources/AnimatedStickerNode.swift +++ b/submodules/AnimatedStickerNode/Sources/AnimatedStickerNode.swift @@ -248,7 +248,7 @@ public final class AnimatedStickerNode: ASDisplayNode { override public func didLoad() { super.didLoad() - if #available(iOS 10.0, *), self.useMetalCache { + if #available(iOS 10.0, *), (self.useMetalCache/* || "".isEmpty*/) { self.renderer = AnimatedStickerNode.hardwareRendererPool.take() } else { self.renderer = AnimatedStickerNode.softwareRendererPool.take() diff --git a/submodules/AnimatedStickerNode/Sources/AnimationRenderer.swift b/submodules/AnimatedStickerNode/Sources/AnimationRenderer.swift index 915c2e4532..e4e2264b15 100644 --- a/submodules/AnimatedStickerNode/Sources/AnimationRenderer.swift +++ b/submodules/AnimatedStickerNode/Sources/AnimationRenderer.swift @@ -42,9 +42,9 @@ final class AnimationRendererPool { } private func putBack(renderer: AnimationRenderer) { - #if DEBUG + /*#if DEBUG self.items.append(renderer) - #endif + #endif*/ } } diff --git a/submodules/AnimatedStickerNode/Sources/CompressedAnimationRenderer.swift b/submodules/AnimatedStickerNode/Sources/CompressedAnimationRenderer.swift index 47dd85d689..3446907812 100644 --- a/submodules/AnimatedStickerNode/Sources/CompressedAnimationRenderer.swift +++ b/submodules/AnimatedStickerNode/Sources/CompressedAnimationRenderer.swift @@ -8,44 +8,19 @@ import Accelerate import AnimationCompression import Metal import MetalKit +import MetalImageView @available(iOS 10.0, *) final class CompressedAnimationRenderer: ASDisplayNode, AnimationRenderer { private final class View: UIView { static override var layerClass: AnyClass { -#if targetEnvironment(simulator) - if #available(iOS 13.0, *) { - return CAMetalLayer.self - } else { - preconditionFailure() - } -#else - return CAMetalLayer.self -#endif + return MetalImageLayer.self } init(device: MTLDevice) { super.init(frame: CGRect()) -#if targetEnvironment(simulator) - if #available(iOS 13.0, *) { - let metalLayer = self.layer as! CAMetalLayer - - metalLayer.device = MTLCreateSystemDefaultDevice() - metalLayer.pixelFormat = .bgra8Unorm - metalLayer.framebufferOnly = true - metalLayer.allowsNextDrawableTimeout = true - } -#else - let metalLayer = self.layer as! CAMetalLayer - - metalLayer.device = MTLCreateSystemDefaultDevice() - metalLayer.pixelFormat = .bgra8Unorm - metalLayer.framebufferOnly = true - if #available(iOS 11.0, *) { - metalLayer.allowsNextDrawableTimeout = true - } -#endif + (self.layer as! MetalImageLayer).renderer.device = device } required init?(coder: NSCoder) { @@ -82,16 +57,25 @@ final class CompressedAnimationRenderer: ASDisplayNode, AnimationRenderer { func render(queue: Queue, width: Int, height: Int, bytesPerRow: Int, data: Data, type: AnimationRendererFrameType, mulAlpha: Bool, completion: @escaping () -> Void) { switch type { case .dct: - self.renderer.renderIdct(metalLayer: self.layer, compressedImage: AnimationCompressor.CompressedImageData(data: data), completion: completion) + self.renderer.renderIdct(layer: self.layer as! MetalImageLayer, compressedImage: AnimationCompressor.CompressedImageData(data: data), completion: { [weak self] in + self?.updateHighlightedContentNode() + completion() + }) case .argb: - self.renderer.renderRgb(metalLayer: self.layer, width: width, height: height, bytesPerRow: bytesPerRow, data: data, completion: completion) + self.renderer.renderRgb(layer: self.layer as! MetalImageLayer, width: width, height: height, bytesPerRow: bytesPerRow, data: data, completion: { [weak self] in + self?.updateHighlightedContentNode() + completion() + }) case .yuva: - self.renderer.renderYuva(metalLayer: self.layer, width: width, height: height, data: data, completion: completion) + self.renderer.renderYuva(layer: self.layer as! MetalImageLayer, width: width, height: height, data: data, completion: { [weak self] in + self?.updateHighlightedContentNode() + completion() + }) } } private func updateHighlightedContentNode() { - /*guard let highlightedContentNode = self.highlightedContentNode, let highlightedColor = self.highlightedColor else { + guard let highlightedContentNode = self.highlightedContentNode, let highlightedColor = self.highlightedColor else { return } if let contents = self.contents, CFGetTypeID(contents as CFTypeRef) == CGImage.typeID { @@ -100,11 +84,11 @@ final class CompressedAnimationRenderer: ASDisplayNode, AnimationRenderer { highlightedContentNode.tintColor = highlightedColor if self.highlightReplacesContent { self.contents = nil - }*/ + } } func setOverlayColor(_ color: UIColor?, replace: Bool, animated: Bool) { - /*self.highlightReplacesContent = replace + self.highlightReplacesContent = replace var updated = false if let current = self.highlightedColor, let color = color { updated = !current.isEqual(color) @@ -141,6 +125,6 @@ final class CompressedAnimationRenderer: ASDisplayNode, AnimationRenderer { strongSelf.highlightedContentNode?.removeFromSupernode() strongSelf.highlightedContentNode = nil }) - }*/ + } } } diff --git a/submodules/AnimationCompression/BUILD b/submodules/AnimationCompression/BUILD index 76e5b99b5a..55b637ec06 100644 --- a/submodules/AnimationCompression/BUILD +++ b/submodules/AnimationCompression/BUILD @@ -54,6 +54,7 @@ swift_library( ], deps = [ ":DctHuffman", + "//submodules/Components/MetalImageView:MetalImageView", ], visibility = [ "//visibility:public", diff --git a/submodules/AnimationCompression/Sources/CompressedImageRenderer.swift b/submodules/AnimationCompression/Sources/CompressedImageRenderer.swift index 064993f610..fe835aa6db 100644 --- a/submodules/AnimationCompression/Sources/CompressedImageRenderer.swift +++ b/submodules/AnimationCompression/Sources/CompressedImageRenderer.swift @@ -4,6 +4,7 @@ import Metal import MetalKit import simd import DctHuffman +import MetalImageView private struct Vertex { var position: vector_float2 @@ -94,6 +95,8 @@ public final class CompressedImageRenderer { private var yuvaTextures: TextureSet? private let commandQueue: MTLCommandQueue + + private var isRendering: Bool = false public init?(sharedContext: AnimationCompressor.SharedContext) { self.sharedContext = sharedContext @@ -107,46 +110,9 @@ public final class CompressedImageRenderer { private var drawableRequestTimestamp: Double? - private func getNextDrawable(metalLayer: CALayer, drawableSize: CGSize) -> CAMetalDrawable? { -#if targetEnvironment(simulator) - if #available(iOS 13.0, *) { - if let metalLayer = metalLayer as? CAMetalLayer { - if metalLayer.drawableSize != drawableSize { - metalLayer.drawableSize = drawableSize - } - return metalLayer.nextDrawable() - } else { - return nil - } - } else { - return nil - } -#else - if let metalLayer = metalLayer as? CAMetalLayer { - if metalLayer.drawableSize != drawableSize { - metalLayer.drawableSize = drawableSize - } - let beginTime = CFAbsoluteTimeGetCurrent() - let drawableRequestDuration: Double - if let drawableRequestTimestamp = self.drawableRequestTimestamp { - drawableRequestDuration = beginTime - drawableRequestTimestamp - if drawableRequestDuration < 1.0 / 60.0 { - return nil - } - } else { - drawableRequestDuration = 0.0 - } - self.drawableRequestTimestamp = beginTime - let result = metalLayer.nextDrawable() - let duration = CFAbsoluteTimeGetCurrent() - beginTime - if duration > 1.0 / 200.0 { - print("lag \(duration * 1000.0) ms (\(drawableRequestDuration * 1000.0) ms)") - } - return result - } else { - return nil - } -#endif + private func getNextDrawable(layer: MetalImageLayer, drawableSize: CGSize) -> MetalImageLayer.Drawable? { + layer.renderer.drawableSize = drawableSize + return layer.renderer.nextDrawable() } private func updateIdctTextures(compressedImage: AnimationCompressor.CompressedImageData) { @@ -223,7 +189,7 @@ public final class CompressedImageRenderer { } } - public func renderIdct(metalLayer: CALayer, compressedImage: AnimationCompressor.CompressedImageData, completion: @escaping () -> Void) { + public func renderIdct(layer: MetalImageLayer, compressedImage: AnimationCompressor.CompressedImageData, completion: @escaping () -> Void) { DispatchQueue.global().async { self.updateIdctTextures(compressedImage: compressedImage) @@ -295,7 +261,7 @@ public final class CompressedImageRenderer { let drawableSize = CGSize(width: CGFloat(outputTextures.textures[0].width), height: CGFloat(outputTextures.textures[0].height)) - guard let drawable = self.getNextDrawable(metalLayer: metalLayer, drawableSize: drawableSize) else { + guard let drawable = self.getNextDrawable(layer: layer, drawableSize: drawableSize) else { commandBuffer.commit() completion() return @@ -321,34 +287,15 @@ public final class CompressedImageRenderer { renderEncoder.endEncoding() - var storedDrawable: MTLDrawable? = drawable - commandBuffer.addScheduledHandler { _ in - storedDrawable?.present() - storedDrawable = nil - } - - -#if targetEnvironment(simulator) + var storedDrawable: MetalImageLayer.Drawable? = drawable commandBuffer.addCompletedHandler { _ in DispatchQueue.main.async { - completion() - } - } -#else - if #available(iOS 10.3, *) { - drawable.addPresentedHandler { _ in - DispatchQueue.main.async { - completion() - } - } - } else { - commandBuffer.addCompletedHandler { _ in - DispatchQueue.main.async { - completion() + autoreleasepool { + storedDrawable?.present(completion: completion) + storedDrawable = nil } } } -#endif commandBuffer.commit() } @@ -383,7 +330,7 @@ public final class CompressedImageRenderer { }) } - public func renderRgb(metalLayer: CALayer, width: Int, height: Int, bytesPerRow: Int, data: Data, completion: @escaping () -> Void) { + public func renderRgb(layer: MetalImageLayer, width: Int, height: Int, bytesPerRow: Int, data: Data, completion: @escaping () -> Void) { self.updateRgbTexture(width: width, height: height, bytesPerRow: bytesPerRow, data: data) guard let rgbTexture = self.rgbTexture else { @@ -397,7 +344,7 @@ public final class CompressedImageRenderer { let drawableSize = CGSize(width: CGFloat(rgbTexture.width), height: CGFloat(rgbTexture.height)) - guard let drawable = self.getNextDrawable(metalLayer: metalLayer, drawableSize: drawableSize) else { + guard let drawable = self.getNextDrawable(layer: layer, drawableSize: drawableSize) else { commandBuffer.commit() completion() return @@ -420,11 +367,13 @@ public final class CompressedImageRenderer { renderEncoder.endEncoding() - commandBuffer.present(drawable) - + var storedDrawable: MetalImageLayer.Drawable? = drawable commandBuffer.addCompletedHandler { _ in DispatchQueue.main.async { - completion() + autoreleasepool { + storedDrawable?.present(completion: completion) + storedDrawable = nil + } } } @@ -432,6 +381,10 @@ public final class CompressedImageRenderer { } private func updateYuvaTextures(width: Int, height: Int, data: Data) { + if width % 2 != 0 || height % 2 != 0 { + return + } + self.compressedTextures = nil self.outputTextures = nil self.rgbTexture = nil @@ -501,56 +454,92 @@ public final class CompressedImageRenderer { } } - public func renderYuva(metalLayer: CALayer, width: Int, height: Int, data: Data, completion: @escaping () -> Void) { - self.updateYuvaTextures(width: width, height: height, data: data) - - guard let yuvaTextures = self.yuvaTextures else { - return - } - - guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { - return - } - commandBuffer.label = "MyCommand" - - let drawableSize = CGSize(width: CGFloat(yuvaTextures.width), height: CGFloat(yuvaTextures.height)) - - guard let drawable = self.getNextDrawable(metalLayer: metalLayer, drawableSize: drawableSize) else { - commandBuffer.commit() - completion() - return - } - - let renderPassDescriptor = MTLRenderPassDescriptor() - renderPassDescriptor.colorAttachments[0].texture = drawable.texture - renderPassDescriptor.colorAttachments[0].loadAction = .clear - renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) - - guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { - return - } - renderEncoder.label = "MyRenderEncoder" - - renderEncoder.setRenderPipelineState(self.shared.renderYuvaPipelineState) - renderEncoder.setFragmentTexture(yuvaTextures.textures[0].texture, index: 0) - renderEncoder.setFragmentTexture(yuvaTextures.textures[1].texture, index: 1) - renderEncoder.setFragmentTexture(yuvaTextures.textures[2].texture, index: 2) - - var alphaSize = simd_uint2(UInt32(yuvaTextures.textures[0].texture.width), UInt32(yuvaTextures.textures[0].texture.height)) - renderEncoder.setFragmentBytes(&alphaSize, length: 8, index: 3) - - renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) - - renderEncoder.endEncoding() - - commandBuffer.present(drawable) - - commandBuffer.addCompletedHandler { _ in - DispatchQueue.main.async { - completion() + public func renderYuva(layer: MetalImageLayer, width: Int, height: Int, data: Data, completion: @escaping () -> Void) { + DispatchQueue.global().async { + autoreleasepool { + //let renderStartTime = CFAbsoluteTimeGetCurrent() + + var beginTime: Double = 0.0 + var duration: Double = 0.0 + beginTime = CFAbsoluteTimeGetCurrent() + + self.updateYuvaTextures(width: width, height: height, data: data) + + duration = CFAbsoluteTimeGetCurrent() - beginTime + if duration > 1.0 / 60.0 { + print("update textures lag \(duration * 1000.0)") + } + + guard let yuvaTextures = self.yuvaTextures else { + DispatchQueue.main.async { + completion() + } + return + } + + beginTime = CFAbsoluteTimeGetCurrent() + + guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { + DispatchQueue.main.async { + completion() + } + return + } + + commandBuffer.label = "MyCommand" + + let drawableSize = CGSize(width: CGFloat(yuvaTextures.width), height: CGFloat(yuvaTextures.height)) + + guard let drawable = self.getNextDrawable(layer: layer, drawableSize: drawableSize) else { + commandBuffer.commit() + DispatchQueue.main.async { + completion() + } + return + } + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = drawable.texture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) + + guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { + DispatchQueue.main.async { + completion() + } + return + } + renderEncoder.label = "MyRenderEncoder" + + renderEncoder.setRenderPipelineState(self.shared.renderYuvaPipelineState) + renderEncoder.setFragmentTexture(yuvaTextures.textures[0].texture, index: 0) + renderEncoder.setFragmentTexture(yuvaTextures.textures[1].texture, index: 1) + renderEncoder.setFragmentTexture(yuvaTextures.textures[2].texture, index: 2) + + var alphaSize = simd_uint2(UInt32(yuvaTextures.textures[0].texture.width), UInt32(yuvaTextures.textures[0].texture.height)) + renderEncoder.setFragmentBytes(&alphaSize, length: 8, index: 3) + + renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + + renderEncoder.endEncoding() + + var storedDrawable: MetalImageLayer.Drawable? = drawable + commandBuffer.addCompletedHandler { _ in + DispatchQueue.main.async { + autoreleasepool { + storedDrawable?.present(completion: completion) + storedDrawable = nil + } + } + } + + commandBuffer.commit() + + duration = CFAbsoluteTimeGetCurrent() - beginTime + if duration > 1.0 / 60.0 { + print("commit lag \(duration * 1000.0)") + } } } - - commandBuffer.commit() } } diff --git a/submodules/ChatListSearchItemHeader/Sources/ChatListSearchItemHeader.swift b/submodules/ChatListSearchItemHeader/Sources/ChatListSearchItemHeader.swift index 57f60eaa4d..b9dd2785a4 100644 --- a/submodules/ChatListSearchItemHeader/Sources/ChatListSearchItemHeader.swift +++ b/submodules/ChatListSearchItemHeader/Sources/ChatListSearchItemHeader.swift @@ -27,6 +27,8 @@ public enum ChatListSearchItemHeaderType { case recentCalls case orImportIntoAnExistingGroup case subscribers + case downloading + case recentDownloads fileprivate func title(strings: PresentationStrings) -> String { switch self { @@ -74,6 +76,12 @@ public enum ChatListSearchItemHeaderType { return strings.ChatList_HeaderImportIntoAnExistingGroup case .subscribers: return strings.Channel_ChannelSubscribersHeader + case .downloading: + //TODO:localize + return "Downloading" + case .recentDownloads: + //TODO:localize + return "Recently Downloaded" } } @@ -123,6 +131,10 @@ public enum ChatListSearchItemHeaderType { return .orImportIntoAnExistingGroup case .subscribers: return .subscribers + case .downloading: + return .downloading + case .recentDownloads: + return .recentDownloads } } } @@ -154,6 +166,8 @@ private enum ChatListSearchItemHeaderId: Int32 { case recentCalls case orImportIntoAnExistingGroup case subscribers + case downloading + case recentDownloads } public final class ChatListSearchItemHeader: ListViewItemHeader { diff --git a/submodules/ChatListSearchItemNode/Sources/ChatListSearchItem.swift b/submodules/ChatListSearchItemNode/Sources/ChatListSearchItem.swift index bb4472f5a6..24b3edf898 100644 --- a/submodules/ChatListSearchItemNode/Sources/ChatListSearchItem.swift +++ b/submodules/ChatListSearchItemNode/Sources/ChatListSearchItem.swift @@ -110,7 +110,7 @@ public class ChatListSearchItemNode: ListViewItemNode { let searchBarNodeLayout = self.searchBarNode.asyncLayout() let placeholder = self.placeholder - return { item, params, nextIsPinned, isEnabled in + return { [weak self] item, params, nextIsPinned, isEnabled in let baseWidth = params.width - params.leftInset - params.rightInset let backgroundColor = nextIsPinned ? item.theme.chatList.pinnedItemBackgroundColor : item.theme.chatList.itemBackgroundColor @@ -120,7 +120,7 @@ public class ChatListSearchItemNode: ListViewItemNode { let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: 54.0), insets: UIEdgeInsets()) - return (layout, { [weak self] animated in + return (layout, { animated in if let strongSelf = self { let transition: ContainedViewLayoutTransition if animated { diff --git a/submodules/ChatListUI/BUILD b/submodules/ChatListUI/BUILD index 652f74211d..52115e69e3 100644 --- a/submodules/ChatListUI/BUILD +++ b/submodules/ChatListUI/BUILD @@ -62,6 +62,10 @@ swift_library( "//submodules/TelegramCallsUI:TelegramCallsUI", "//submodules/StickerResources:StickerResources", "//submodules/TextFormat:TextFormat", + "//submodules/FetchManagerImpl:FetchManagerImpl", + "//submodules/ComponentFlow:ComponentFlow", + "//submodules/Components/LottieAnimationComponent:LottieAnimationComponent", + "//submodules/Components/ProgressIndicatorComponent:ProgressIndicatorComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index 6f76cc6c36..bcc2d1fb7c 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -25,6 +25,10 @@ import TooltipUI import TelegramCallsUI import StickerResources import PasswordSetupUI +import FetchManagerImpl +import ComponentFlow +import LottieAnimationComponent +import ProgressIndicatorComponent private func fixListNodeScrolling(_ listNode: ListView, searchNode: NavigationBarSearchContentNode) -> Bool { if listNode.scroller.isDragging { @@ -151,6 +155,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController private let tabContainerNode: ChatListFilterTabContainerNode private var tabContainerData: ([ChatListFilterTabEntry], Bool)? + private var activeDownloadsDisposable: Disposable? + private var clearUnseenDownloadsTimer: SwiftSignalKit.Timer? + private var didSetupTabs = false public override func updateNavigationCustomData(_ data: Any?, progress: CGFloat, transition: ContainedViewLayoutTransition) { @@ -467,6 +474,263 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) self.searchContentNode?.updateExpansionProgress(0.0) self.navigationBar?.setContentNode(self.searchContentNode, animated: false) + + enum State: Equatable { + case empty + case downloading(Double) + case hasUnseen + } + + let entriesWithFetchStatuses = Signal<[(entry: FetchManagerEntrySummary, progress: Double)], NoError> { subscriber in + let queue = Queue() + final class StateHolder { + final class EntryContext { + var entry: FetchManagerEntrySummary + var isRemoved: Bool = false + var statusDisposable: Disposable? + var status: MediaResourceStatus? + + init(entry: FetchManagerEntrySummary) { + self.entry = entry + } + + deinit { + self.statusDisposable?.dispose() + } + } + + let queue: Queue + + var entryContexts: [FetchManagerLocationEntryId: EntryContext] = [:] + + let state = Promise<[(entry: FetchManagerEntrySummary, progress: Double)]>() + + init(queue: Queue) { + self.queue = queue + } + + func update(engine: TelegramEngine, entries: [FetchManagerEntrySummary]) { + if entries.isEmpty { + self.entryContexts.removeAll() + } else { + for entry in entries { + let context: EntryContext + if let current = self.entryContexts[entry.id] { + context = current + } else { + context = EntryContext(entry: entry) + self.entryContexts[entry.id] = context + } + + context.entry = entry + + if context.isRemoved { + context.isRemoved = false + context.status = nil + context.statusDisposable?.dispose() + context.statusDisposable = nil + } + } + + for (_, context) in self.entryContexts { + if !entries.contains(where: { $0.id == context.entry.id }) { + context.isRemoved = true + } + + if context.statusDisposable == nil { + context.statusDisposable = (engine.account.postbox.mediaBox.resourceStatus(context.entry.resourceReference.resource) + |> deliverOn(self.queue)).start(next: { [weak self, weak context] status in + guard let strongSelf = self, let context = context else { + return + } + if context.status != status { + context.status = status + strongSelf.notifyUpdatedIfReady() + } + }) + } + } + } + + self.notifyUpdatedIfReady() + } + + func notifyUpdatedIfReady() { + var result: [(entry: FetchManagerEntrySummary, progress: Double)] = [] + loop: for (_, context) in self.entryContexts { + guard let status = context.status else { + return + } + let progress: Double + switch status { + case .Local: + progress = 1.0 + case .Remote: + if context.isRemoved { + continue loop + } + progress = 0.0 + case let .Paused(value): + progress = Double(value) + case let .Fetching(_, value): + progress = Double(value) + } + result.append((context.entry, progress)) + } + self.state.set(.single(result)) + } + } + let holder = QueueLocalObject(queue: queue, generate: { + return StateHolder(queue: queue) + }) + let entriesDisposable = ((context.fetchManager as! FetchManagerImpl).entriesSummary).start(next: { entries in + holder.with { holder in + holder.update(engine: context.engine, entries: entries) + } + }) + let holderStateDisposable = MetaDisposable() + holder.with { holder in + holderStateDisposable.set(holder.state.get().start(next: { state in + subscriber.putNext(state) + })) + } + + return ActionDisposable { + entriesDisposable.dispose() + holderStateDisposable.dispose() + } + } + + let stateSignal: Signal = (combineLatest(queue: .mainQueue(), entriesWithFetchStatuses, recentDownloadItems(postbox: context.account.postbox)) + |> map { entries, recentDownloadItems -> State in + if !entries.isEmpty { + var totalBytes = 0.0 + var totalProgressInBytes = 0.0 + for (entry, progress) in entries { + var size = 1024 * 1024 * 1024 + if let sizeValue = entry.resourceReference.resource.size { + size = sizeValue + } + totalBytes += Double(size) + totalProgressInBytes += Double(size) * progress + } + let totalProgress: Double + if totalBytes.isZero { + totalProgress = 0.0 + } else { + totalProgress = totalProgressInBytes / totalBytes + } + return .downloading(totalProgress) + } else { + for item in recentDownloadItems { + if !item.isSeen { + return .hasUnseen + } + } + return .empty + } + } + |> mapToSignal { value -> Signal in + return .single(value) |> delay(0.1, queue: .mainQueue()) + } + |> distinctUntilChanged + |> deliverOnMainQueue) + + /*if !"".isEmpty { + stateSignal = Signal.single(.downloading) + |> then(Signal.single(.hasUnseen) |> delay(3.0, queue: .mainQueue())) + }*/ + + self.activeDownloadsDisposable = stateSignal.start(next: { [weak self] state in + guard let strongSelf = self else { + return + } + let animation: LottieAnimationComponent.Animation? + let progressValue: Double? + switch state { + case let .downloading(progress): + animation = LottieAnimationComponent.Animation( + name: "anim_search_downloading", + colors: [ + "Oval.Ellipse 1.Stroke 1": strongSelf.presentationData.theme.list.itemAccentColor, + "Arrow1.Union.Fill 1": strongSelf.presentationData.theme.list.itemAccentColor, + "Arrow2.Union.Fill 1": strongSelf.presentationData.theme.list.itemAccentColor, + ], + loop: true + ) + progressValue = progress + + strongSelf.clearUnseenDownloadsTimer?.invalidate() + strongSelf.clearUnseenDownloadsTimer = nil + case .hasUnseen: + animation = LottieAnimationComponent.Animation( + name: "anim_search_downloaded", + colors: [ + "Fill 2.Ellipse 1.Fill 1": strongSelf.presentationData.theme.list.itemAccentColor, + "Mask1.Ellipse 1.Fill 1": strongSelf.presentationData.theme.list.itemAccentColor, + "Mask2.Ellipse 1.Fill 1": strongSelf.presentationData.theme.list.itemAccentColor, + "Arrow3.Union.Fill 1": strongSelf.presentationData.theme.list.itemAccentColor, + "Fill.Ellipse 1.Fill 1": strongSelf.presentationData.theme.list.itemAccentColor, + "Oval.Ellipse 1.Stroke 1": strongSelf.presentationData.theme.list.itemAccentColor, + "Arrow1.Union.Fill 1": strongSelf.presentationData.theme.list.itemAccentColor, + "Arrow2.Union.Fill 1": strongSelf.presentationData.theme.rootController.navigationSearchBar.inputFillColor.blitOver(strongSelf.presentationData.theme.rootController.navigationBar.opaqueBackgroundColor, alpha: 1.0), + ], + loop: false + ) + progressValue = 1.0 + + if strongSelf.clearUnseenDownloadsTimer == nil { + let timeout: Double + #if DEBUG + timeout = 10.0 + #else + timeout = 1.0 * 60.0 + #endif + strongSelf.clearUnseenDownloadsTimer = SwiftSignalKit.Timer(timeout: timeout, repeat: false, completion: { + guard let strongSelf = self else { + return + } + strongSelf.clearUnseenDownloadsTimer = nil + let _ = markAllRecentDownloadItemsAsSeen(postbox: strongSelf.context.account.postbox).start() + }, queue: .mainQueue()) + strongSelf.clearUnseenDownloadsTimer?.start() + } + case .empty: + animation = nil + progressValue = nil + + strongSelf.clearUnseenDownloadsTimer?.invalidate() + strongSelf.clearUnseenDownloadsTimer = nil + } + + if let animation = animation, let progressValue = progressValue { + let contentComponent = AnyComponent(ZStack([ + AnyComponentWithIdentity(id: 0, component: AnyComponent(LottieAnimationComponent( + animation: animation, + size: CGSize(width: 24.0, height: 24.0) + ))), + AnyComponentWithIdentity(id: 1, component: AnyComponent(ProgressIndicatorComponent( + diameter: 16.0, + backgroundColor: .clear, + foregroundColor: strongSelf.presentationData.theme.list.itemAccentColor, + value: progressValue + ))) + ])) + + strongSelf.searchContentNode?.placeholderNode.setAccessoryComponent(component: AnyComponent(Button( + content: contentComponent, + insets: UIEdgeInsets(), + action: { + guard let strongSelf = self else { + return + } + strongSelf.activateSearch(filter: .downloads, query: nil) + } + ))) + } else { + strongSelf.searchContentNode?.placeholderNode.setAccessoryComponent(component: nil) + } + }) } if enableDebugActions { @@ -514,6 +778,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.stateDisposable.dispose() self.filterDisposable.dispose() self.featuredFiltersDisposable.dispose() + self.activeDownloadsDisposable?.dispose() } private func updateThemeAndStrings() { diff --git a/submodules/ChatListUI/Sources/ChatListSearchContainerNode.swift b/submodules/ChatListUI/Sources/ChatListSearchContainerNode.swift index 5d2b5900f8..f6170af27f 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchContainerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchContainerNode.swift @@ -29,6 +29,7 @@ import ChatInterfaceState import ShareController import UndoUI import TextFormat +import Postbox private enum ChatListTokenId: Int32 { case archive @@ -45,14 +46,14 @@ final class ChatListSearchInteraction { let clearRecentSearch: () -> Void let addContact: (String) -> Void let toggleMessageSelection: (EngineMessage.Id, Bool) -> Void - let messageContextAction: ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?) -> Void) + let messageContextAction: ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?, ChatListSearchPaneKey, (id: String, size: Int, isFirstInList: Bool)?) -> Void) let mediaMessageContextAction: ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?) -> Void) let peerContextAction: ((EnginePeer, ChatListSearchContextActionSource, ASDisplayNode, ContextGesture?) -> Void)? let present: (ViewController, Any?) -> Void let dismissInput: () -> Void let getSelectedMessageIds: () -> Set? - init(openPeer: @escaping (EnginePeer, EnginePeer?, Bool) -> Void, openDisabledPeer: @escaping (EnginePeer) -> Void, openMessage: @escaping (EnginePeer, EngineMessage.Id, Bool) -> Void, openUrl: @escaping (String) -> Void, clearRecentSearch: @escaping () -> Void, addContact: @escaping (String) -> Void, toggleMessageSelection: @escaping (EngineMessage.Id, Bool) -> Void, messageContextAction: @escaping ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?) -> Void), mediaMessageContextAction: @escaping ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?) -> Void), peerContextAction: ((EnginePeer, ChatListSearchContextActionSource, ASDisplayNode, ContextGesture?) -> Void)?, present: @escaping (ViewController, Any?) -> Void, dismissInput: @escaping () -> Void, getSelectedMessageIds: @escaping () -> Set?) { + init(openPeer: @escaping (EnginePeer, EnginePeer?, Bool) -> Void, openDisabledPeer: @escaping (EnginePeer) -> Void, openMessage: @escaping (EnginePeer, EngineMessage.Id, Bool) -> Void, openUrl: @escaping (String) -> Void, clearRecentSearch: @escaping () -> Void, addContact: @escaping (String) -> Void, toggleMessageSelection: @escaping (EngineMessage.Id, Bool) -> Void, messageContextAction: @escaping ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?, ChatListSearchPaneKey, (id: String, size: Int, isFirstInList: Bool)?) -> Void), mediaMessageContextAction: @escaping ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?) -> Void), peerContextAction: ((EnginePeer, ChatListSearchContextActionSource, ASDisplayNode, ContextGesture?) -> Void)?, present: @escaping (ViewController, Any?) -> Void, dismissInput: @escaping () -> Void, getSelectedMessageIds: @escaping () -> Set?) { self.openPeer = openPeer self.openDisabledPeer = openDisabledPeer self.openMessage = openMessage @@ -112,8 +113,8 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo private var stateValue = ChatListSearchContainerNodeSearchState() private let statePromise = ValuePromise() - private var selectedFilterKey: ChatListSearchFilterEntryId? - private var selectedFilterKeyPromise = Promise() + private var selectedFilter: ChatListSearchFilterEntry? + private var selectedFilterPromise = Promise() private var transitionFraction: CGFloat = 0.0 private weak var copyProtectionTooltipController: TooltipController? @@ -134,8 +135,8 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo self.navigationController = navigationController self.presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 } - self.selectedFilterKey = .filter(initialFilter.id) - self.selectedFilterKeyPromise.set(.single(self.selectedFilterKey)) + self.selectedFilter = .filter(initialFilter) + self.selectedFilterPromise.set(.single(self.selectedFilter)) self.openMessage = originalOpenMessage self.present = present @@ -217,8 +218,8 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo return state.withUpdatedSelectedMessageIds(selectedMessageIds) } } - }, messageContextAction: { [weak self] message, node, rect, gesture in - self?.messageContextAction(message, node: node, rect: rect, gesture: gesture) + }, messageContextAction: { [weak self] message, node, rect, gesture, paneKey, downloadResource in + self?.messageContextAction(message, node: node, rect: rect, gesture: gesture, paneKey: paneKey, downloadResource: downloadResource) }, mediaMessageContextAction: { [weak self] message, node, rect, gesture in self?.mediaMessageContextAction(message, node: node, rect: rect, gesture: gesture) }, peerContextAction: { peer, source, node, gesture in @@ -244,6 +245,8 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo filterKey = .chats case .media: filterKey = .media + case .downloads: + filterKey = .downloads case .links: filterKey = .links case .files: @@ -253,8 +256,8 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo case .voice: filterKey = .voice } - strongSelf.selectedFilterKey = .filter(filterKey.id) - strongSelf.selectedFilterKeyPromise.set(.single(strongSelf.selectedFilterKey)) + strongSelf.selectedFilter = .filter(filterKey) + strongSelf.selectedFilterPromise.set(.single(strongSelf.selectedFilter)) strongSelf.transitionFraction = transitionFraction if let (layout, _) = strongSelf.validLayout { @@ -262,9 +265,9 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo if let suggestedFilters = strongSelf.suggestedFilters, !suggestedFilters.isEmpty { filters = suggestedFilters } else { - filters = [.chats, .media, .links, .files, .music, .voice] + filters = [.chats, .media, .downloads, .links, .files, .music, .voice] } - strongSelf.filterContainerNode.update(size: CGSize(width: layout.size.width - 40.0, height: 38.0), sideInset: layout.safeInsets.left - 20.0, filters: filters.map { .filter($0) }, selectedFilter: strongSelf.selectedFilterKey, transitionFraction: strongSelf.transitionFraction, presentationData: strongSelf.presentationData, transition: transition) + strongSelf.filterContainerNode.update(size: CGSize(width: layout.size.width - 40.0, height: 38.0), sideInset: layout.safeInsets.left - 20.0, filters: filters.map { .filter($0) }, selectedFilter: strongSelf.selectedFilter?.id, transitionFraction: strongSelf.transitionFraction, presentationData: strongSelf.presentationData, transition: transition) } } } @@ -283,6 +286,8 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo key = .chats case .media: key = .media + case .downloads: + key = .downloads case .links: key = .links case .files: @@ -306,7 +311,31 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo self.filterContainerNode.filterPressed?(initialFilter) - let suggestedPeers = self.searchQuery.get() + let searchQuerySignal = self.searchQuery.get() + + let suggestedPeers = self.selectedFilterPromise.get() + |> map { filter -> Bool in + guard let filter = filter else { + return false + } + switch filter { + case let .filter(filter): + switch filter { + case .downloads: + return false + default: + return true + } + } + } + |> distinctUntilChanged + |> mapToSignal { value -> Signal in + if value { + return searchQuerySignal + } else { + return .single(nil) + } + } |> mapToSignal { query -> Signal<[EnginePeer], NoError> in if let query = query { return context.account.postbox.searchPeers(query: query.lowercased()) @@ -321,13 +350,13 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo let accountPeer = self.context.account.postbox.loadedPeerWithId(self.context.account.peerId) |> take(1) - self.suggestedFiltersDisposable.set((combineLatest(suggestedPeers, self.suggestedDates.get(), self.selectedFilterKeyPromise.get(), self.searchQuery.get(), accountPeer) + self.suggestedFiltersDisposable.set((combineLatest(suggestedPeers, self.suggestedDates.get(), self.selectedFilterPromise.get(), self.searchQuery.get(), accountPeer) |> mapToSignal { peers, dates, selectedFilter, searchQuery, accountPeer -> Signal<([EnginePeer], [(Date?, Date, String?)], ChatListSearchFilterEntryId?, String?, EnginePeer?), NoError> in if searchQuery?.isEmpty ?? true { - return .single((peers, dates, selectedFilter, searchQuery, EnginePeer(accountPeer))) + return .single((peers, dates, selectedFilter?.id, searchQuery, EnginePeer(accountPeer))) } else { return (.complete() |> delay(0.25, queue: Queue.mainQueue())) - |> then(.single((peers, dates, selectedFilter, searchQuery, EnginePeer(accountPeer)))) + |> then(.single((peers, dates, selectedFilter?.id, searchQuery, EnginePeer(accountPeer)))) } } |> map { peers, dates, selectedFilter, searchQuery, accountPeer -> [ChatListSearchFilter] in var suggestedFilters: [ChatListSearchFilter] = [] @@ -523,6 +552,8 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo key = .music case .voice: key = .voice + case .downloads: + key = .downloads default: key = .chats } @@ -549,11 +580,11 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo if let suggestedFilters = self.suggestedFilters, !suggestedFilters.isEmpty { filters = suggestedFilters } else { - filters = [.chats, .media, .links, .files, .music, .voice] + filters = [.chats, .media, .downloads, .links, .files, .music, .voice] } let overflowInset: CGFloat = 20.0 - self.filterContainerNode.update(size: CGSize(width: layout.size.width - overflowInset * 2.0, height: 38.0), sideInset: layout.safeInsets.left - overflowInset, filters: filters.map { .filter($0) }, selectedFilter: self.selectedFilterKey, transitionFraction: self.transitionFraction, presentationData: self.presentationData, transition: .animated(duration: 0.4, curve: .spring)) + self.filterContainerNode.update(size: CGSize(width: layout.size.width - overflowInset * 2.0, height: 38.0), sideInset: layout.safeInsets.left - overflowInset, filters: filters.map { .filter($0) }, selectedFilter: self.selectedFilter?.id, transitionFraction: self.transitionFraction, presentationData: self.presentationData, transition: .animated(duration: 0.4, curve: .spring)) var bottomIntrinsicInset = layout.intrinsicInsets.bottom if case .root = self.groupId { @@ -737,10 +768,154 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo let _ = self.paneContainerNode.scrollToTop() } - private func messageContextAction(_ message: EngineMessage, node: ASDisplayNode?, rect: CGRect?, gesture anyRecognizer: UIGestureRecognizer?) { + private func messageContextAction(_ message: EngineMessage, node: ASDisplayNode?, rect: CGRect?, gesture anyRecognizer: UIGestureRecognizer?, paneKey: ChatListSearchPaneKey, downloadResource: (id: String, size: Int, isFirstInList: Bool)?) { guard let node = node as? ContextExtractedContentContainingNode else { return } + + let gesture: ContextGesture? = anyRecognizer as? ContextGesture + + if paneKey == .downloads { + let isCachedValue: Signal + if let downloadResource = downloadResource { + isCachedValue = self.context.account.postbox.mediaBox.resourceStatus(MediaResourceId(downloadResource.id), resourceSize: downloadResource.size) + |> map { status -> Bool in + switch status { + case .Local: + return true + default: + return false + } + } + |> distinctUntilChanged + } else { + isCachedValue = .single(false) + } + + let shouldBeDismissed: Signal = Signal { subscriber in + subscriber.putNext(false) + let previous = Atomic(value: nil) + return isCachedValue.start(next: { value in + let previousSwapped = previous.swap(value) + if let previousSwapped = previousSwapped, previousSwapped != value { + subscriber.putNext(true) + subscriber.putCompletion() + } + }) + } + + let items = combineLatest(queue: .mainQueue(), + context.sharedContext.chatAvailableMessageActions(postbox: context.account.postbox, accountPeerId: context.account.peerId, messageIds: [message.id], messages: [message.id: message], peers: [:]), + isCachedValue |> take(1) + ) + |> deliverOnMainQueue + |> map { [weak self] actions, isCachedValue -> [ContextMenuItem] in + guard let strongSelf = self else { + return [] + } + + var items: [ContextMenuItem] = [] + + if isCachedValue { + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Delete from Cache", textColor: .primary, icon: { _ in + return nil + }, action: { _, f in + guard let strongSelf = self, let downloadResource = downloadResource else { + f(.default) + return + } + let _ = (strongSelf.context.account.postbox.mediaBox.removeCachedResources([MediaResourceId(downloadResource.id)], notify: true) + |> deliverOnMainQueue).start(completed: { + f(.dismissWithoutContent) + }) + }))) + } else { + if let downloadResource = downloadResource, !downloadResource.isFirstInList { + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Raise Priority", textColor: .primary, icon: { _ in + return nil + }, action: { _, f in + guard let strongSelf = self else { + f(.default) + return + } + + strongSelf.context.fetchManager.raisePriority(resourceId: downloadResource.id) + + Queue.mainQueue().after(0.1, { + f(.default) + }) + }))) + } + + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Cancel Downloading", textColor: .primary, icon: { _ in + return nil + }, action: { _, f in + guard let strongSelf = self, let downloadResource = downloadResource else { + f(.default) + return + } + + strongSelf.context.fetchManager.cancelInteractiveFetches(resourceId: downloadResource.id) + + f(.default) + }))) + } + + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.SharedMedia_ViewInChat, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in + c.dismiss(completion: { [weak self] in + self?.openMessage(EnginePeer(message.peers[message.id.peerId]!), message.id, false) + }) + }))) + + if !actions.options.intersection([.deleteLocally, .deleteGlobally]).isEmpty { + if !items.isEmpty { + items.append(.separator) + } + + if actions.options.contains(.deleteLocally) { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Conversation_DeleteMessagesForMe, textColor: .destructive, icon: { _ in + return nil + }, action: { controller, f in + guard let strongSelf = self else { + return + } + let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: [message.id], type: .forLocalPeer).start() + f(.dismissWithoutContent) + }))) + } + + if actions.options.contains(.deleteGlobally) { + let text: String + if let mainPeer = message.peers[message.id.peerId] { + if mainPeer is TelegramUser { + text = strongSelf.presentationData.strings.ChatList_DeleteForEveryone(EnginePeer(mainPeer).compactDisplayTitle).string + } else { + text = strongSelf.presentationData.strings.Conversation_DeleteMessagesForEveryone + } + } else { + text = strongSelf.presentationData.strings.Conversation_DeleteMessagesForEveryone + } + items.append(.action(ContextMenuActionItem(text: text, textColor: .destructive, icon: { _ in + return nil + }, action: { controller, f in + let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: [message.id], type: .forEveryone).start() + f(.dismissWithoutContent) + }))) + } + } + + return items + } + + let controller = ContextController(account: self.context.account, presentationData: self.presentationData, source: .extracted(MessageContextExtractedContentSource(sourceNode: node, shouldBeDismissed: shouldBeDismissed)), items: items |> map { ContextController.Items(content: .list($0)) }, recognizer: nil, gesture: gesture) + self.presentInGlobalOverlay?(controller, nil) + + return + } + let _ = storedMessageFromSearch(account: self.context.account, message: message._asMessage()).start() var linkForCopying: String? @@ -756,8 +931,6 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo } } - let gesture: ContextGesture? = anyRecognizer as? ContextGesture - let context = self.context let (peers, messages) = self.currentMessages let items = context.sharedContext.chatAvailableMessageActions(postbox: context.account.postbox, accountPeerId: context.account.peerId, messageIds: [message.id], messages: messages, peers: peers) @@ -1150,10 +1323,13 @@ private final class MessageContextExtractedContentSource: ContextExtractedConten let ignoreContentTouches: Bool = true let blurBackground: Bool = true + let shouldBeDismissed: Signal + private let sourceNode: ContextExtractedContentContainingNode - init(sourceNode: ContextExtractedContentContainingNode) { + init(sourceNode: ContextExtractedContentContainingNode, shouldBeDismissed: Signal? = nil) { self.sourceNode = sourceNode + self.shouldBeDismissed = shouldBeDismissed ?? .single(false) } func takeView() -> ContextControllerTakeViewInfo? { diff --git a/submodules/ChatListUI/Sources/ChatListSearchFiltersContainerNode.swift b/submodules/ChatListUI/Sources/ChatListSearchFiltersContainerNode.swift index d3ccd0b522..5a9e169af5 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchFiltersContainerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchFiltersContainerNode.swift @@ -84,6 +84,10 @@ private final class ItemNode: ASDisplayNode { case .media: title = presentationData.strings.ChatList_Search_FilterMedia icon = nil + case .downloads: + //TODO:localize + title = "Downloads" + icon = nil case .links: title = presentationData.strings.ChatList_Search_FilterLinks icon = nil diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index 63f14b6aaa..811935d1df 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -26,6 +26,8 @@ import AppBundle import ShimmerEffect import ChatListSearchRecentPeersNode import UndoUI +import Postbox +import FetchManagerImpl private enum ChatListRecentEntryStableId: Hashable { case topPeers @@ -217,7 +219,7 @@ private enum ChatListRecentEntry: Comparable, Identifiable { public enum ChatListSearchEntryStableId: Hashable { case localPeerId(EnginePeer.Id) case globalPeerId(EnginePeer.Id) - case messageId(EngineMessage.Id) + case messageId(EngineMessage.Id, ChatListSearchEntry.MessageSection) case addContact } @@ -228,9 +230,54 @@ public enum ChatListSearchSectionExpandType { } public enum ChatListSearchEntry: Comparable, Identifiable { + public enum MessageOrderingKey: Comparable { + case index(MessageIndex) + case downloading(FetchManagerPriorityKey) + case downloaded(timestamp: Int32, index: MessageIndex) + + public static func <(lhs: MessageOrderingKey, rhs: MessageOrderingKey) -> Bool { + switch lhs { + case let .index(lhsIndex): + if case let .index(rhsIndex) = rhs { + return lhsIndex > rhsIndex + } else { + return true + } + case let .downloading(lhsKey): + switch rhs { + case let .downloading(rhsKey): + return lhsKey < rhsKey + case .index: + return false + case .downloaded: + return true + } + case let .downloaded(lhsTimestamp, lhsIndex): + switch rhs { + case let .downloaded(rhsTimestamp, rhsIndex): + if lhsTimestamp != rhsTimestamp { + return lhsTimestamp > rhsTimestamp + } else { + return lhsIndex > rhsIndex + } + case .downloading: + return false + case .index: + return false + } + } + } + } + + public enum MessageSection: Hashable { + case generic + case downloading + case recentlyDownloaded + } + case localPeer(EnginePeer, EnginePeer?, (Int32, Bool)?, Int, PresentationTheme, PresentationStrings, PresentationPersonNameOrder, PresentationPersonNameOrder, ChatListSearchSectionExpandType) case globalPeer(FoundPeer, (Int32, Bool)?, Int, PresentationTheme, PresentationStrings, PresentationPersonNameOrder, PresentationPersonNameOrder, ChatListSearchSectionExpandType) - case message(EngineMessage, EngineRenderedPeer, EnginePeerReadCounters?, ChatListPresentationData, Int32, Bool?, Bool) + case message(EngineMessage, EngineRenderedPeer, EnginePeerReadCounters?, ChatListPresentationData, Int32, Bool?, Bool, MessageOrderingKey, (id: String, size: Int, isFirstInList: Bool)?, MessageSection, Bool) case addContact(String, PresentationTheme, PresentationStrings) public var stableId: ChatListSearchEntryStableId { @@ -239,8 +286,8 @@ public enum ChatListSearchEntry: Comparable, Identifiable { return .localPeerId(peer.id) case let .globalPeer(peer, _, _, _, _, _, _, _): return .globalPeerId(peer.peer.id) - case let .message(message, _, _, _, _, _, _): - return .messageId(message.id) + case let .message(message, _, _, _, _, _, _, _, _, section, _): + return .messageId(message.id, section) case .addContact: return .addContact } @@ -260,8 +307,8 @@ public enum ChatListSearchEntry: Comparable, Identifiable { } else { return false } - case let .message(lhsMessage, lhsPeer, lhsCombinedPeerReadState, lhsPresentationData, lhsTotalCount, lhsSelected, lhsDisplayCustomHeader): - if case let .message(rhsMessage, rhsPeer, rhsCombinedPeerReadState, rhsPresentationData, rhsTotalCount, rhsSelected, rhsDisplayCustomHeader) = rhs { + case let .message(lhsMessage, lhsPeer, lhsCombinedPeerReadState, lhsPresentationData, lhsTotalCount, lhsSelected, lhsDisplayCustomHeader, lhsKey, lhsResourceId, lhsSection, lhsAllPaused): + if case let .message(rhsMessage, rhsPeer, rhsCombinedPeerReadState, rhsPresentationData, rhsTotalCount, rhsSelected, rhsDisplayCustomHeader, rhsKey, rhsResourceId, rhsSection, rhsAllPaused) = rhs { if lhsMessage.id != rhsMessage.id { return false } @@ -286,6 +333,21 @@ public enum ChatListSearchEntry: Comparable, Identifiable { if lhsDisplayCustomHeader != rhsDisplayCustomHeader { return false } + if lhsKey != rhsKey { + return false + } + if lhsResourceId?.0 != rhsResourceId?.0 { + return false + } + if lhsResourceId?.1 != rhsResourceId?.1 { + return false + } + if lhsSection != rhsSection { + return false + } + if lhsAllPaused != rhsAllPaused { + return false + } return true } else { return false @@ -325,9 +387,9 @@ public enum ChatListSearchEntry: Comparable, Identifiable { case .message, .addContact: return true } - case let .message(lhsMessage, _, _, _, _, _, _): - if case let .message(rhsMessage, _, _, _, _, _, _) = rhs { - return lhsMessage.index < rhsMessage.index + case let .message(_, _, _, _, _, _, _, lhsKey, _, _, _): + if case let .message(_, _, _, _, _, _, _, rhsKey, _, _, _) = rhs { + return lhsKey < rhsKey } else if case .addContact = rhs { return true } else { @@ -338,7 +400,7 @@ public enum ChatListSearchEntry: Comparable, Identifiable { } } - public func item(context: AccountContext, presentationData: PresentationData, enableHeaders: Bool, filter: ChatListNodePeersFilter, tagMask: EngineMessage.Tags?, interaction: ChatListNodeInteraction, listInteraction: ListMessageItemInteraction, peerContextAction: ((EnginePeer, ChatListSearchContextActionSource, ASDisplayNode, ContextGesture?) -> Void)?, toggleExpandLocalResults: @escaping () -> Void, toggleExpandGlobalResults: @escaping () -> Void, searchPeer: @escaping (EnginePeer) -> Void, searchQuery: String?, searchOptions: ChatListSearchOptions?, messageContextAction: ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?) -> Void)?) -> ListViewItem { + public func item(context: AccountContext, presentationData: PresentationData, enableHeaders: Bool, filter: ChatListNodePeersFilter, key: ChatListSearchPaneKey, tagMask: EngineMessage.Tags?, interaction: ChatListNodeInteraction, listInteraction: ListMessageItemInteraction, peerContextAction: ((EnginePeer, ChatListSearchContextActionSource, ASDisplayNode, ContextGesture?) -> Void)?, toggleExpandLocalResults: @escaping () -> Void, toggleExpandGlobalResults: @escaping () -> Void, searchPeer: @escaping (EnginePeer) -> Void, searchQuery: String?, searchOptions: ChatListSearchOptions?, messageContextAction: ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?, ChatListSearchPaneKey, (id: String, size: Int, isFirstInList: Bool)?) -> Void)?, openStorageSettings: @escaping () -> Void, toggleAllPaused: @escaping () -> Void) -> ListViewItem { switch self { case let .localPeer(peer, associatedPeer, unreadBadge, _, theme, strings, nameSortOrder, nameDisplayOrder, expandType): let primaryPeer: EnginePeer @@ -486,11 +548,37 @@ public enum ChatListSearchEntry: Comparable, Identifiable { peerContextAction(EnginePeer(peer.peer), .search(nil), node, gesture) } }) - case let .message(message, peer, readState, presentationData, _, selected, displayCustomHeader): - let header = ChatListSearchItemHeader(type: .messages, theme: presentationData.theme, strings: presentationData.strings, actionTitle: nil, action: nil) + case let .message(message, peer, readState, presentationData, _, selected, displayCustomHeader, orderingKey, _, _, allPaused): + let header: ChatListSearchItemHeader + switch orderingKey { + case .downloading: + //TODO:localize + if allPaused { + header = ChatListSearchItemHeader(type: .downloading, theme: presentationData.theme, strings: presentationData.strings, actionTitle: "Resume All", action: { + toggleAllPaused() + }) + } else { + header = ChatListSearchItemHeader(type: .downloading, theme: presentationData.theme, strings: presentationData.strings, actionTitle: "Pause All", action: { + toggleAllPaused() + }) + } + case .downloaded: + //TODO:localize + header = ChatListSearchItemHeader(type: .recentDownloads, theme: presentationData.theme, strings: presentationData.strings, actionTitle: "Settings", action: { + openStorageSettings() + }) + case .index: + header = ChatListSearchItemHeader(type: .messages, theme: presentationData.theme, strings: presentationData.strings, actionTitle: nil, action: nil) + } let selection: ChatHistoryMessageSelection = selected.flatMap { .selectable(selected: $0) } ?? .none + var isMedia = false if let tagMask = tagMask, tagMask != .photoOrVideo { - return ListMessageItem(presentationData: ChatPresentationData(theme: ChatPresentationThemeData(theme: presentationData.theme, wallpaper: .builtin(WallpaperSettings())), fontSize: presentationData.fontSize, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, disableAnimations: true, largeEmoji: false, chatBubbleCorners: PresentationChatBubbleCorners(mainRadius: 0.0, auxiliaryRadius: 0.0, mergeBubbleCorners: false)), context: context, chatLocation: .peer(peer.peerId), interaction: listInteraction, message: message._asMessage(), selection: selection, displayHeader: enableHeaders && !displayCustomHeader, customHeader: nil, hintIsLink: tagMask == .webPage, isGlobalSearchResult: true) + isMedia = true + } else if key == .downloads { + isMedia = true + } + if isMedia { + return ListMessageItem(presentationData: ChatPresentationData(theme: ChatPresentationThemeData(theme: presentationData.theme, wallpaper: .builtin(WallpaperSettings())), fontSize: presentationData.fontSize, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, disableAnimations: true, largeEmoji: false, chatBubbleCorners: PresentationChatBubbleCorners(mainRadius: 0.0, auxiliaryRadius: 0.0, mergeBubbleCorners: false)), context: context, chatLocation: .peer(peer.peerId), interaction: listInteraction, message: message._asMessage(), selection: selection, displayHeader: enableHeaders && !displayCustomHeader, customHeader: key == .downloads ? header : nil, hintIsLink: tagMask == .webPage, isGlobalSearchResult: key != .downloads, isDownloadList: key == .downloads) } else { return ChatListItem(presentationData: presentationData, context: context, peerGroupId: .root, filterData: nil, index: EngineChatList.Item.Index(pinningIndex: nil, messageIndex: message.index), content: .peer(messages: [message], peer: peer, combinedReadState: readState, isRemovedFromTotalUnreadCount: false, presence: nil, hasUnseenMentions: false, hasUnseenReactions: false, draftState: nil, inputActivities: nil, promoInfo: nil, ignoreUnreadBadge: true, displayAsMessage: false, hasFailedMessages: false), editing: false, hasActiveRevealControls: false, selected: false, header: tagMask == nil ? header : nil, enableContextActions: false, hiddenOffset: false, interaction: interaction) } @@ -516,7 +604,7 @@ public struct ChatListSearchContainerTransition { public let isEmpty: Bool public let isLoading: Bool public let query: String? - public let animated: Bool + public var animated: Bool public init(deletions: [ListViewDeleteItem], insertions: [ListViewInsertItem], updates: [ListViewUpdateItem], displayingResults: Bool, isEmpty: Bool, isLoading: Bool, query: String?, animated: Bool) { self.deletions = deletions @@ -540,12 +628,12 @@ private func chatListSearchContainerPreparedRecentTransition(from fromEntries: [ return ChatListSearchContainerRecentTransition(deletions: deletions, insertions: insertions, updates: updates) } -public func chatListSearchContainerPreparedTransition(from fromEntries: [ChatListSearchEntry], to toEntries: [ChatListSearchEntry], displayingResults: Bool, isEmpty: Bool, isLoading: Bool, animated: Bool, context: AccountContext, presentationData: PresentationData, enableHeaders: Bool, filter: ChatListNodePeersFilter, tagMask: EngineMessage.Tags?, interaction: ChatListNodeInteraction, listInteraction: ListMessageItemInteraction, peerContextAction: ((EnginePeer, ChatListSearchContextActionSource, ASDisplayNode, ContextGesture?) -> Void)?, toggleExpandLocalResults: @escaping () -> Void, toggleExpandGlobalResults: @escaping () -> Void, searchPeer: @escaping (EnginePeer) -> Void, searchQuery: String?, searchOptions: ChatListSearchOptions?, messageContextAction: ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?) -> Void)?) -> ChatListSearchContainerTransition { +public func chatListSearchContainerPreparedTransition(from fromEntries: [ChatListSearchEntry], to toEntries: [ChatListSearchEntry], displayingResults: Bool, isEmpty: Bool, isLoading: Bool, animated: Bool, context: AccountContext, presentationData: PresentationData, enableHeaders: Bool, filter: ChatListNodePeersFilter, key: ChatListSearchPaneKey, tagMask: EngineMessage.Tags?, interaction: ChatListNodeInteraction, listInteraction: ListMessageItemInteraction, peerContextAction: ((EnginePeer, ChatListSearchContextActionSource, ASDisplayNode, ContextGesture?) -> Void)?, toggleExpandLocalResults: @escaping () -> Void, toggleExpandGlobalResults: @escaping () -> Void, searchPeer: @escaping (EnginePeer) -> Void, searchQuery: String?, searchOptions: ChatListSearchOptions?, messageContextAction: ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?, ChatListSearchPaneKey, (id: String, size: Int, isFirstInList: Bool)?) -> Void)?, openStorageSettings: @escaping () -> Void, toggleAllPaused: @escaping () -> Void) -> ChatListSearchContainerTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } - let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enableHeaders: enableHeaders, filter: filter, tagMask: tagMask, interaction: interaction, listInteraction: listInteraction, peerContextAction: peerContextAction, toggleExpandLocalResults: toggleExpandLocalResults, toggleExpandGlobalResults: toggleExpandGlobalResults, searchPeer: searchPeer, searchQuery: searchQuery, searchOptions: searchOptions, messageContextAction: messageContextAction), directionHint: nil) } - let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enableHeaders: enableHeaders, filter: filter, tagMask: tagMask, interaction: interaction, listInteraction: listInteraction, peerContextAction: peerContextAction, toggleExpandLocalResults: toggleExpandLocalResults, toggleExpandGlobalResults: toggleExpandGlobalResults, searchPeer: searchPeer, searchQuery: searchQuery, searchOptions: searchOptions, messageContextAction: messageContextAction), directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enableHeaders: enableHeaders, filter: filter, key: key, tagMask: tagMask, interaction: interaction, listInteraction: listInteraction, peerContextAction: peerContextAction, toggleExpandLocalResults: toggleExpandLocalResults, toggleExpandGlobalResults: toggleExpandGlobalResults, searchPeer: searchPeer, searchQuery: searchQuery, searchOptions: searchOptions, messageContextAction: messageContextAction, openStorageSettings: openStorageSettings, toggleAllPaused: toggleAllPaused), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enableHeaders: enableHeaders, filter: filter, key: key, tagMask: tagMask, interaction: interaction, listInteraction: listInteraction, peerContextAction: peerContextAction, toggleExpandLocalResults: toggleExpandLocalResults, toggleExpandGlobalResults: toggleExpandGlobalResults, searchPeer: searchPeer, searchQuery: searchQuery, searchOptions: searchOptions, messageContextAction: messageContextAction, openStorageSettings: openStorageSettings, toggleAllPaused: toggleAllPaused), directionHint: nil) } return ChatListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, displayingResults: displayingResults, isEmpty: isEmpty, isLoading: isLoading, query: searchQuery, animated: animated) } @@ -617,6 +705,29 @@ public struct ChatListSearchOptions { } } +private struct DownloadItem: Equatable { + let resourceId: MediaResourceId + let message: Message + let priority: FetchManagerPriorityKey + let isPaused: Bool + + static func ==(lhs: DownloadItem, rhs: DownloadItem) -> Bool { + if lhs.resourceId != rhs.resourceId { + return false + } + if lhs.message.id != rhs.message.id { + return false + } + if lhs.priority != rhs.priority { + return false + } + if lhs.isPaused != rhs.isPaused { + return false + } + return true + } +} + final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { private let context: AccountContext private let interaction: ChatListSearchInteraction @@ -704,6 +815,8 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { tagMask = nil case .media: tagMask = .photoOrVideo + case .downloads: + tagMask = nil case .links: tagMask = .webPage case .files: @@ -811,13 +924,135 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { let presentationDataPromise = self.presentationDataPromise let searchStatePromise = self.searchStatePromise let selectionPromise = self.selectedMessagesPromise - let foundItems = combineLatest(searchQuery, searchOptions) - |> mapToSignal { query, options -> Signal<([ChatListSearchEntry], Bool)?, NoError> in - if query == nil && options == nil && tagMask == nil { + + let downloadItems: Signal<(inProgressItems: [DownloadItem], doneItems: [RenderedRecentDownloadItem]), NoError> + if key == .downloads { + downloadItems = combineLatest(queue: .mainQueue(), (context.fetchManager as! FetchManagerImpl).entriesSummary, recentDownloadItems(postbox: context.account.postbox)) + |> mapToSignal { entries, recentDownloadItems -> Signal<(inProgressItems: [DownloadItem], doneItems: [RenderedRecentDownloadItem]), NoError> in + var itemSignals: [Signal] = [] + + for entry in entries { + switch entry.id.locationKey { + case let .messageId(id): + itemSignals.append(context.account.postbox.transaction { transaction -> DownloadItem? in + if let message = transaction.getMessage(id) { + return DownloadItem(resourceId: entry.resourceReference.resource.id, message: message, priority: entry.priority, isPaused: entry.isPaused) + } + return nil + }) + default: + break + } + } + + return combineLatest(queue: .mainQueue(), itemSignals) + |> map { items -> (inProgressItems: [DownloadItem], doneItems: [RenderedRecentDownloadItem]) in + return (items.compactMap { $0 }, recentDownloadItems) + } + } + } else { + downloadItems = .single(([], [])) + } + + let foundItems = combineLatest(queue: .mainQueue(), searchQuery, searchOptions, downloadItems) + |> mapToSignal { [weak self] query, options, downloadItems -> Signal<([ChatListSearchEntry], Bool)?, NoError> in + if query == nil && options == nil && key == .chats { let _ = currentRemotePeers.swap(nil) return .single(nil) } + if key == .downloads { + let queryTokens = stringIndexTokens(query ?? "", transliteration: .combined) + + func messageMatchesTokens(message: EngineMessage, tokens: [ValueBoxKey]) -> Bool { + for media in message.media { + if let file = media as? TelegramMediaFile { + if let fileName = file.fileName { + if matchStringIndexTokens(stringIndexTokens(fileName, transliteration: .none), with: tokens) { + return true + } + } + } else if let _ = media as? TelegramMediaImage { + if matchStringIndexTokens(stringIndexTokens("Photo Image", transliteration: .none), with: tokens) { + return true + } + } + } + return false + } + + return presentationDataPromise.get() + |> map { presentationData -> ([ChatListSearchEntry], Bool)? in + var entries: [ChatListSearchEntry] = [] + var existingMessageIds = Set() + + var allPaused = true + for item in downloadItems.inProgressItems { + if !item.isPaused { + allPaused = false + break + } + } + + for item in downloadItems.inProgressItems.sorted(by: { $0.priority < $1.priority }) { + if existingMessageIds.contains(item.message.id) { + continue + } + existingMessageIds.insert(item.message.id) + + let message = EngineMessage(item.message) + + if !queryTokens.isEmpty { + if !messageMatchesTokens(message: message, tokens: queryTokens) { + continue + } + } + + var peer = EngineRenderedPeer(message: message) + if let group = item.message.peers[message.id.peerId] as? TelegramGroup, let migrationReference = group.migrationReference { + if let channelPeer = message.peers[migrationReference.peerId] { + peer = EngineRenderedPeer(peer: EnginePeer(channelPeer)) + } + } + + var resource: (id: String, size: Int, isFirstInList: Bool)? + if let resourceValue = findMediaResourceById(message: item.message, resourceId: item.resourceId), let size = resourceValue.size { + resource = (resourceValue.id.stringRepresentation, size, entries.isEmpty) + } + + entries.append(.message(message, peer, nil, presentationData, 1, nil, false, .downloading(item.priority), resource, .downloading, allPaused)) + } + for item in downloadItems.doneItems.sorted(by: { ChatListSearchEntry.MessageOrderingKey.downloaded(timestamp: $0.timestamp, index: $0.message.index) < ChatListSearchEntry.MessageOrderingKey.downloaded(timestamp: $1.timestamp, index: $1.message.index) }) { + if !item.isSeen { + Queue.mainQueue().async { + self?.scheduleMarkRecentDownloadsAsSeen() + } + } + if existingMessageIds.contains(item.message.id) { + continue + } + existingMessageIds.insert(item.message.id) + + let message = EngineMessage(item.message) + + if !queryTokens.isEmpty { + if !messageMatchesTokens(message: message, tokens: queryTokens) { + continue + } + } + + var peer = EngineRenderedPeer(message: message) + if let group = item.message.peers[message.id.peerId] as? TelegramGroup, let migrationReference = group.migrationReference { + if let channelPeer = message.peers[migrationReference.peerId] { + peer = EngineRenderedPeer(peer: EnginePeer(channelPeer)) + } + } + entries.append(.message(message, peer, nil, presentationData, 1, nil, false, .downloaded(timestamp: item.timestamp, index: message.index), (item.resourceId, item.size, false), .recentlyDownloaded, false)) + } + return (entries.sorted(), false) + } + } + let accountPeer = context.account.postbox.loadedPeerWithId(context.account.peerId) |> take(1) let foundLocalPeers: Signal<(peers: [EngineRenderedPeer], unread: [EnginePeer.Id: (Int32, Bool)]), NoError> if let query = query { @@ -1108,7 +1343,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { peer = EngineRenderedPeer(peer: EnginePeer(channelPeer)) } } - entries.append(.message(message, peer, nil, presentationData, 1, nil, true)) + entries.append(.message(message, peer, nil, presentationData, 1, nil, true, .index(message.index), nil, .generic, false)) index += 1 } @@ -1131,7 +1366,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { peer = EngineRenderedPeer(peer: EnginePeer(channelPeer)) } } - entries.append(.message(message, peer, foundRemoteMessages.0.1[message.id.peerId], presentationData, foundRemoteMessages.0.2, selectionState?.contains(message.id), headerId == firstHeaderId)) + entries.append(.message(message, peer, foundRemoteMessages.0.1[message.id.peerId], presentationData, foundRemoteMessages.0.2, selectionState?.contains(message.id), headerId == firstHeaderId, .index(message.index), nil, .generic, false)) index += 1 } } @@ -1249,8 +1484,24 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { }) let listInteraction = ListMessageItemInteraction(openMessage: { [weak self] message, mode -> Bool in + guard let strongSelf = self else { + return false + } interaction.dismissInput() - return context.sharedContext.openChatMessage(OpenChatMessageParams(context: context, chatLocation: nil, chatLocationContextHolder: nil, message: message, standalone: false, reverseMessageGalleryOrder: true, mode: mode, navigationController: navigationController, dismissInput: { + + let gallerySource: GalleryControllerItemSource + + if strongSelf.key == .downloads { + gallerySource = .peerMessagesAtId(messageId: message.id, chatLocation: .peer(message.id.peerId), chatLocationContextHolder: Atomic(value: nil)) + } else { + gallerySource = .custom(messages: foundMessages |> map { message, a, b in + return (message.map { $0._asMessage() }, a, b) + }, messageId: message.id, loadMore: { + loadMore() + }) + } + + return context.sharedContext.openChatMessage(OpenChatMessageParams(context: context, chatLocation: .peer(message.id.peerId), chatLocationContextHolder: nil, message: message, standalone: false, reverseMessageGalleryOrder: true, mode: mode, navigationController: navigationController, dismissInput: { interaction.dismissInput() }, present: { c, a in interaction.present(c, a) @@ -1277,13 +1528,25 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { return (message.map { $0._asMessage() }, a, b) }, at: message.id, loadMore: { loadMore() - }), gallerySource: .custom(messages: foundMessages |> map { message, a, b in - return (message.map { $0._asMessage() }, a, b) - }, messageId: message.id, loadMore: { - loadMore() - }))) - }, openMessageContextMenu: { message, _, node, rect, gesture in - interaction.messageContextAction(EngineMessage(message), node, rect, gesture) + }), gallerySource: gallerySource)) + }, openMessageContextMenu: { [weak self] message, _, node, rect, gesture in + guard let strongSelf = self, let currentEntries = strongSelf.currentEntries else { + return + } + + var fetchResourceId: (id: String, size: Int, isFirstInList: Bool)? + for entry in currentEntries { + switch entry { + case let .message(m, _, _, _, _, _, _, _, resource, _, _): + if m.id == message.id { + fetchResourceId = resource + } + default: + break + } + } + + interaction.messageContextAction(EngineMessage(message), node, rect, gesture, key, fetchResourceId) }, toggleMessagesSelection: { messageId, selected in if let messageId = messageId.first { interaction.toggleMessageSelection(messageId, selected) @@ -1355,7 +1618,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { let animated = (previousSelectedMessageIds == nil) != (strongSelf.selectedMessages == nil) let firstTime = previousEntries == nil - let transition = chatListSearchContainerPreparedTransition(from: previousEntries ?? [], to: newEntries, displayingResults: entriesAndFlags?.0 != nil, isEmpty: !isSearching && (entriesAndFlags?.0.isEmpty ?? false), isLoading: isSearching, animated: animated, context: context, presentationData: strongSelf.presentationData, enableHeaders: true, filter: peersFilter, tagMask: tagMask, interaction: chatListInteraction, listInteraction: listInteraction, peerContextAction: { message, node, rect, gesture in + let transition = chatListSearchContainerPreparedTransition(from: previousEntries ?? [], to: newEntries, displayingResults: entriesAndFlags?.0 != nil, isEmpty: !isSearching && (entriesAndFlags?.0.isEmpty ?? false), isLoading: isSearching, animated: animated, context: context, presentationData: strongSelf.presentationData, enableHeaders: true, filter: peersFilter, key: strongSelf.key, tagMask: tagMask, interaction: chatListInteraction, listInteraction: listInteraction, peerContextAction: { message, node, rect, gesture in interaction.peerContextAction?(message, node, rect, gesture) }, toggleExpandLocalResults: { [weak self] in guard let strongSelf = self else { @@ -1376,15 +1639,46 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { return state } }, searchPeer: { peer in - }, searchQuery: strongSelf.searchQueryValue, searchOptions: strongSelf.searchOptionsValue, messageContextAction: { message, node, rect, gesture in - interaction.messageContextAction(message, node, rect, gesture) + }, searchQuery: strongSelf.searchQueryValue, searchOptions: strongSelf.searchOptionsValue, messageContextAction: { message, node, rect, gesture, paneKey, downloadResource in + interaction.messageContextAction(message, node, rect, gesture, paneKey, downloadResource) + }, openStorageSettings: { + guard let strongSelf = self else { + return + } + strongSelf.context.sharedContext.openStorageUsage(context: strongSelf.context) + }, toggleAllPaused: { + guard let strongSelf = self else { + return + } + + let _ = ((strongSelf.context.fetchManager as! FetchManagerImpl).entriesSummary + |> take(1) + |> deliverOnMainQueue).start(next: { entries in + guard let strongSelf = self, !entries.isEmpty else { + return + } + var allPaused = true + for entry in entries { + if !entry.isPaused { + allPaused = false + break + } + } + + for entry in entries { + strongSelf.context.fetchManager.toggleInteractiveFetchPaused(resourceId: entry.resourceReference.resource.id.stringRepresentation, isPaused: !allPaused) + } + }) }) strongSelf.currentEntries = newEntries + /*if key == .downloads, !firstTime { + transition.animated = true + }*/ strongSelf.enqueueTransition(transition, firstTime: firstTime) var messages: [EngineMessage] = [] for entry in newEntries { - if case let .message(message, _, _, _, _, _, _) = entry { + if case let .message(message, _, _, _, _, _, _, _, _, _, _) = entry { messages.append(message) } } @@ -1652,6 +1946,27 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { } } + func didBecomeFocused() { + if self.key == .downloads { + self.scheduleMarkRecentDownloadsAsSeen() + } + } + + private var scheduledMarkRecentDownloadsAsSeen: Bool = false + + func scheduleMarkRecentDownloadsAsSeen() { + if !self.scheduledMarkRecentDownloadsAsSeen { + self.scheduledMarkRecentDownloadsAsSeen = true + Queue.mainQueue().after(0.1, { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.scheduledMarkRecentDownloadsAsSeen = false + let _ = markAllRecentDownloadItemsAsSeen(postbox: strongSelf.context.account.postbox).start() + }) + } + } + func scrollToTop() -> Bool { if !self.mediaNode.isHidden { return self.mediaNode.scrollToTop() @@ -2110,16 +2425,26 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { strongSelf.update(size: size, sideInset: sideInset, bottomInset: bottomInset, visibleHeight: visibleHeight, presentationData: presentationData, synchronous: true, transition: .animated(duration: 0.4, curve: .spring)) } - strongSelf.emptyResultsAnimationNode.isHidden = !emptyResults - strongSelf.emptyResultsTitleNode.isHidden = !emptyResults - strongSelf.emptyResultsTextNode.isHidden = !emptyResults - strongSelf.emptyResultsAnimationNode.visibility = emptyResults + if strongSelf.key == .downloads { + strongSelf.emptyResultsAnimationNode.isHidden = true + strongSelf.emptyResultsTitleNode.isHidden = true + strongSelf.emptyResultsTextNode.isHidden = true + strongSelf.emptyResultsAnimationNode.visibility = false + } else { + strongSelf.emptyResultsAnimationNode.isHidden = !emptyResults + strongSelf.emptyResultsTitleNode.isHidden = !emptyResults + strongSelf.emptyResultsTextNode.isHidden = !emptyResults + strongSelf.emptyResultsAnimationNode.visibility = emptyResults + } - let displayPlaceholder = transition.isLoading && (strongSelf.key != .chats || (strongSelf.currentEntries?.isEmpty ?? true)) + var displayPlaceholder = transition.isLoading && (strongSelf.key != .chats || (strongSelf.currentEntries?.isEmpty ?? true)) + if strongSelf.key == .downloads { + displayPlaceholder = false + } let targetAlpha: CGFloat = displayPlaceholder ? 1.0 : 0.0 if strongSelf.shimmerNode.alpha != targetAlpha { - let transition: ContainedViewLayoutTransition = displayPlaceholder ? .immediate : .animated(duration: 0.2, curve: .linear) + let transition: ContainedViewLayoutTransition = (displayPlaceholder || isFirstTime) ? .immediate : .animated(duration: 0.2, curve: .linear) transition.updateAlpha(node: strongSelf.shimmerNode, alpha: targetAlpha, delay: 0.1) } @@ -2334,7 +2659,7 @@ private final class ChatListSearchShimmerNode: ASDisplayNode { let items = (0 ..< 2).compactMap { _ -> ListViewItem? in switch key { - case .chats: + case .chats, .downloads: let message = EngineMessage( stableId: 0, stableVersion: 0, @@ -2358,7 +2683,7 @@ private final class ChatListSearchShimmerNode: ASDisplayNode { associatedMessageIds: [] ) let readState = EnginePeerReadCounters() - return ChatListItem(presentationData: chatListPresentationData, context: context, peerGroupId: .root, filterData: nil, index: EngineChatList.Item.Index(pinningIndex: 0, messageIndex: EngineMessage.Index(id: EngineMessage.Id(peerId: peer1.id, namespace: 0, id: 0), timestamp: timestamp1)), content: .peer(messages: [message], peer: EngineRenderedPeer(peer: peer1), combinedReadState: readState, isRemovedFromTotalUnreadCount: false, presence: nil, hasUnseenMentions: false, hasUnseenReactions: false, draftState: nil, inputActivities: nil, promoInfo: nil, ignoreUnreadBadge: false, displayAsMessage: false, hasFailedMessages: false), editing: false, hasActiveRevealControls: false, selected: false, header: nil, enableContextActions: false, hiddenOffset: false, interaction: interaction) + return ChatListItem(presentationData: chatListPresentationData, context: context, peerGroupId: .root, filterData: nil, index: EngineChatList.Item.Index(pinningIndex: 0, messageIndex: EngineMessage.Index(id: EngineMessage.Id(peerId: peer1.id, namespace: 0, id: 0), timestamp: timestamp1)), content: .peer(messages: [message], peer: EngineRenderedPeer(peer: peer1), combinedReadState: readState, isRemovedFromTotalUnreadCount: false, presence: nil, hasUnseenMentions: false, hasUnseenReactions: false, draftState: nil, inputActivities: nil, promoInfo: nil, ignoreUnreadBadge: false, displayAsMessage: false, hasFailedMessages: false), editing: false, hasActiveRevealControls: false, selected: false, header: nil, enableContextActions: false, hiddenOffset: false, interaction: interaction) case .media: return nil case .links: diff --git a/submodules/ChatListUI/Sources/ChatListSearchMediaNode.swift b/submodules/ChatListUI/Sources/ChatListSearchMediaNode.swift index 3000caaced..5494b6d70b 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchMediaNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchMediaNode.swift @@ -165,7 +165,7 @@ private final class VisualMediaItemNode: ASDisplayNode { messageMediaFileCancelInteractiveFetch(context: self.context, messageId: message.id, file: file) case .Local: self.interaction.openMessage(message) - case .Remote: + case .Remote, .Paused: self.fetchDisposable.set(messageMediaFileInteractiveFetched(context: self.context, message: message, file: file, userInitiated: true).start()) } } @@ -236,7 +236,7 @@ private final class VisualMediaItemNode: ASDisplayNode { statusState = .progress(color: .white, lineWidth: nil, value: CGFloat(adjustedProgress), cancelEnabled: true, animateRotation: true) case .Local: statusState = .none - case .Remote: + case .Remote, .Paused: statusState = .download(.white) } } @@ -270,7 +270,7 @@ private final class VisualMediaItemNode: ASDisplayNode { mediaDownloadState = .compactFetching(progress: 0.0) case .Local: badgeContent = .text(inset: 0.0, backgroundColor: mediaBadgeBackgroundColor, foregroundColor: mediaBadgeTextColor, text: NSAttributedString(string: durationString)) - case .Remote: + case .Remote, .Paused: badgeContent = .text(inset: 12.0, backgroundColor: mediaBadgeBackgroundColor, foregroundColor: mediaBadgeTextColor, text: NSAttributedString(string: durationString)) mediaDownloadState = .compactRemote } @@ -702,7 +702,7 @@ final class ChatListSearchMediaNode: ASDisplayNode, UIScrollViewDelegate { var index: UInt32 = 0 if let entries = entries { for entry in entries { - if case let .message(message, _, _, _, _, _, _) = entry { + if case let .message(message, _, _, _, _, _, _, _, _, _, _) = entry { self.mediaItems.append(VisualMediaItem(message: message._asMessage(), index: nil)) } index += 1 diff --git a/submodules/ChatListUI/Sources/ChatListSearchPaneContainerNode.swift b/submodules/ChatListUI/Sources/ChatListSearchPaneContainerNode.swift index 7c8e7601c1..440cfefc55 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchPaneContainerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchPaneContainerNode.swift @@ -19,6 +19,7 @@ protocol ChatListSearchPaneNode: ASDisplayNode { func updateHiddenMedia() func updateSelectedMessages(animated: Bool) func previewViewAndActionAtLocation(_ location: CGPoint) -> (UIView, CGRect, Any)? + func didBecomeFocused() var searchCurrentMessages: [EngineMessage]? { get } } @@ -44,16 +45,17 @@ final class ChatListSearchPaneWrapper { } } -enum ChatListSearchPaneKey { +public enum ChatListSearchPaneKey { case chats case media + case downloads case links case files case music case voice } -let defaultAvailableSearchPanes: [ChatListSearchPaneKey] = [.chats, .media, .links, .files, .music, .voice] +let defaultAvailableSearchPanes: [ChatListSearchPaneKey] = [.chats, .media, .downloads, .links, .files, .music, .voice] struct ChatListSearchPaneSpecifier: Equatable { var key: ChatListSearchPaneKey @@ -475,6 +477,9 @@ final class ChatListSearchPaneContainerNode: ASDisplayNode, UIGestureRecognizerD }) } pane.update(size: paneFrame.size, sideInset: sideInset, bottomInset: bottomInset, visibleHeight: visibleHeight, presentationData: presentationData, synchronous: paneWasAdded, transition: paneTransition) + if paneWasAdded && key == self.currentPaneKey { + pane.node.didBecomeFocused() + } } } diff --git a/submodules/CheckNode/Sources/CheckNode.swift b/submodules/CheckNode/Sources/CheckNode.swift index 9cd3e8da41..a0b076df5b 100644 --- a/submodules/CheckNode/Sources/CheckNode.swift +++ b/submodules/CheckNode/Sources/CheckNode.swift @@ -305,13 +305,6 @@ public class InteractiveCheckNode: CheckNode { } } -private final class NullActionClass: NSObject, CAAction { - @objc func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { - } -} - -private let nullAction = NullActionClass() - public class CheckLayer: CALayer { private var animatingOut = false private var animationProgress: CGFloat = 0.0 diff --git a/submodules/ComponentFlow/Source/Components/LazyList.swift b/submodules/ComponentFlow/Source/Components/LazyList.swift new file mode 100644 index 0000000000..e0f73bbae7 --- /dev/null +++ b/submodules/ComponentFlow/Source/Components/LazyList.swift @@ -0,0 +1,3 @@ +import Foundation +import UIKit + diff --git a/submodules/ComponentFlow/Source/Components/ZStack.swift b/submodules/ComponentFlow/Source/Components/ZStack.swift new file mode 100644 index 0000000000..c79ef1a937 --- /dev/null +++ b/submodules/ComponentFlow/Source/Components/ZStack.swift @@ -0,0 +1,49 @@ +import Foundation +import UIKit + +public final class ZStack: CombinedComponent { + public typealias EnvironmentType = ChildEnvironment + + private let items: [AnyComponentWithIdentity] + + public init(_ items: [AnyComponentWithIdentity]) { + self.items = items + } + + public static func ==(lhs: ZStack, rhs: ZStack) -> Bool { + if lhs.items != rhs.items { + return false + } + return true + } + + public static var body: Body { + let children = ChildMap(environment: ChildEnvironment.self, keyedBy: AnyHashable.self) + + return { context in + let updatedChildren = context.component.items.map { item in + return children[item.id].update( + component: item.component, environment: { + context.environment[ChildEnvironment.self] + }, + availableSize: context.availableSize, + transition: context.transition + ) + } + + var size = CGSize(width: 0.0, height: 0.0) + for child in updatedChildren { + size.width = max(size.width, child.size.width) + size.height = max(size.height, child.size.height) + } + + for child in updatedChildren { + context.add(child + .position(child.size.centered(in: CGRect(origin: CGPoint(), size: size)).center) + ) + } + + return size + } + } +} diff --git a/submodules/Components/LottieAnimationComponent/BUILD b/submodules/Components/LottieAnimationComponent/BUILD new file mode 100644 index 0000000000..93b589e835 --- /dev/null +++ b/submodules/Components/LottieAnimationComponent/BUILD @@ -0,0 +1,20 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "LottieAnimationComponent", + module_name = "LottieAnimationComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/ComponentFlow:ComponentFlow", + "//submodules/lottie-ios:Lottie", + "//submodules/AppBundle:AppBundle", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/Components/LottieAnimationComponent/Sources/LottieAnimationComponent.swift b/submodules/Components/LottieAnimationComponent/Sources/LottieAnimationComponent.swift new file mode 100644 index 0000000000..e5b90f72b0 --- /dev/null +++ b/submodules/Components/LottieAnimationComponent/Sources/LottieAnimationComponent.swift @@ -0,0 +1,101 @@ +import Foundation +import ComponentFlow +import Lottie +import AppBundle + +public final class LottieAnimationComponent: Component { + public struct Animation: Equatable { + public var name: String + public var loop: Bool + public var colors: [String: UIColor] + + public init(name: String, colors: [String: UIColor], loop: Bool) { + self.name = name + self.colors = colors + self.loop = loop + } + } + + public let animation: Animation + public let size: CGSize + + public init(animation: Animation, size: CGSize) { + self.animation = animation + self.size = size + } + + public static func ==(lhs: LottieAnimationComponent, rhs: LottieAnimationComponent) -> Bool { + if lhs.animation != rhs.animation { + return false + } + if lhs.size != rhs.size { + return false + } + return true + } + + public final class View: UIView { + private var currentAnimation: Animation? + + private var colorCallbacks: [LOTColorValueCallback] = [] + private var animationView: LOTAnimationView? + + func update(component: LottieAnimationComponent, availableSize: CGSize, transition: Transition) -> CGSize { + let size = CGSize(width: min(component.size.width, availableSize.width), height: min(component.size.height, availableSize.height)) + + if self.currentAnimation != component.animation { + if let animationView = self.animationView, animationView.isAnimationPlaying { + animationView.completionBlock = { [weak self] _ in + guard let strongSelf = self else { + return + } + let _ = strongSelf.update(component: component, availableSize: availableSize, transition: transition) + } + animationView.loopAnimation = false + } else { + self.currentAnimation = component.animation + + self.animationView?.removeFromSuperview() + + if let url = getAppBundle().url(forResource: component.animation.name, withExtension: "json"), let composition = LOTComposition(filePath: url.path) { + let view = LOTAnimationView(model: composition, in: getAppBundle()) + view.loopAnimation = component.animation.loop + view.animationSpeed = 1.0 + view.backgroundColor = .clear + view.isOpaque = false + + //view.logHierarchyKeypaths() + + for (key, value) in component.animation.colors { + let colorCallback = LOTColorValueCallback(color: value.cgColor) + self.colorCallbacks.append(colorCallback) + view.setValueDelegate(colorCallback, for: LOTKeypath(string: "\(key).Color")) + } + + self.animationView = view + self.addSubview(view) + } + } + } + + if let animationView = self.animationView { + animationView.frame = CGRect(origin: CGPoint(x: floor((size.width - component.size.width) / 2.0), y: floor((size.height - component.size.height) / 2.0)), size: component.size) + + if !animationView.isAnimationPlaying { + animationView.play { _ in + } + } + } + + return size + } + } + + public func makeView() -> View { + return View() + } + + public func update(view: View, availableSize: CGSize, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, transition: transition) + } +} diff --git a/submodules/Components/MetalImageView/BUILD b/submodules/Components/MetalImageView/BUILD new file mode 100644 index 0000000000..a9d08899a7 --- /dev/null +++ b/submodules/Components/MetalImageView/BUILD @@ -0,0 +1,18 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "MetalImageView", + module_name = "MetalImageView", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display:Display", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/Components/MetalImageView/Sources/MetalImageView.swift b/submodules/Components/MetalImageView/Sources/MetalImageView.swift new file mode 100644 index 0000000000..32a12db87b --- /dev/null +++ b/submodules/Components/MetalImageView/Sources/MetalImageView.swift @@ -0,0 +1,252 @@ +import Foundation +import UIKit +import Metal +import Display + +private func alignUp(size: Int, align: Int) -> Int { + precondition(((align - 1) & align) == 0, "Align must be a power of two") + + let alignmentMask = align - 1 + return (size + alignmentMask) & ~alignmentMask +} + +open class MetalImageLayer: CALayer { + fileprivate final class TextureStoragePool { + let width: Int + let height: Int + + private var items: [TextureStorage.Content] = [] + + init(width: Int, height: Int) { + self.width = width + self.height = height + } + + func recycle(content: TextureStorage.Content) { + if self.items.count < 4 { + self.items.append(content) + } else { + print("Warning: over-recycling texture storage") + } + } + + func take() -> TextureStorage.Content? { + if self.items.isEmpty { + return nil + } + return self.items.removeLast() + } + } + + fileprivate final class TextureStorage { + final class Content { + #if !targetEnvironment(simulator) + let buffer: MTLBuffer + #endif + + let width: Int + let height: Int + let bytesPerRow: Int + let texture: MTLTexture + + init?(device: MTLDevice, width: Int, height: Int) { + if #available(iOS 12.0, *) { + let bytesPerPixel = 4 + let pixelRowAlignment = device.minimumLinearTextureAlignment(for: .bgra8Unorm) + let bytesPerRow = alignUp(size: width * bytesPerPixel, align: pixelRowAlignment) + + self.width = width + self.height = height + self.bytesPerRow = bytesPerRow + + #if targetEnvironment(simulator) + let textureDescriptor = MTLTextureDescriptor() + textureDescriptor.textureType = .type2D + textureDescriptor.pixelFormat = .bgra8Unorm + textureDescriptor.width = width + textureDescriptor.height = height + textureDescriptor.usage = [.renderTarget] + textureDescriptor.storageMode = .shared + + guard let texture = device.makeTexture(descriptor: textureDescriptor) else { + return nil + } + #else + guard let buffer = device.makeBuffer(length: bytesPerRow * height, options: MTLResourceOptions.storageModeShared) else { + return nil + } + self.buffer = buffer + + let textureDescriptor = MTLTextureDescriptor() + textureDescriptor.textureType = .type2D + textureDescriptor.pixelFormat = .bgra8Unorm + textureDescriptor.width = width + textureDescriptor.height = height + textureDescriptor.usage = [.renderTarget] + textureDescriptor.storageMode = buffer.storageMode + + guard let texture = buffer.makeTexture(descriptor: textureDescriptor, offset: 0, bytesPerRow: bytesPerRow) else { + return nil + } + #endif + + self.texture = texture + } else { + return nil + } + } + } + + private weak var pool: TextureStoragePool? + let content: Content + private var isInvalidated: Bool = false + + init(pool: TextureStoragePool, content: Content) { + self.pool = pool + self.content = content + } + + deinit { + if !self.isInvalidated { + self.pool?.recycle(content: self.content) + } + } + + func createCGImage() -> CGImage? { + if self.isInvalidated { + return nil + } + self.isInvalidated = true + + #if targetEnvironment(simulator) + guard let data = NSMutableData(capacity: self.content.bytesPerRow * self.content.height) else { + return nil + } + data.length = self.content.bytesPerRow * self.content.height + self.content.texture.getBytes(data.mutableBytes, bytesPerRow: self.content.bytesPerRow, bytesPerImage: self.content.bytesPerRow * self.content.height, from: MTLRegion(origin: MTLOrigin(), size: MTLSize(width: self.content.width, height: self.content.height, depth: 1)), mipmapLevel: 0, slice: 0) + + guard let dataProvider = CGDataProvider(data: data as CFData) else { + return nil + } + #else + let content = self.content + let pool = self.pool + guard let dataProvider = CGDataProvider(data: Data(bytesNoCopy: self.content.buffer.contents(), count: self.content.buffer.length, deallocator: .custom { [weak pool] _, _ in + guard let pool = pool else { + return + } + pool.recycle(content: content) + }) as CFData) else { + return nil + } + #endif + + guard let image = CGImage( + width: Int(self.content.width), + height: Int(self.content.height), + bitsPerComponent: 8, + bitsPerPixel: 8 * 4, + bytesPerRow: self.content.bytesPerRow, + space: DeviceGraphicsContextSettings.shared.colorSpace, + bitmapInfo: DeviceGraphicsContextSettings.shared.transparentBitmapInfo, + provider: dataProvider, + decode: nil, + shouldInterpolate: true, + intent: .defaultIntent + ) else { + return nil + } + + return image + } + } + + public final class Drawable { + private weak var renderer: Renderer? + fileprivate let textureStorage: TextureStorage + public var texture: MTLTexture { + return self.textureStorage.content.texture + } + + fileprivate init(renderer: Renderer, textureStorage: TextureStorage) { + self.renderer = renderer + self.textureStorage = textureStorage + } + + public func present(completion: @escaping () -> Void) { + self.renderer?.present(drawable: self) + completion() + } + } + + public final class Renderer { + public var device: MTLDevice? + private var storagePool: TextureStoragePool? + + public var imageUpdated: ((CGImage?) -> Void)? + + public var drawableSize: CGSize = CGSize() { + didSet { + if self.drawableSize != oldValue { + if !self.drawableSize.width.isZero && !self.drawableSize.height.isZero { + self.storagePool = TextureStoragePool(width: Int(self.drawableSize.width), height: Int(self.drawableSize.height)) + } else { + self.storagePool = nil + } + } + } + } + + public func nextDrawable() -> Drawable? { + guard let device = self.device else { + return nil + } + guard let storagePool = self.storagePool else { + return nil + } + + if let content = storagePool.take() { + return Drawable(renderer: self, textureStorage: TextureStorage(pool: storagePool, content: content)) + } else { + guard let content = TextureStorage.Content(device: device, width: storagePool.width, height: storagePool.height) else { + return nil + } + return Drawable(renderer: self, textureStorage: TextureStorage(pool: storagePool, content: content)) + } + } + + fileprivate func present(drawable: Drawable) { + if let imageUpdated = self.imageUpdated { + imageUpdated(drawable.textureStorage.createCGImage()) + } + } + } + + public let renderer = Renderer() + + override public init() { + super.init() + + self.renderer.imageUpdated = { [weak self] image in + self?.contents = image + } + } + + override public init(layer: Any) { + preconditionFailure() + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override open func action(forKey event: String) -> CAAction? { + return nullAction + } +} + +open class MetalImageView: UIView { + public static override var layerClass: AnyClass { + return MetalImageLayer.self + } +} diff --git a/submodules/Components/ProgressIndicatorComponent/BUILD b/submodules/Components/ProgressIndicatorComponent/BUILD new file mode 100644 index 0000000000..df967f4f4f --- /dev/null +++ b/submodules/Components/ProgressIndicatorComponent/BUILD @@ -0,0 +1,19 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ProgressIndicatorComponent", + module_name = "ProgressIndicatorComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/ComponentFlow:ComponentFlow", + "//submodules/Display:Display", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/Components/ProgressIndicatorComponent/Sources/ProgressIndicatorComponent.swift b/submodules/Components/ProgressIndicatorComponent/Sources/ProgressIndicatorComponent.swift new file mode 100644 index 0000000000..b942ac6926 --- /dev/null +++ b/submodules/Components/ProgressIndicatorComponent/Sources/ProgressIndicatorComponent.swift @@ -0,0 +1,113 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +public final class ProgressIndicatorComponent: Component { + public let diameter: CGFloat + public let value: Double + public let backgroundColor: UIColor + public let foregroundColor: UIColor + + public init( + diameter: CGFloat, + backgroundColor: UIColor, + foregroundColor: UIColor, + value: Double + ) { + self.diameter = diameter + self.backgroundColor = backgroundColor + self.foregroundColor = foregroundColor + self.value = value + } + + public static func ==(lhs: ProgressIndicatorComponent, rhs: ProgressIndicatorComponent) -> Bool { + if lhs.diameter != rhs.diameter { + return false + } + if lhs.backgroundColor != rhs.backgroundColor { + return false + } + if lhs.foregroundColor != rhs.foregroundColor { + return false + } + if lhs.value != rhs.value { + return false + } + return true + } + + public final class View: UIView { + private var currentComponent: ProgressIndicatorComponent? + + private let foregroundShapeLayer: SimpleShapeLayer + + public init() { + self.foregroundShapeLayer = SimpleShapeLayer() + self.foregroundShapeLayer.isOpaque = false + self.foregroundShapeLayer.backgroundColor = nil + self.foregroundShapeLayer.fillColor = nil + self.foregroundShapeLayer.lineCap = .round + + super.init(frame: CGRect()) + + let shapeLayer = self.layer as! CAShapeLayer + shapeLayer.isOpaque = false + shapeLayer.backgroundColor = nil + shapeLayer.fillColor = nil + + self.layer.addSublayer(self.foregroundShapeLayer) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public static var layerClass: AnyClass { + return CAShapeLayer.self + } + + func update(component: ProgressIndicatorComponent, availableSize: CGSize, transition: Transition) -> CGSize { + let lineWidth: CGFloat = 1.33 + let size = CGSize(width: component.diameter, height: component.diameter) + + let shapeLayer = self.layer as! CAShapeLayer + + if self.currentComponent?.backgroundColor != component.backgroundColor { + shapeLayer.strokeColor = component.backgroundColor.cgColor + shapeLayer.lineWidth = lineWidth + } + + if self.currentComponent?.foregroundColor != component.foregroundColor { + self.foregroundShapeLayer.strokeColor = component.foregroundColor.cgColor + self.foregroundShapeLayer.lineWidth = lineWidth + } + + if self.currentComponent?.diameter != component.diameter { + let path = UIBezierPath(arcCenter: CGPoint(x: component.diameter / 2.0, y: component.diameter / 2.0), radius: component.diameter / 2.0, startAngle: -CGFloat.pi / 2.0, endAngle: 2.0 * CGFloat.pi - CGFloat.pi / 2.0, clockwise: true).cgPath + shapeLayer.path = path + self.foregroundShapeLayer.path = path + + self.foregroundShapeLayer.frame = CGRect(origin: CGPoint(), size: size) + } + + if self.currentComponent != nil { + let previousValue: CGFloat = self.foregroundShapeLayer.presentation()?.strokeEnd ?? self.foregroundShapeLayer.strokeEnd + self.foregroundShapeLayer.animate(from: CGFloat(previousValue) as NSNumber, to: CGFloat(component.value) as NSNumber, keyPath: "strokeEnd", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.12) + } + self.foregroundShapeLayer.strokeEnd = CGFloat(component.value) + + self.currentComponent = component + + return size + } + } + + public func makeView() -> View { + return View() + } + + public func update(view: View, availableSize: CGSize, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, transition: transition) + } +} diff --git a/submodules/ConfettiEffect/Sources/ConfettiView.swift b/submodules/ConfettiEffect/Sources/ConfettiView.swift index 441cc4cc8f..7d6d0a7815 100644 --- a/submodules/ConfettiEffect/Sources/ConfettiView.swift +++ b/submodules/ConfettiEffect/Sources/ConfettiView.swift @@ -7,13 +7,6 @@ private struct Vector2 { var y: Float } -private final class NullActionClass: NSObject, CAAction { - @objc func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { - } -} - -private let nullAction = NullActionClass() - private final class ParticleLayer: CALayer { let mass: Float var velocity: Vector2 diff --git a/submodules/DebugSettingsUI/Sources/DebugController.swift b/submodules/DebugSettingsUI/Sources/DebugController.swift index 5715148260..ad1d2f8303 100644 --- a/submodules/DebugSettingsUI/Sources/DebugController.swift +++ b/submodules/DebugSettingsUI/Sources/DebugController.swift @@ -270,7 +270,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { let fileResource = LocalFileMediaResource(fileId: id, size: gzippedData.count, isSecretRelated: false) context.account.postbox.mediaBox.storeResourceData(fileResource.id, data: gzippedData) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: gzippedData.count, attributes: [.FileName(fileName: "Log-iOS-Full.txt.gz")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: gzippedData.count, attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")]) let message: EnqueueMessage = .message(text: "", attributes: [], mediaReference: .standalone(media: file), replyToMessageId: nil, localGroupingKey: nil, correlationId: nil) let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).start() @@ -436,7 +436,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { let fileResource = LocalFileMediaResource(fileId: id, size: gzippedData.count, isSecretRelated: false) context.account.postbox.mediaBox.storeResourceData(fileResource.id, data: gzippedData) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: gzippedData.count, attributes: [.FileName(fileName: "Log-iOS-Full.txt.gz")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: gzippedData.count, attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")]) let message: EnqueueMessage = .message(text: "", attributes: [], mediaReference: .standalone(media: file), replyToMessageId: nil, localGroupingKey: nil, correlationId: nil) let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).start() @@ -520,7 +520,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { let fileResource = LocalFileMediaResource(fileId: id, size: gzippedData.count, isSecretRelated: false) context.account.postbox.mediaBox.storeResourceData(fileResource.id, data: gzippedData) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: gzippedData.count, attributes: [.FileName(fileName: "Log-iOS-Full.txt.gz")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: gzippedData.count, attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")]) let message: EnqueueMessage = .message(text: "", attributes: [], mediaReference: .standalone(media: file), replyToMessageId: nil, localGroupingKey: nil, correlationId: nil) let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).start() diff --git a/submodules/Display/Source/SimpleLayer.swift b/submodules/Display/Source/SimpleLayer.swift index 43f6e3520c..ad942b5cd6 100644 --- a/submodules/Display/Source/SimpleLayer.swift +++ b/submodules/Display/Source/SimpleLayer.swift @@ -1,11 +1,11 @@ import UIKit -private final class NullActionClass: NSObject, CAAction { - @objc func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { +public final class NullActionClass: NSObject, CAAction { + @objc public func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { } } -private let nullAction = NullActionClass() +public let nullAction = NullActionClass() open class SimpleLayer: CALayer { override open func action(forKey event: String) -> CAAction? { @@ -24,3 +24,21 @@ open class SimpleLayer: CALayer { fatalError("init(coder:) has not been implemented") } } + +open class SimpleShapeLayer: CAShapeLayer { + override open func action(forKey event: String) -> CAAction? { + return nullAction + } + + override public init() { + super.init() + } + + override public init(layer: Any) { + super.init(layer: layer) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/submodules/Display/Source/TransformImageNode.swift b/submodules/Display/Source/TransformImageNode.swift index 73a0dcbec8..cc4b58db0b 100644 --- a/submodules/Display/Source/TransformImageNode.swift +++ b/submodules/Display/Source/TransformImageNode.swift @@ -277,13 +277,6 @@ open class TransformImageNode: ASDisplayNode { } } -private final class NullActionClass: NSObject, CAAction { - @objc func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { - } -} - -private let nullAction = NullActionClass() - private class CaptureProtectedContentLayer: AVSampleBufferDisplayLayer { override func action(forKey event: String) -> CAAction? { return nullAction diff --git a/submodules/FetchManagerImpl/Sources/FetchManagerImpl.swift b/submodules/FetchManagerImpl/Sources/FetchManagerImpl.swift index 15cd0c210c..d12be7953c 100644 --- a/submodules/FetchManagerImpl/Sources/FetchManagerImpl.swift +++ b/submodules/FetchManagerImpl/Sources/FetchManagerImpl.swift @@ -44,6 +44,7 @@ private final class FetchManagerLocationEntry { let ranges = Bag() var elevatedPriorityReferenceCount: Int32 = 0 var userInitiatedPriorityIndices: [Int32] = [] + var isPaused: Bool = false var combinedRanges: IndexSet { var result = IndexSet() @@ -90,6 +91,7 @@ private final class FetchManagerStatusContext { var subscribers = Bag<(MediaResourceStatus) -> Void>() var hasEntry = false + var isPaused = false var isEmpty: Bool { return !self.hasEntry && self.subscribers.isEmpty @@ -97,14 +99,18 @@ private final class FetchManagerStatusContext { var combinedStatus: MediaResourceStatus? { if let originalStatus = self.originalStatus { - if originalStatus == .Remote && self.hasEntry { - return .Fetching(isActive: false, progress: 0.0) + if case let .Remote(progress) = originalStatus, self.hasEntry { + if self.isPaused { + return .Paused(progress: progress) + } else { + return .Fetching(isActive: false, progress: progress) + } } else if self.hasEntry { return originalStatus } else if case .Local = originalStatus { return originalStatus } else { - return .Remote + return .Remote(progress: 0.0) } } else { return nil @@ -116,7 +122,7 @@ private final class FetchManagerCategoryContext { private let postbox: Postbox private let storeManager: DownloadedMediaStoreManager? private let entryCompleted: (FetchManagerLocationEntryId) -> Void - private let activeEntriesUpdated: ([FetchManagerEntrySummary]) -> Void + private let activeEntriesUpdated: () -> Void private var topEntryIdAndPriority: (FetchManagerLocationEntryId, FetchManagerPriorityKey)? private var entries: [FetchManagerLocationEntryId: FetchManagerLocationEntry] = [:] @@ -133,13 +139,17 @@ private final class FetchManagerCategoryContext { return false } - init(postbox: Postbox, storeManager: DownloadedMediaStoreManager?, entryCompleted: @escaping (FetchManagerLocationEntryId) -> Void, activeEntriesUpdated: @escaping ([FetchManagerEntrySummary]) -> Void) { + init(postbox: Postbox, storeManager: DownloadedMediaStoreManager?, entryCompleted: @escaping (FetchManagerLocationEntryId) -> Void, activeEntriesUpdated: @escaping () -> Void) { self.postbox = postbox self.storeManager = storeManager self.entryCompleted = entryCompleted self.activeEntriesUpdated = activeEntriesUpdated } + func getActiveEntries() -> [FetchManagerLocationEntry] { + return Array(self.entries.values) + } + func withEntry(id: FetchManagerLocationEntryId, takeNew: (() -> (AnyMediaReference?, MediaResourceReference, MediaResourceStatsCategory, Int32))?, _ f: (FetchManagerLocationEntry) -> Void) { let entry: FetchManagerLocationEntry let previousPriorityKey: FetchManagerPriorityKey? @@ -221,8 +231,13 @@ private final class FetchManagerCategoryContext { parsedRanges = resultRanges } activeContext.disposable?.dispose() - activeContext.disposable = (fetchedMediaResource(mediaBox: self.postbox.mediaBox, reference: entry.resourceReference, ranges: parsedRanges, statsCategory: entry.statsCategory, reportResultStatus: true, continueInBackground: entry.userInitiated) + let postbox = self.postbox + activeContext.disposable = (fetchedMediaResource(mediaBox: postbox.mediaBox, reference: entry.resourceReference, ranges: parsedRanges, statsCategory: entry.statsCategory, reportResultStatus: true, continueInBackground: entry.userInitiated) |> mapToSignal { type -> Signal in + if filterDownloadStatsEntry(entry: entry), case let .message(message, _) = entry.mediaReference, let messageId = message.id, case .remote = type { + let _ = addRecentDownloadItem(postbox: postbox, item: RecentDownloadItem(messageId: messageId, resourceId: entry.resourceReference.resource.id.stringRepresentation, timestamp: Int32(Date().timeIntervalSince1970), isSeen: false)).start() + } + if let storeManager = storeManager, let mediaReference = entry.mediaReference, case .remote = type, let peerType = entry.storeToDownloadsPeerType { return storeDownloadedMedia(storeManager: storeManager, media: mediaReference, peerType: peerType) |> castError(FetchResourceError.self) @@ -241,58 +256,32 @@ private final class FetchManagerCategoryContext { } } - if (previousPriorityKey != nil) != (updatedPriorityKey != nil) { - if let statusContext = self.statusContexts[id] { - let previousStatus = statusContext.combinedStatus - statusContext.hasEntry = self.entries[id] != nil - if let combinedStatus = statusContext.combinedStatus, combinedStatus != previousStatus { - for f in statusContext.subscribers.copyItems() { - f(combinedStatus) - } + if let statusContext = self.statusContexts[id] { + let previousStatus = statusContext.combinedStatus + if let entry = self.entries[id] { + statusContext.hasEntry = true + statusContext.isPaused = entry.isPaused + } else { + statusContext.hasEntry = false + statusContext.isPaused = false + } + if let combinedStatus = statusContext.combinedStatus, combinedStatus != previousStatus { + for f in statusContext.subscribers.copyItems() { + f(combinedStatus) } - /*var hasForegroundPriorityKey = false - if let updatedPriorityKey = updatedPriorityKey, let topReference = updatedPriorityKey.topReference { - switch topReference { - case .userInitiated: - hasForegroundPriorityKey = true - default: - hasForegroundPriorityKey = false - } - } - - if hasForegroundPriorityKey { - if !statusContext.hasEntry { - let previousStatus = statusContext.combinedStatus - statusContext.hasEntry = true - if let combinedStatus = statusContext.combinedStatus, combinedStatus != previousStatus { - for f in statusContext.subscribers.copyItems() { - f(combinedStatus) - } - } - } else { - assertionFailure() - } - } else { - if statusContext.hasEntry { - let previousStatus = statusContext.combinedStatus - statusContext.hasEntry = false - if let combinedStatus = statusContext.combinedStatus, combinedStatus != previousStatus { - for f in statusContext.subscribers.copyItems() { - f(combinedStatus) - } - } - } - }*/ } } - self.activeEntriesUpdated(self.entries.values.compactMap(FetchManagerEntrySummary.init).sorted(by: { $0.priority < $1.priority })) + self.activeEntriesUpdated() } func maybeFindAndActivateNewTopEntry() -> Bool { if self.topEntryIdAndPriority == nil && !self.entries.isEmpty { var topEntryIdAndPriority: (FetchManagerLocationEntryId, FetchManagerPriorityKey)? for (id, entry) in self.entries { + if entry.isPaused { + continue + } if let entryPriorityKey = entry.priorityKey { if let (_, topKey) = topEntryIdAndPriority { if entryPriorityKey < topKey { @@ -366,8 +355,13 @@ private final class FetchManagerCategoryContext { }) } else if ranges.isEmpty { } else { - activeContext.disposable = (fetchedMediaResource(mediaBox: self.postbox.mediaBox, reference: entry.resourceReference, ranges: parsedRanges, statsCategory: entry.statsCategory, reportResultStatus: true, continueInBackground: entry.userInitiated) + let postbox = self.postbox + activeContext.disposable = (fetchedMediaResource(mediaBox: postbox.mediaBox, reference: entry.resourceReference, ranges: parsedRanges, statsCategory: entry.statsCategory, reportResultStatus: true, continueInBackground: entry.userInitiated) |> mapToSignal { type -> Signal in + if filterDownloadStatsEntry(entry: entry), case let .message(message, _) = entry.mediaReference, let messageId = message.id, case .remote = type { + let _ = addRecentDownloadItem(postbox: postbox, item: RecentDownloadItem(messageId: messageId, resourceId: entry.resourceReference.resource.id.stringRepresentation, timestamp: Int32(Date().timeIntervalSince1970), isSeen: false)).start() + } + if let storeManager = storeManager, let mediaReference = entry.mediaReference, case .remote = type, let peerType = entry.storeToDownloadsPeerType { return storeDownloadedMedia(storeManager: storeManager, media: mediaReference, peerType: peerType) |> castError(FetchResourceError.self) @@ -394,6 +388,15 @@ private final class FetchManagerCategoryContext { } } + func getEntryId(resourceId: String) -> FetchManagerLocationEntryId? { + for (id, _) in self.entries { + if id.resourceId.stringRepresentation == resourceId { + return id + } + } + return nil + } + func cancelEntry(_ entryId: FetchManagerLocationEntryId, isCompleted: Bool) { var id: FetchManagerLocationEntryId = entryId if self.entries[id] == nil { @@ -419,6 +422,7 @@ private final class FetchManagerCategoryContext { } let previousStatus = statusContext.combinedStatus statusContext.hasEntry = false + statusContext.isPaused = false if let combinedStatus = statusContext.combinedStatus, combinedStatus != previousStatus { for f in statusContext.subscribers.copyItems() { f(combinedStatus) @@ -446,7 +450,88 @@ private final class FetchManagerCategoryContext { activeContextsUpdated = true } - self.activeEntriesUpdated(self.entries.values.compactMap(FetchManagerEntrySummary.init).sorted(by: { $0.priority < $1.priority })) + self.activeEntriesUpdated() + } + + func toggleEntryPaused(_ entryId: FetchManagerLocationEntryId, isPaused: Bool) -> Bool { + var id: FetchManagerLocationEntryId = entryId + if self.entries[id] == nil { + for (key, _) in self.entries { + if key.resourceId == entryId.resourceId { + id = key + break + } + } + } + + if let entry = self.entries[id] { + if entry.isPaused == isPaused { + return entry.isPaused + } + entry.isPaused = !entry.isPaused + + if entry.isPaused { + if self.topEntryIdAndPriority?.0 == id { + self.topEntryIdAndPriority = nil + } + + if let activeContext = self.activeContexts[id] { + activeContext.disposable?.dispose() + activeContext.disposable = nil + self.activeContexts.removeValue(forKey: id) + } + } + + if let statusContext = self.statusContexts[id] { + let previousStatus = statusContext.combinedStatus + statusContext.hasEntry = true + statusContext.isPaused = entry.isPaused + if let combinedStatus = statusContext.combinedStatus, combinedStatus != previousStatus { + for f in statusContext.subscribers.copyItems() { + f(combinedStatus) + } + } + } + + let _ = self.maybeFindAndActivateNewTopEntry() + self.activeEntriesUpdated() + + return entry.isPaused + } else { + return false + } + } + + func raiseEntryPriority(_ entryId: FetchManagerLocationEntryId) { + var id: FetchManagerLocationEntryId = entryId + if self.entries[id] == nil { + for (key, _) in self.entries { + if key.resourceId == entryId.resourceId { + id = key + break + } + } + } + + if self.entries[id] == nil { + return + } + + var topUserInitiatedPriority: Int32 = 0 + for (_, entry) in self.entries { + for index in entry.userInitiatedPriorityIndices { + topUserInitiatedPriority = min(topUserInitiatedPriority, index) + } + } + + self.withEntry(id: id, takeNew: nil, { entry in + if entry.userInitiatedPriorityIndices.last == topUserInitiatedPriority { + return + } + + entry.userInitiatedPriorityIndices.removeAll() + entry.userInitiatedPriorityIndices.append(topUserInitiatedPriority - 1) + }) } func withFetchStatusContext(_ id: FetchManagerLocationEntryId, _ f: (FetchManagerStatusContext) -> Void) { @@ -456,8 +541,9 @@ private final class FetchManagerCategoryContext { } else { statusContext = FetchManagerStatusContext() self.statusContexts[id] = statusContext - if self.entries[id] != nil { + if let entry = self.entries[id] { statusContext.hasEntry = true + statusContext.isPaused = entry.isPaused } } @@ -479,6 +565,7 @@ public struct FetchManagerEntrySummary: Equatable { public let mediaReference: AnyMediaReference? public let resourceReference: MediaResourceReference public let priority: FetchManagerPriorityKey + public let isPaused: Bool } private extension FetchManagerEntrySummary { @@ -490,6 +577,38 @@ private extension FetchManagerEntrySummary { self.mediaReference = entry.mediaReference self.resourceReference = entry.resourceReference self.priority = priority + self.isPaused = entry.isPaused + } +} + +private func filterDownloadStatsEntry(entry: FetchManagerLocationEntry) -> Bool { + guard let mediaReference = entry.mediaReference else { + return false + } + if !entry.userInitiated { + return false + } + switch mediaReference { + case let .message(_, media): + switch media { + case let file as TelegramMediaFile: + if file.isVideo { + if file.isAnimated { + return false + } + } + if file.isSticker { + return false + } + if file.isVoice { + return false + } + return true + default: + return false + } + default: + return false } } @@ -545,21 +664,28 @@ public final class FetchManagerImpl: FetchManager { context.cancelEntry(id, isCompleted: true) }) } - }, activeEntriesUpdated: { [weak self] entries in + }, activeEntriesUpdated: { [weak self] in queue.async { guard let strongSelf = self else { return } var hasActiveUserInitiatedEntries = false + var activeEntries: [FetchManagerEntrySummary] = [] for (_, context) in strongSelf.categoryContexts { if context.hasActiveUserInitiatedEntries { hasActiveUserInitiatedEntries = true - break } + activeEntries.append(contentsOf: context.getActiveEntries().filter { entry in + return filterDownloadStatsEntry(entry: entry) + }.compactMap { entry -> FetchManagerEntrySummary? in + return FetchManagerEntrySummary(entry: entry) + }) } strongSelf.hasUserInitiatedEntriesValue.set(hasActiveUserInitiatedEntries) - strongSelf.entriesSummaryValue.set(entries) + let sortedEntries = activeEntries.sorted(by: { $0.priority < $1.priority }) + + strongSelf.entriesSummaryValue.set(sortedEntries) } }) self.categoryContexts[key] = context @@ -578,6 +704,7 @@ public final class FetchManagerImpl: FetchManager { if let strongSelf = self { var assignedEpisode: Int32? var assignedUserInitiatedIndex: Int32? + let _ = assignedUserInitiatedIndex var assignedReferenceIndex: Int? var assignedRangeIndex: Int? @@ -588,6 +715,8 @@ public final class FetchManagerImpl: FetchManager { if userInitiated { entry.userInitiated = true } + let wasPaused = entry.isPaused + entry.isPaused = false if let peerType = storeToDownloadsPeerType { entry.storeToDownloadsPeerType = peerType } @@ -596,9 +725,10 @@ public final class FetchManagerImpl: FetchManager { entry.elevatedPriorityReferenceCount += 1 } assignedRangeIndex = entry.ranges.add(ranges) - if userInitiated { + if userInitiated && !wasPaused { let userInitiatedIndex = strongSelf.takeNextUserInitiatedIndex() assignedUserInitiatedIndex = userInitiatedIndex + entry.userInitiatedPriorityIndices.removeAll() entry.userInitiatedPriorityIndices.append(userInitiatedIndex) entry.userInitiatedPriorityIndices.sort() } @@ -628,13 +758,13 @@ public final class FetchManagerImpl: FetchManager { entry.elevatedPriorityReferenceCount -= 1 assert(entry.elevatedPriorityReferenceCount >= 0) } - if let userInitiatedIndex = assignedUserInitiatedIndex { + /*if let userInitiatedIndex = assignedUserInitiatedIndex { if let index = entry.userInitiatedPriorityIndices.firstIndex(of: userInitiatedIndex) { entry.userInitiatedPriorityIndices.remove(at: index) } else { assertionFailure() } - } + }*/ } }) }) @@ -657,6 +787,48 @@ public final class FetchManagerImpl: FetchManager { } } + public func cancelInteractiveFetches(resourceId: String) { + self.queue.async { + var removeCategories: [FetchManagerCategory] = [] + for (category, categoryContext) in self.categoryContexts { + if let id = categoryContext.getEntryId(resourceId: resourceId) { + categoryContext.cancelEntry(id, isCompleted: false) + if categoryContext.isEmpty { + removeCategories.append(category) + } + } + } + + for category in removeCategories { + self.categoryContexts.removeValue(forKey: category) + } + + self.postbox.mediaBox.cancelInteractiveResourceFetch(resourceId: MediaResourceId(resourceId)) + } + } + + public func toggleInteractiveFetchPaused(resourceId: String, isPaused: Bool) { + self.queue.async { + for (_, categoryContext) in self.categoryContexts { + if let id = categoryContext.getEntryId(resourceId: resourceId) { + if categoryContext.toggleEntryPaused(id, isPaused: isPaused) { + self.postbox.mediaBox.cancelInteractiveResourceFetch(resourceId: MediaResourceId(resourceId)) + } + } + } + } + } + + public func raisePriority(resourceId: String) { + self.queue.async { + for (_, categoryContext) in self.categoryContexts { + if let id = categoryContext.getEntryId(resourceId: resourceId) { + categoryContext.raiseEntryPriority(id) + } + } + } + } + public func fetchStatus(category: FetchManagerCategory, location: FetchManagerLocation, locationKey: FetchManagerLocationKey, resource: MediaResource) -> Signal { let queue = self.queue return Signal { [weak self] subscriber in diff --git a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift index 068ab0b8a3..c96f9227da 100644 --- a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift +++ b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift @@ -209,7 +209,7 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll statusState = .cloudProgress(color: UIColor.white, strokeBackgroundColor: UIColor.white.withAlphaComponent(0.5), lineWidth: 2.0, value: CGFloat(adjustedProgress)) case .Local: break - case .Remote: + case .Remote, .Paused: if let image = cloudFetchIcon { statusState = .customIcon(image) } diff --git a/submodules/GalleryUI/Sources/Items/ChatAnimationGalleryItem.swift b/submodules/GalleryUI/Sources/Items/ChatAnimationGalleryItem.swift index 386a27af17..cb8cb9d308 100644 --- a/submodules/GalleryUI/Sources/Items/ChatAnimationGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/ChatAnimationGalleryItem.swift @@ -203,7 +203,7 @@ final class ChatAnimationGalleryItemNode: ZoomableContentGalleryItemNode { let previousStatus = strongSelf.status strongSelf.status = status switch status { - case .Remote: + case .Remote, .Paused: strongSelf.statusNode.isHidden = false strongSelf.statusNode.alpha = 1.0 strongSelf.statusNodeContainer.isUserInteractionEnabled = true diff --git a/submodules/GalleryUI/Sources/Items/ChatDocumentGalleryItem.swift b/submodules/GalleryUI/Sources/Items/ChatDocumentGalleryItem.swift index 4379cf5aca..b44a9336c5 100644 --- a/submodules/GalleryUI/Sources/Items/ChatDocumentGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/ChatDocumentGalleryItem.swift @@ -192,7 +192,7 @@ class ChatDocumentGalleryItemNode: ZoomableContentGalleryItemNode, WKNavigationD let previousStatus = strongSelf.status strongSelf.status = status switch status { - case .Remote: + case .Remote, .Paused: strongSelf.statusNode.isHidden = false strongSelf.statusNode.alpha = 1.0 strongSelf.statusNodeContainer.isUserInteractionEnabled = true diff --git a/submodules/GalleryUI/Sources/Items/ChatExternalFileGalleryItem.swift b/submodules/GalleryUI/Sources/Items/ChatExternalFileGalleryItem.swift index bd7f697d09..aab16e3fa9 100644 --- a/submodules/GalleryUI/Sources/Items/ChatExternalFileGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/ChatExternalFileGalleryItem.swift @@ -190,7 +190,7 @@ class ChatExternalFileGalleryItemNode: GalleryItemNode { let previousStatus = strongSelf.status strongSelf.status = status switch status { - case .Remote: + case .Remote, .Paused: strongSelf.statusNode.isHidden = false strongSelf.statusNode.alpha = 1.0 strongSelf.statusNodeContainer.isUserInteractionEnabled = true diff --git a/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift b/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift index b9da1334b7..4f8de99440 100644 --- a/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift @@ -529,7 +529,7 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { let previousStatus = strongSelf.status strongSelf.status = status switch status { - case .Remote: + case .Remote, .Paused: strongSelf.statusNode.isHidden = false strongSelf.statusNode.alpha = 1.0 strongSelf.statusNodeContainer.isUserInteractionEnabled = true diff --git a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift index 63137ec516..e37bb44121 100644 --- a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift @@ -866,7 +866,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { switch fetchStatus { case .Fetching: fetchControls.cancel() - case .Remote: + case .Remote, .Paused: fetchControls.fetch() case .Local: break @@ -2044,7 +2044,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { switch fetchStatus { case .Local: videoNode.playOnceWithSound(playAndRecord: false, seek: .none, actionAtEnd: self.actionAtEnd) - case .Remote: + case .Remote, .Paused: if self.requiresDownload { self.fetchControls?.fetch() } else { diff --git a/submodules/GridMessageSelectionNode/Sources/GridMessageSelectionNode.swift b/submodules/GridMessageSelectionNode/Sources/GridMessageSelectionNode.swift index 97da49b7be..98507d97e5 100644 --- a/submodules/GridMessageSelectionNode/Sources/GridMessageSelectionNode.swift +++ b/submodules/GridMessageSelectionNode/Sources/GridMessageSelectionNode.swift @@ -60,13 +60,6 @@ public final class GridMessageSelectionNode: ASDisplayNode { } } -private final class NullActionClass: NSObject, CAAction { - @objc func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { - } -} - -private let nullAction = NullActionClass() - public final class GridMessageSelectionLayer: CALayer { private var selected = false private let checkLayer: CheckLayer diff --git a/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift b/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift index 41ceb62c87..537e40c918 100644 --- a/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift +++ b/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift @@ -45,7 +45,7 @@ public final class HashtagSearchController: TelegramBaseController { |> map { result, presentationData in let result = result.0 let chatListPresentationData = ChatListPresentationData(theme: presentationData.theme, fontSize: presentationData.listsFontSize, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameSortOrder: presentationData.nameSortOrder, nameDisplayOrder: presentationData.nameDisplayOrder, disableAnimations: true) - return result.messages.map({ .message(EngineMessage($0), EngineRenderedPeer(message: EngineMessage($0)), result.readStates[$0.id.peerId].flatMap(EnginePeerReadCounters.init), chatListPresentationData, result.totalCount, nil, false) }) + return result.messages.map({ .message(EngineMessage($0), EngineRenderedPeer(message: EngineMessage($0)), result.readStates[$0.id.peerId].flatMap(EnginePeerReadCounters.init), chatListPresentationData, result.totalCount, nil, false, .index($0.index), nil, .generic, false) }) } let interaction = ChatListNodeInteraction(activateSearch: { }, peerSelected: { _, _, _ in @@ -95,10 +95,10 @@ public final class HashtagSearchController: TelegramBaseController { }) let firstTime = previousEntries == nil - let transition = chatListSearchContainerPreparedTransition(from: previousEntries ?? [], to: entries, displayingResults: true, isEmpty: entries.isEmpty, isLoading: false, animated: false, context: strongSelf.context, presentationData: strongSelf.presentationData, enableHeaders: false, filter: [], tagMask: nil, interaction: interaction, listInteraction: listInteraction, peerContextAction: nil, toggleExpandLocalResults: { + let transition = chatListSearchContainerPreparedTransition(from: previousEntries ?? [], to: entries, displayingResults: true, isEmpty: entries.isEmpty, isLoading: false, animated: false, context: strongSelf.context, presentationData: strongSelf.presentationData, enableHeaders: false, filter: [], key: .chats, tagMask: nil, interaction: interaction, listInteraction: listInteraction, peerContextAction: nil, toggleExpandLocalResults: { }, toggleExpandGlobalResults: { }, searchPeer: { _ in - }, searchQuery: "", searchOptions: nil, messageContextAction: nil) + }, searchQuery: "", searchOptions: nil, messageContextAction: nil, openStorageSettings: {}, toggleAllPaused: {}) strongSelf.controllerNode.enqueueTransition(transition, firstTime: firstTime) } }) diff --git a/submodules/InstantPageUI/Sources/InstantPageImageNode.swift b/submodules/InstantPageUI/Sources/InstantPageImageNode.swift index 559bf1e7f5..f7ceffea21 100644 --- a/submodules/InstantPageUI/Sources/InstantPageImageNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageImageNode.swift @@ -299,7 +299,7 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { default: break } - case .Remote: + case .Remote, .Paused: if case .tap = gesture { self.fetchControls?.fetch(true) } diff --git a/submodules/InstantPageUI/Sources/InstantPagePlayableVideoNode.swift b/submodules/InstantPageUI/Sources/InstantPagePlayableVideoNode.swift index ddf4c72a5d..86b0921dce 100644 --- a/submodules/InstantPageUI/Sources/InstantPagePlayableVideoNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPagePlayableVideoNode.swift @@ -167,7 +167,7 @@ final class InstantPagePlayableVideoNode: ASDisplayNode, InstantPageNode, Galler switch fetchStatus { case .Local: self.openMedia(self.media) - case .Remote: + case .Remote, .Paused: self.fetchControls?.fetch(true) case .Fetching: self.fetchControls?.cancel() diff --git a/submodules/ListMessageItem/Sources/ListMessageFileItemNode.swift b/submodules/ListMessageItem/Sources/ListMessageFileItemNode.swift index b43e226c65..a7f4a123e6 100644 --- a/submodules/ListMessageItem/Sources/ListMessageFileItemNode.swift +++ b/submodules/ListMessageItem/Sources/ListMessageFileItemNode.swift @@ -360,7 +360,6 @@ public final class ListMessageFileItemNode: ListMessageNode { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } - override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { var rect = rect rect.origin.y += self.insets.top @@ -446,7 +445,7 @@ public final class ListMessageFileItemNode: ListMessageNode { var descriptionString: String if let performer = performer { - if item.isGlobalSearchResult { + if item.isGlobalSearchResult || item.isDownloadList { descriptionString = performer } else { descriptionString = "\(stringForDuration(Int32(duration))) • \(performer)" @@ -457,7 +456,7 @@ public final class ListMessageFileItemNode: ListMessageNode { descriptionString = "" } - if item.isGlobalSearchResult { + if item.isGlobalSearchResult || item.isDownloadList { let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) if descriptionString.isEmpty { descriptionString = authorString @@ -501,7 +500,7 @@ public final class ListMessageFileItemNode: ListMessageNode { authorName = " " } - if item.isGlobalSearchResult { + if item.isGlobalSearchResult || item.isDownloadList { authorName = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) } @@ -509,13 +508,13 @@ public final class ListMessageFileItemNode: ListMessageNode { let dateString = stringForFullDate(timestamp: message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) var descriptionString: String = "" if let duration = file.duration { - if item.isGlobalSearchResult || !item.displayFileInfo { + if item.isGlobalSearchResult || item.isDownloadList || !item.displayFileInfo { descriptionString = stringForDuration(Int32(duration)) } else { descriptionString = "\(stringForDuration(Int32(duration))) • \(dateString)" } } else { - if !item.isGlobalSearchResult { + if !(item.isGlobalSearchResult || item.isDownloadList) { descriptionString = dateString } } @@ -523,7 +522,7 @@ public final class ListMessageFileItemNode: ListMessageNode { descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) iconImage = .roundVideo(file) } else if !isAudio { - let fileName: String = file.fileName ?? "" + let fileName: String = file.fileName ?? "File" titleText = NSAttributedString(string: fileName, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) var fileExtension: String? @@ -543,18 +542,18 @@ public final class ListMessageFileItemNode: ListMessageNode { var descriptionString: String = "" if let size = file.size { - if item.isGlobalSearchResult || !item.displayFileInfo { + if item.isGlobalSearchResult || item.isDownloadList || !item.displayFileInfo { descriptionString = dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData)) } else { descriptionString = "\(dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData))) • \(dateString)" } } else { - if !item.isGlobalSearchResult { + if !(item.isGlobalSearchResult || item.isDownloadList) { descriptionString = "\(dateString)" } } - if item.isGlobalSearchResult { + if item.isGlobalSearchResult || item.isDownloadList { let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) if descriptionString.isEmpty { descriptionString = authorString @@ -581,11 +580,11 @@ public final class ListMessageFileItemNode: ListMessageNode { let dateString = stringForFullDate(timestamp: message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) var descriptionString: String = "" - if !item.isGlobalSearchResult { + if !(item.isGlobalSearchResult || item.isDownloadList) { descriptionString = "\(dateString)" } - if item.isGlobalSearchResult { + if item.isGlobalSearchResult || item.isDownloadList { let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) if descriptionString.isEmpty { descriptionString = authorString @@ -597,7 +596,7 @@ public final class ListMessageFileItemNode: ListMessageNode { descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) } } - + for attribute in message.attributes { if let attribute = attribute as? RestrictedContentMessageAttribute, attribute.platformText(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) != nil { isRestricted = true @@ -638,16 +637,24 @@ public final class ListMessageFileItemNode: ListMessageNode { } }, cancel: { if let file = selectedMedia as? TelegramMediaFile { - messageMediaFileCancelInteractiveFetch(context: context, messageId: message.id, file: file) + if item.isDownloadList { + context.fetchManager.toggleInteractiveFetchPaused(resourceId: file.resource.id.stringRepresentation, isPaused: true) + } else { + messageMediaFileCancelInteractiveFetch(context: context, messageId: message.id, file: file) + } } else if let image = selectedMedia as? TelegramMediaImage, let representation = image.representations.last { - messageMediaImageCancelInteractiveFetch(context: context, messageId: message.id, image: image, resource: representation.resource) + if item.isDownloadList { + context.fetchManager.toggleInteractiveFetchPaused(resourceId: representation.resource.id.stringRepresentation, isPaused: true) + } else { + messageMediaImageCancelInteractiveFetch(context: context, messageId: message.id, image: image, resource: representation.resource) + } } }) } if statusUpdated && item.displayFileInfo { if let file = selectedMedia as? TelegramMediaFile { - updatedStatusSignal = messageFileMediaResourceStatus(context: item.context, file: file, message: message, isRecentActions: false, isSharedMedia: true, isGlobalSearch: item.isGlobalSearchResult) + updatedStatusSignal = messageFileMediaResourceStatus(context: item.context, file: file, message: message, isRecentActions: false, isSharedMedia: true, isGlobalSearch: item.isGlobalSearchResult || item.isDownloadList) |> mapToSignal { value -> Signal in if case .Fetching = value.fetchStatus { return .single(value) |> delay(0.1, queue: Queue.concurrentDefaultQueue()) @@ -670,10 +677,10 @@ public final class ListMessageFileItemNode: ListMessageNode { } } if isVoice { - updatedPlaybackStatusSignal = messageFileMediaPlaybackStatus(context: item.context, file: file, message: message, isRecentActions: false, isGlobalSearch: item.isGlobalSearchResult) + updatedPlaybackStatusSignal = messageFileMediaPlaybackStatus(context: item.context, file: file, message: message, isRecentActions: false, isGlobalSearch: item.isGlobalSearchResult || item.isDownloadList) } } else if let image = selectedMedia as? TelegramMediaImage { - updatedStatusSignal = messageImageMediaResourceStatus(context: item.context, image: image, message: message, isRecentActions: false, isSharedMedia: true, isGlobalSearch: item.isGlobalSearchResult) + updatedStatusSignal = messageImageMediaResourceStatus(context: item.context, image: image, message: message, isRecentActions: false, isSharedMedia: true, isGlobalSearch: item.isGlobalSearchResult || item.isDownloadList) |> mapToSignal { value -> Signal in if case .Fetching = value.fetchStatus { return .single(value) |> delay(0.1, queue: Queue.concurrentDefaultQueue()) @@ -929,7 +936,7 @@ public final class ListMessageFileItemNode: ListMessageNode { break case let .fetchStatus(fetchStatus): switch fetchStatus { - case .Remote, .Fetching: + case .Remote, .Fetching, .Paused: descriptionOffset = 14.0 case .Local: break @@ -1100,7 +1107,7 @@ public final class ListMessageFileItemNode: ListMessageNode { if isAudio || isInstantVideo { iconStatusState = .play } - case .Remote: + case .Remote, .Paused: if isAudio || isInstantVideo { iconStatusState = .play } @@ -1151,7 +1158,7 @@ public final class ListMessageFileItemNode: ListMessageNode { } override public func transitionNode(id: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { - if let item = self.item, item.message?.id == id, self.iconImageNode.supernode != nil { + if let item = self.item, let message = item.message, message.id == id, self.iconImageNode.supernode != nil { let iconImageNode = self.iconImageNode return (self.iconImageNode, self.iconImageNode.bounds, { [weak iconImageNode] in return (iconImageNode?.view.snapshotContentTree(unhide: true), nil) @@ -1190,7 +1197,7 @@ public final class ListMessageFileItemNode: ListMessageNode { case let .fetchStatus(fetchStatus): maybeFetchStatus = fetchStatus switch fetchStatus { - case let .Fetching(_, progress): + case .Fetching(_, let progress), .Paused(let progress): if let file = self.currentMedia as? TelegramMediaFile, let size = file.size { downloadingString = "\(dataSizeString(Int(Float(size) * progress), forceDecimal: true, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData))) / \(dataSizeString(size, forceDecimal: true, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData)))" } @@ -1203,8 +1210,8 @@ public final class ListMessageFileItemNode: ListMessageNode { } switch maybeFetchStatus { - case let .Fetching(_, progress): - let progressFrame = CGRect(x: self.currentLeftOffset + leftInset + 65.0, y: size.height - 2.0, width: floor((size.width - 65.0 - leftInset - rightInset)), height: 3.0) + case .Fetching(_, let progress), .Paused(let progress): + let progressFrame = CGRect(x: self.currentLeftOffset + leftInset + 65.0, y: size.height - 3.0, width: floor((size.width - 65.0 - leftInset - rightInset)), height: 3.0) let linearProgressNode: LinearProgressNode if let current = self.linearProgressNode { linearProgressNode = current @@ -1223,7 +1230,11 @@ public final class ListMessageFileItemNode: ListMessageNode { animated = false self.offsetContainerNode.addSubnode(downloadStatusIconNode) } - downloadStatusIconNode.enqueueState(.pause, animated: animated) + if case .Paused = maybeFetchStatus { + downloadStatusIconNode.enqueueState(.download, animated: animated) + } else { + downloadStatusIconNode.enqueueState(.pause, animated: animated) + } } case .Local: if let linearProgressNode = self.linearProgressNode { @@ -1304,12 +1315,12 @@ public final class ListMessageFileItemNode: ListMessageNode { if let cancel = self.fetchControls.with({ return $0?.cancel }) { cancel() } - case .Remote: + case .Remote, .Paused: if let fetch = self.fetchControls.with({ return $0?.fetch }) { fetch() } case .Local: - if let item = self.item, let interaction = self.interaction, let message = item.message { + if let item = self.item, let message = item.message, let interaction = self.interaction { let _ = interaction.openMessage(message, .default) } } @@ -1344,7 +1355,7 @@ public final class ListMessageFileItemNode: ListMessageNode { if let cancel = self.fetchControls.with({ return $0?.cancel }) { cancel() } - case .Remote: + case .Remote, .Paused: if let fetch = self.fetchControls.with({ return $0?.fetch }) { fetch() } diff --git a/submodules/ListMessageItem/Sources/ListMessageItem.swift b/submodules/ListMessageItem/Sources/ListMessageItem.swift index bfb9417677..f05478845a 100644 --- a/submodules/ListMessageItem/Sources/ListMessageItem.swift +++ b/submodules/ListMessageItem/Sources/ListMessageItem.swift @@ -52,6 +52,7 @@ public final class ListMessageItem: ListViewItem { public let selection: ChatHistoryMessageSelection let hintIsLink: Bool let isGlobalSearchResult: Bool + let isDownloadList: Bool let displayFileInfo: Bool let displayBackground: Bool let style: ItemListStyle @@ -60,7 +61,7 @@ public final class ListMessageItem: ListViewItem { public let selectable: Bool = true - public init(presentationData: ChatPresentationData, context: AccountContext, chatLocation: ChatLocation, interaction: ListMessageItemInteraction, message: Message?, selection: ChatHistoryMessageSelection, displayHeader: Bool, customHeader: ListViewItemHeader? = nil, hintIsLink: Bool = false, isGlobalSearchResult: Bool = false, displayFileInfo: Bool = true, displayBackground: Bool = false, style: ItemListStyle = .plain) { + public init(presentationData: ChatPresentationData, context: AccountContext, chatLocation: ChatLocation, interaction: ListMessageItemInteraction, message: Message?, selection: ChatHistoryMessageSelection, displayHeader: Bool, customHeader: ListViewItemHeader? = nil, hintIsLink: Bool = false, isGlobalSearchResult: Bool = false, isDownloadList: Bool = false, displayFileInfo: Bool = true, displayBackground: Bool = false, style: ItemListStyle = .plain) { self.presentationData = presentationData self.context = context self.chatLocation = chatLocation @@ -76,6 +77,7 @@ public final class ListMessageItem: ListViewItem { self.selection = selection self.hintIsLink = hintIsLink self.isGlobalSearchResult = isGlobalSearchResult + self.isDownloadList = isDownloadList self.displayFileInfo = displayFileInfo self.displayBackground = displayBackground self.style = style diff --git a/submodules/PhotoResources/Sources/PhotoResources.swift b/submodules/PhotoResources/Sources/PhotoResources.swift index c2982a9ff6..144fe79123 100644 --- a/submodules/PhotoResources/Sources/PhotoResources.swift +++ b/submodules/PhotoResources/Sources/PhotoResources.swift @@ -1673,10 +1673,12 @@ public func chatMessagePhotoStatus(context: AccountContext, messageId: MessageId switch status { case .Local: return .Local - case .Remote: - return .Remote + case let .Remote(progress): + return .Remote(progress: progress) case let .Fetching(isActive, progress): return .Fetching(isActive: isActive, progress: max(progress, 0.0)) + case let .Paused(progress): + return .Paused(progress: progress) } } |> distinctUntilChanged diff --git a/submodules/Postbox/Sources/ContactTable.swift b/submodules/Postbox/Sources/ContactTable.swift index 84ead561b2..e774bd3e60 100644 --- a/submodules/Postbox/Sources/ContactTable.swift +++ b/submodules/Postbox/Sources/ContactTable.swift @@ -21,14 +21,6 @@ final class ContactTable: Table { return sharedKey } - private func lowerBound() -> ValueBoxKey { - return self.key(PeerId(0)) - } - - private func upperBound() -> ValueBoxKey { - return self.key(PeerId.max) - } - func isContact(peerId: PeerId) -> Bool { return self.get().contains(peerId) } @@ -38,10 +30,10 @@ final class ContactTable: Table { return peerIds } else { var peerIds = Set() - self.valueBox.range(self.table, start: self.lowerBound(), end: self.upperBound(), keys: { key in + self.valueBox.scan(self.table, keys: { key in peerIds.insert(PeerId(key.getInt64(0))) return true - }, limit: 0) + }) self.peerIds = peerIds return peerIds } diff --git a/submodules/Postbox/Sources/MediaBox.swift b/submodules/Postbox/Sources/MediaBox.swift index 3887b62ed7..5d0d6ea91f 100644 --- a/submodules/Postbox/Sources/MediaBox.swift +++ b/submodules/Postbox/Sources/MediaBox.swift @@ -142,6 +142,11 @@ public final class MediaBox { private let cacheQueue = Queue() private let timeBasedCleanup: TimeBasedCleanup + private let didRemoveResourcesPipe = ValuePipe() + public var didRemoveResources: Signal { + return .single(Void()) |> then(self.didRemoveResourcesPipe.signal()) + } + private var statusContexts: [MediaResourceId: ResourceStatusContext] = [:] private var cachedRepresentationContexts: [CachedMediaResourceRepresentationKey: CachedMediaResourceRepresentationContext] = [:] @@ -310,11 +315,15 @@ public final class MediaBox { } public func resourceStatus(_ resource: MediaResource, approximateSynchronousValue: Bool = false) -> Signal { + return self.resourceStatus(resource.id, resourceSize: resource.size, approximateSynchronousValue: approximateSynchronousValue) + } + + public func resourceStatus(_ resourceId: MediaResourceId, resourceSize: Int?, approximateSynchronousValue: Bool = false) -> Signal { let signal = Signal { subscriber in let disposable = MetaDisposable() self.concurrentQueue.async { - let paths = self.storePathsForId(resource.id) + let paths = self.storePathsForId(resourceId) if let _ = fileSize(paths.complete) { self.timeBasedCleanup.touch(paths: [ @@ -324,7 +333,6 @@ public final class MediaBox { subscriber.putCompletion() } else { self.statusQueue.async { - let resourceId = resource.id let statusContext: ResourceStatusContext var statusUpdateDisposable: MetaDisposable? if let current = self.statusContexts[resourceId] { @@ -347,7 +355,7 @@ public final class MediaBox { if let statusUpdateDisposable = statusUpdateDisposable { let statusQueue = self.statusQueue self.dataQueue.async { - if let (fileContext, releaseContext) = self.fileContext(for: resource.id) { + if let (fileContext, releaseContext) = self.fileContext(for: resourceId) { let statusDisposable = fileContext.status(next: { [weak statusContext] value in statusQueue.async { if let current = self.statusContexts[resourceId], current === statusContext, current.status != value { @@ -367,7 +375,7 @@ public final class MediaBox { } } } - }, size: resource.size.flatMap(Int32.init)) + }, size: resourceSize.flatMap(Int32.init)) statusUpdateDisposable.set(ActionDisposable { statusDisposable.dispose() releaseContext() @@ -378,10 +386,10 @@ public final class MediaBox { disposable.set(ActionDisposable { [weak statusContext] in self.statusQueue.async { - if let current = self.statusContexts[resource.id], current === statusContext { + if let current = self.statusContexts[resourceId], current === statusContext { current.subscribers.remove(index) if current.subscribers.isEmpty { - self.statusContexts.removeValue(forKey: resource.id) + self.statusContexts.removeValue(forKey: resourceId) current.disposable.dispose() } } @@ -395,13 +403,13 @@ public final class MediaBox { } if approximateSynchronousValue { return Signal, NoError> { subscriber in - let paths = self.storePathsForId(resource.id) + let paths = self.storePathsForId(resourceId) if let _ = fileSize(paths.complete) { subscriber.putNext(.single(.Local)) - } else if let size = fileSize(paths.partial), size == resource.size { + } else if let size = fileSize(paths.partial), size == resourceSize { subscriber.putNext(.single(.Local)) } else { - subscriber.putNext(.single(.Remote) |> then(signal)) + subscriber.putNext(.single(.Remote(progress: 0.0)) |> then(signal)) } subscriber.putCompletion() return EmptyDisposable @@ -775,8 +783,12 @@ public final class MediaBox { } public func cancelInteractiveResourceFetch(_ resource: MediaResource) { + self.cancelInteractiveResourceFetch(resourceId: resource.id) + } + + public func cancelInteractiveResourceFetch(resourceId: MediaResourceId) { self.dataQueue.async { - if let (fileContext, releaseContext) = self.fileContext(for: resource.id) { + if let (fileContext, releaseContext) = self.fileContext(for: resourceId) { fileContext.cancelFullRangeFetches() releaseContext() } @@ -1287,7 +1299,7 @@ public final class MediaBox { } } - public func removeCachedResources(_ ids: Set, force: Bool = false) -> Signal { + public func removeCachedResources(_ ids: Set, force: Bool = false, notify: Bool = false) -> Signal { return Signal { subscriber in self.dataQueue.async { let uniqueIds = Set(ids.map { $0.stringRepresentation }) @@ -1352,6 +1364,21 @@ public final class MediaBox { reportProgress(count) } + if notify { + for id in ids { + if let context = self.statusContexts[id] { + context.status = .Remote(progress: 0.0) + for f in context.subscribers.copyItems() { + f(.Remote(progress: 0.0)) + } + } + } + } + + self.dataQueue.justDispatch { + self.didRemoveResourcesPipe.putNext(Void()) + } + subscriber.putCompletion() } return EmptyDisposable diff --git a/submodules/Postbox/Sources/MediaBoxFile.swift b/submodules/Postbox/Sources/MediaBoxFile.swift index 66259bdc81..c9871455e3 100644 --- a/submodules/Postbox/Sources/MediaBoxFile.swift +++ b/submodules/Postbox/Sources/MediaBoxFile.swift @@ -605,7 +605,15 @@ final class MediaBoxPartialFile { if let truncationSize = self.fileMap.truncationSize, self.fileMap.sum == truncationSize { status = .Local } else { - status = .Remote + let progress: Float + if let truncationSize = self.fileMap.truncationSize, truncationSize != 0 { + progress = Float(self.fileMap.sum) / Float(truncationSize) + } else if let size = size { + progress = Float(self.fileMap.sum) / Float(size) + } else { + progress = self.fileMap.progress ?? 0.0 + } + status = .Remote(progress: progress) } } else { let progress: Float diff --git a/submodules/Postbox/Sources/MediaResourceStatus.swift b/submodules/Postbox/Sources/MediaResourceStatus.swift index 189a992326..33c8a8cdd9 100644 --- a/submodules/Postbox/Sources/MediaResourceStatus.swift +++ b/submodules/Postbox/Sources/MediaResourceStatus.swift @@ -2,33 +2,8 @@ import Foundation import SwiftSignalKit public enum MediaResourceStatus: Equatable { - case Remote + case Remote(progress: Float) case Local case Fetching(isActive: Bool, progress: Float) -} - -public func ==(lhs: MediaResourceStatus, rhs: MediaResourceStatus) -> Bool { - switch lhs { - case .Remote: - switch rhs { - case .Remote: - return true - default: - return false - } - case .Local: - switch rhs { - case .Local: - return true - default: - return false - } - case let .Fetching(lhsIsActive, lhsProgress): - switch rhs { - case let .Fetching(rhsIsActive, rhsProgress): - return lhsIsActive == rhsIsActive && lhsProgress.isEqual(to: rhsProgress) - default: - return false - } - } + case Paused(progress: Float) } diff --git a/submodules/Postbox/Sources/OrderedItemListTable.swift b/submodules/Postbox/Sources/OrderedItemListTable.swift index 0754986fd3..a42f716082 100644 --- a/submodules/Postbox/Sources/OrderedItemListTable.swift +++ b/submodules/Postbox/Sources/OrderedItemListTable.swift @@ -161,16 +161,18 @@ final class OrderedItemListTable: Table { } func addItemOrMoveToFirstPosition(collectionId: Int32, item: OrderedItemListEntry, removeTailIfCountExceeds: Int?, operations: inout [Int32: [OrderedItemListOperation]]) { - if let index = self.getIndex(collectionId: collectionId, id: item.id), index == 0 { - return - } - if operations[collectionId] == nil { operations[collectionId] = [.addOrMoveToFirstPosition(item, removeTailIfCountExceeds)] } else { operations[collectionId]!.append(.addOrMoveToFirstPosition(item, removeTailIfCountExceeds)) } + if let index = self.getIndex(collectionId: collectionId, id: item.id), index == 0 { + self.indexTable.set(collectionId: collectionId, id: item.id, content: item.contents) + + return + } + var orderedIds = self.getItemIds(collectionId: collectionId) let offsetUntilIndex: Int diff --git a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift index 582210fafd..9d5681fa61 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift @@ -635,6 +635,11 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { targetView.imageView.alpha = 0.0 targetView.addSubnode(itemNode) itemNode.frame = targetView.bounds + + if strongSelf.hapticFeedback == nil { + strongSelf.hapticFeedback = HapticFeedback() + } + strongSelf.hapticFeedback?.tap() }) }) diff --git a/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift b/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift index 3defd26afc..b9d5eb9af7 100644 --- a/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift +++ b/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift @@ -56,6 +56,8 @@ public func fetchMediaData(context: AccountContext, postbox: Postbox, mediaRefer subscriber.putNext(.progress(0.0)) case let .Fetching(_, progress): subscriber.putNext(.progress(progress)) + case let .Paused(progress): + subscriber.putNext(.progress(progress)) } }) let data = postbox.mediaBox.resourceData(resource, pathExtension: fileExtension, option: .complete(waitUntilFetchStatus: true)).start(next: { next in diff --git a/submodules/SearchBarNode/BUILD b/submodules/SearchBarNode/BUILD index 5545d3b679..87d6d23c56 100644 --- a/submodules/SearchBarNode/BUILD +++ b/submodules/SearchBarNode/BUILD @@ -16,6 +16,7 @@ swift_library( "//submodules/TelegramPresentationData:TelegramPresentationData", "//submodules/ActivityIndicator:ActivityIndicator", "//submodules/AppBundle:AppBundle", + "//submodules/ComponentFlow:ComponentFlow", ], visibility = [ "//visibility:public", diff --git a/submodules/SearchBarNode/Sources/SearchBarNode.swift b/submodules/SearchBarNode/Sources/SearchBarNode.swift index 9dec26f70d..96b50ab5b8 100644 --- a/submodules/SearchBarNode/Sources/SearchBarNode.swift +++ b/submodules/SearchBarNode/Sources/SearchBarNode.swift @@ -1053,9 +1053,14 @@ public class SearchBarNode: ASDisplayNode, UITextFieldDelegate { self.textBackgroundNode.isHidden = true - self.textBackgroundNode.layer.animateFrame(from: self.textBackgroundNode.frame, to: targetTextBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false, completion: { _ in + self.textBackgroundNode.layer.animateFrame(from: self.textBackgroundNode.frame, to: targetTextBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false, completion: { [weak node] _ in textBackgroundCompleted = true intermediateCompletion() + + if let node = node, let accessoryComponentView = node.accessoryComponentView { + accessoryComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + accessoryComponentView.layer.animateScale(from: 0.01, to: 1.0, duration: 0.35, timingFunction: kCAMediaTimingFunctionSpring) + } }) let transitionBackgroundNode = ASDisplayNode() diff --git a/submodules/SearchBarNode/Sources/SearchBarPlaceholderNode.swift b/submodules/SearchBarNode/Sources/SearchBarPlaceholderNode.swift index 499ee864fd..9b557c036d 100644 --- a/submodules/SearchBarNode/Sources/SearchBarPlaceholderNode.swift +++ b/submodules/SearchBarNode/Sources/SearchBarPlaceholderNode.swift @@ -4,6 +4,7 @@ import SwiftSignalKit import AsyncDisplayKit import Display import AppBundle +import ComponentFlow private let templateLoupeIcon = UIImage(bundleImageName: "Components/Search Bar/Loupe") @@ -35,6 +36,8 @@ public class SearchBarPlaceholderNode: ASDisplayNode { public private(set) var placeholderString: NSAttributedString? + var accessoryComponentView: ComponentHostView? + convenience public override init() { self.init(fieldStyle: .legacy) } @@ -105,6 +108,32 @@ public class SearchBarPlaceholderNode: ASDisplayNode { }) } + public func setAccessoryComponent(component: AnyComponent?) { + if let component = component { + let accessoryComponentView: ComponentHostView + if let current = self.accessoryComponentView { + accessoryComponentView = current + } else { + accessoryComponentView = ComponentHostView() + self.accessoryComponentView = accessoryComponentView + self.view.addSubview(accessoryComponentView) + } + let accessorySize = accessoryComponentView.update( + transition: .immediate, + component: component, + environment: {}, + containerSize: CGSize(width: 32.0, height: 32.0) + ) + accessoryComponentView.frame = CGRect(origin: CGPoint(x: self.bounds.width - accessorySize.width - 4.0, y: floor((self.bounds.height - accessorySize.height) / 2.0)), size: accessorySize) + } else if let accessoryComponentView = self.accessoryComponentView { + self.accessoryComponentView = nil + accessoryComponentView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) + accessoryComponentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak accessoryComponentView] _ in + accessoryComponentView?.removeFromSuperview() + }) + } + } + public func asyncLayout() -> (_ placeholderString: NSAttributedString?, _ constrainedSize: CGSize, _ expansionProgress: CGFloat, _ iconColor: UIColor, _ foregroundColor: UIColor, _ backgroundColor: UIColor, _ transition: ContainedViewLayoutTransition) -> (CGFloat, () -> Void) { let labelLayout = TextNode.asyncLayout(self.labelNode) let currentForegroundColor = self.foregroundColor @@ -183,6 +212,10 @@ public class SearchBarPlaceholderNode: ASDisplayNode { transition.updateCornerRadius(node: strongSelf.backgroundNode, cornerRadius: cornerRadius) transition.updateAlpha(node: strongSelf.backgroundNode, alpha: outerAlpha) transition.updateFrame(node: strongSelf.backgroundNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: constrainedSize.width, height: height))) + + if let accessoryComponentView = strongSelf.accessoryComponentView { + accessoryComponentView.frame = CGRect(origin: CGPoint(x: constrainedSize.width - accessoryComponentView.bounds.width - 4.0, y: floor((constrainedSize.height - accessoryComponentView.bounds.height) / 2.0)), size: accessoryComponentView.bounds.size) + } } }) } diff --git a/submodules/SettingsUI/Sources/Downloads/DownloadsController.swift b/submodules/SettingsUI/Sources/Downloads/DownloadsController.swift deleted file mode 100644 index 9b9188487d..0000000000 --- a/submodules/SettingsUI/Sources/Downloads/DownloadsController.swift +++ /dev/null @@ -1,337 +0,0 @@ -import Foundation -import UIKit -import Display -import AsyncDisplayKit -import SwiftSignalKit -import Postbox -import TelegramCore -import TelegramPresentationData -import TelegramUIPreferences -import ItemListUI -import PresentationDataUtils -import AccountContext -import ReactionImageComponent -import WebPBinding -import FetchManagerImpl -import ListMessageItem -import ListSectionHeaderNode - -private struct DownloadItem: Equatable { - let resourceId: MediaResourceId - let message: Message - let priority: FetchManagerPriorityKey - - static func ==(lhs: DownloadItem, rhs: DownloadItem) -> Bool { - if lhs.resourceId != rhs.resourceId { - return false - } - if lhs.message.id != rhs.message.id { - return false - } - if lhs.priority != rhs.priority { - return false - } - return true - } -} - -private final class DownloadsControllerArguments { - let context: AccountContext - - init( - context: AccountContext - ) { - self.context = context - } -} - -private enum DownloadsControllerSection: Int32 { - case items -} - -public final class DownloadsItemHeader: ListViewItemHeader { - public let id: ListViewItemNode.HeaderId - public let title: String - public let stickDirection: ListViewItemHeaderStickDirection = .top - public let stickOverInsets: Bool = true - public let theme: PresentationTheme - - public let height: CGFloat = 28.0 - - public init(id: ListViewItemNode.HeaderId, title: String, theme: PresentationTheme) { - self.id = id - self.title = title - self.theme = theme - } - - public func combinesWith(other: ListViewItemHeader) -> Bool { - if let other = other as? DownloadsItemHeader, other.id == self.id { - return true - } else { - return false - } - } - - public func node(synchronousLoad: Bool) -> ListViewItemHeaderNode { - return DownloadsItemHeaderNode(title: self.title, theme: self.theme) - } - - public func updateNode(_ node: ListViewItemHeaderNode, previous: ListViewItemHeader?, next: ListViewItemHeader?) { - (node as? DownloadsItemHeaderNode)?.update(title: self.title) - } -} - -public final class DownloadsItemHeaderNode: ListViewItemHeaderNode { - private var title: String - private var theme: PresentationTheme - - private var validLayout: (size: CGSize, leftInset: CGFloat, rightInset: CGFloat)? - - private let sectionHeaderNode: ListSectionHeaderNode - - public init(title: String, theme: PresentationTheme) { - self.title = title - self.theme = theme - - self.sectionHeaderNode = ListSectionHeaderNode(theme: theme) - - super.init() - - self.sectionHeaderNode.title = title - self.sectionHeaderNode.action = nil - - self.addSubnode(self.sectionHeaderNode) - } - - public func updateTheme(theme: PresentationTheme) { - self.theme = theme - self.sectionHeaderNode.updateTheme(theme: theme) - } - - public func update(title: String) { - self.sectionHeaderNode.title = title - self.sectionHeaderNode.action = nil - - if let (size, leftInset, rightInset) = self.validLayout { - self.sectionHeaderNode.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset) - } - } - - override public func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat) { - self.validLayout = (size, leftInset, rightInset) - self.sectionHeaderNode.frame = CGRect(origin: CGPoint(), size: size) - self.sectionHeaderNode.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset) - } - - override public func animateRemoved(duration: Double) { - self.alpha = 0.0 - self.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, removeOnCompletion: true) - } -} - -private enum DownloadsControllerEntry: ItemListNodeEntry { - enum StableId: Hashable { - case item(MediaResourceId) - } - - case item(item: DownloadItem) - - var section: ItemListSectionId { - switch self { - case .item: - return DownloadsControllerSection.items.rawValue - } - } - - var stableId: StableId { - switch self { - case let .item(item): - return .item(item.resourceId) - } - } - - var sortId: FetchManagerPriorityKey { - switch self { - case let .item(item): - return item.priority - } - } - - static func ==(lhs: DownloadsControllerEntry, rhs: DownloadsControllerEntry) -> Bool { - switch lhs { - case let .item(item): - if case .item(item) = rhs { - return true - } else { - return false - } - } - } - - static func <(lhs: DownloadsControllerEntry, rhs: DownloadsControllerEntry) -> Bool { - return lhs.sortId < rhs.sortId - } - - func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { - let arguments = arguments as! DownloadsControllerArguments - let _ = arguments - switch self { - case let .item(item): - let listInteraction = ListMessageItemInteraction(openMessage: { message, mode -> Bool in - return false - }, openMessageContextMenu: { message, _, node, rect, gesture in - }, toggleMessagesSelection: { messageId, selected in - }, openUrl: { url, _, _, message in - }, openInstantPage: { message, data in - }, longTap: { action, message in - }, getHiddenMedia: { - return [:] - }) - - let presentationData = arguments.context.sharedContext.currentPresentationData.with({ $0 }) - - return ListMessageItem(presentationData: ChatPresentationData(presentationData: presentationData), context: arguments.context, chatLocation: .peer(item.message.id.peerId), interaction: listInteraction, message: item.message, selection: .none, displayHeader: false, customHeader: nil/*DownloadsItemHeader(id: ListViewItemNode.HeaderId(space: 0, id: item.message.id.peerId), title: item.message.peers[item.message.id.peerId].flatMap(EnginePeer.init)?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) ?? "", theme: presentationData.theme)*/, hintIsLink: false, isGlobalSearchResult: false) - } - } -} - -private struct DownloadsControllerState: Equatable { - var hasReaction: Bool = false -} - -private func downloadsControllerEntries( - presentationData: PresentationData, - items: [DownloadItem], - state: DownloadsControllerState -) -> [DownloadsControllerEntry] { - var entries: [DownloadsControllerEntry] = [] - - var index = 0 - for item in items { - entries.append(.item( - item: item - )) - index += 1 - } - - return entries -} - -public func downloadsController( - context: AccountContext, - updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil -) -> ViewController { - let statePromise = ValuePromise(DownloadsControllerState(), ignoreRepeated: true) - let stateValue = Atomic(value: DownloadsControllerState()) - let updateState: ((DownloadsControllerState) -> DownloadsControllerState) -> Void = { f in - statePromise.set(stateValue.modify { f($0) }) - } - let _ = updateState - - var dismissImpl: (() -> Void)? - let _ = dismissImpl - - let actionsDisposable = DisposableSet() - - let arguments = DownloadsControllerArguments( - context: context - ) - - let settings = context.account.postbox.preferencesView(keys: [PreferencesKeys.reactionSettings]) - |> map { preferencesView -> ReactionSettings in - let reactionSettings: ReactionSettings - if let entry = preferencesView.values[PreferencesKeys.reactionSettings], let value = entry.get(ReactionSettings.self) { - reactionSettings = value - } else { - reactionSettings = .default - } - return reactionSettings - } - - let downloadItems: Signal<[DownloadItem], NoError> = (context.fetchManager as! FetchManagerImpl).entriesSummary - |> mapToSignal { entries -> Signal<[DownloadItem], NoError> in - var itemSignals: [Signal] = [] - - for entry in entries { - switch entry.id.locationKey { - case let .messageId(id): - itemSignals.append(context.account.postbox.transaction { transaction -> DownloadItem? in - if let message = transaction.getMessage(id) { - return DownloadItem(resourceId: entry.resourceReference.resource.id, message: message, priority: entry.priority) - } - return nil - }) - default: - break - } - } - - return combineLatest(queue: .mainQueue(), itemSignals) - |> map { items -> [DownloadItem] in - return items.compactMap { $0 } - } - } - - let presentationData = updatedPresentationData?.signal ?? context.sharedContext.presentationData - let signal = combineLatest(queue: .mainQueue(), - presentationData, - statePromise.get(), - context.engine.stickers.availableReactions(), - settings, - downloadItems - ) - |> deliverOnMainQueue - |> map { presentationData, state, availableReactions, settings, downloadItems -> (ItemListControllerState, (ItemListNodeState, Any)) in - //TODO:localize - let title: String = "Downloads" - - let entries = downloadsControllerEntries( - presentationData: presentationData, - items: downloadItems, - state: state - ) - - let controllerState = ItemListControllerState( - presentationData: ItemListPresentationData(presentationData), - title: .text(title), - leftNavigationButton: nil, - rightNavigationButton: nil, - backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), - animateChanges: false - ) - let listState = ItemListNodeState( - presentationData: ItemListPresentationData(presentationData), - entries: entries, - style: .plain, - animateChanges: true - ) - - return (controllerState, (listState, arguments)) - } - |> afterDisposed { - actionsDisposable.dispose() - } - - let controller = ItemListController(context: context, state: signal) - - controller.didScrollWithOffset = { [weak controller] offset, transition, _ in - guard let controller = controller else { - return - } - controller.forEachItemNode { itemNode in - if let itemNode = itemNode as? ReactionChatPreviewItemNode { - itemNode.standaloneReactionAnimation?.addRelativeContentOffset(CGPoint(x: 0.0, y: offset), transition: transition) - } - } - } - - dismissImpl = { [weak controller] in - guard let controller = controller else { - return - } - controller.dismiss() - } - - return controller -} - diff --git a/submodules/SettingsUI/Sources/Themes/WallpaperGalleryItem.swift b/submodules/SettingsUI/Sources/Themes/WallpaperGalleryItem.swift index 25f5f63d6b..3fa36563e1 100644 --- a/submodules/SettingsUI/Sources/Themes/WallpaperGalleryItem.swift +++ b/submodules/SettingsUI/Sources/Themes/WallpaperGalleryItem.swift @@ -602,7 +602,7 @@ final class WallpaperGalleryItemNode: GalleryItemNode { case .Local: state = .none local = true - case .Remote: + case .Remote, .Paused: state = .progress(color: statusForegroundColor, lineWidth: nil, value: 0.027, cancelEnabled: false, animateRotation: true) } strongSelf.statusNode.transitionToState(state, completion: {}) diff --git a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift index 0649f310eb..b50ce121fb 100644 --- a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift +++ b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift @@ -5,13 +5,6 @@ import AsyncDisplayKit import SwiftSignalKit import TelegramPresentationData -private final class NullActionClass: NSObject, CAAction { - @objc func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { - } -} - -private let nullAction = NullActionClass() - public protocol SparseItemGridLayer: CALayer { func update(size: CGSize) func needsShimmer() -> Bool diff --git a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift index b2acd9099a..529ba8e2db 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift @@ -1410,7 +1410,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { disposable.set(strongSelf.requestMediaChannelDescriptions(ssrcs: ssrcs, completion: completion)) } return disposable - }, audioStreamData: OngoingGroupCallContext.AudioStreamData(engine: self.accountContext.engine, callId: callInfo.id, accessHash: callInfo.accessHash), rejoinNeeded: { [weak self] in + }, rejoinNeeded: { [weak self] in Queue.mainQueue().async { guard let strongSelf = self else { return @@ -1513,8 +1513,9 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { strongSelf.currentConnectionMode = .rtc strongSelf.genericCallContext?.setConnectionMode(.rtc, keepBroadcastConnectedIfWasEnabled: false) strongSelf.genericCallContext?.setJoinResponse(payload: clientParams) - case .broadcast: + case let .broadcast(isExternalStream): strongSelf.currentConnectionMode = .broadcast + strongSelf.genericCallContext?.setAudioStreamData(audioStreamData: OngoingGroupCallContext.AudioStreamData(engine: strongSelf.accountContext.engine, callId: callInfo.id, accessHash: callInfo.accessHash, isExternalStream: isExternalStream)) strongSelf.genericCallContext?.setConnectionMode(.broadcast, keepBroadcastConnectedIfWasEnabled: false) } @@ -2399,7 +2400,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { activeSpeakers: Set() ))) - self.startDisposable.set((self.accountContext.engine.calls.createGroupCall(peerId: self.peerId, title: nil, scheduleDate: timestamp) + self.startDisposable.set((self.accountContext.engine.calls.createGroupCall(peerId: self.peerId, title: nil, scheduleDate: timestamp, isExternalStream: false) |> deliverOnMainQueue).start(next: { [weak self] callInfo in guard let strongSelf = self else { return @@ -2668,7 +2669,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { self.hasScreencast = true - let screencastCallContext = OngoingGroupCallContext(video: self.screencastCapturer, requestMediaChannelDescriptions: { _, _ in EmptyDisposable }, audioStreamData: nil, rejoinNeeded: { }, outgoingAudioBitrateKbit: nil, videoContentType: .screencast, enableNoiseSuppression: false, preferX264: false) + let screencastCallContext = OngoingGroupCallContext(video: self.screencastCapturer, requestMediaChannelDescriptions: { _, _ in EmptyDisposable }, rejoinNeeded: { }, outgoingAudioBitrateKbit: nil, videoContentType: .screencast, enableNoiseSuppression: false, preferX264: false) self.screencastCallContext = screencastCallContext self.screencastJoinDisposable.set((screencastCallContext.joinPayload diff --git a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift index 065ce3e7ab..e5d3e9943a 100644 --- a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift +++ b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift @@ -142,6 +142,71 @@ private func findMediaResource(media: Media, previousMedia: Media?, resource: Me return nil } +public func findMediaResourceById(message: Message, resourceId: MediaResourceId) -> TelegramMediaResource? { + for media in message.media { + if let result = findMediaResourceById(media: media, resourceId: resourceId) { + return result + } + } + return nil +} + +func findMediaResourceById(media: Media, resourceId: MediaResourceId) -> TelegramMediaResource? { + if let image = media as? TelegramMediaImage { + for representation in image.representations { + if representation.resource.id == resourceId { + return representation.resource + } + } + for representation in image.videoRepresentations { + if representation.resource.id == resourceId { + return representation.resource + } + } + } else if let file = media as? TelegramMediaFile { + if file.resource.id == resourceId { + return file.resource + } + + for representation in file.previewRepresentations { + if representation.resource.id == resourceId { + return representation.resource + } + } + } else if let webPage = media as? TelegramMediaWebpage, case let .Loaded(content) = webPage.content { + if let image = content.image, let result = findMediaResourceById(media: image, resourceId: resourceId) { + return result + } + if let file = content.file, let result = findMediaResourceById(media: file, resourceId: resourceId) { + return result + } + if let instantPage = content.instantPage { + for pageMedia in instantPage.media.values { + if let result = findMediaResourceById(media: pageMedia, resourceId: resourceId) { + return result + } + } + } + } else if let game = media as? TelegramMediaGame { + if let image = game.image, let result = findMediaResourceById(media: image, resourceId: resourceId) { + return result + } + if let file = game.file, let result = findMediaResourceById(media: file, resourceId: resourceId) { + return result + } + } else if let action = media as? TelegramMediaAction { + switch action.action { + case let .photoUpdated(image): + if let image = image, let result = findMediaResourceById(media: image, resourceId: resourceId) { + return result + } + default: + break + } + } + return nil +} + private func findUpdatedMediaResource(media: Media, previousMedia: Media?, resource: MediaResource) -> TelegramMediaResource? { if let foundResource = findMediaResource(media: media, previousMedia: previousMedia, resource: resource) { return foundResource diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift index 2461c195d1..aaea82f8af 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift @@ -60,6 +60,7 @@ public struct Namespaces { public static let RecentlyUsedHashtags: Int32 = 8 public static let CloudThemes: Int32 = 9 public static let CloudGreetingStickers: Int32 = 10 + public static let RecentDownloads: Int32 = 11 } public struct CachedItemCollection { diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RecentDownloadItem.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RecentDownloadItem.swift new file mode 100644 index 0000000000..f8a57864bc --- /dev/null +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RecentDownloadItem.swift @@ -0,0 +1,252 @@ +import Foundation +import Postbox +import SwiftSignalKit + +public final class RecentDownloadItem: Codable, Equatable { + struct Id { + var rawValue: MemoryBuffer + + init(id: MessageId, resourceId: String) { + let buffer = WriteBuffer() + + var idId: Int32 = id.id + buffer.write(&idId, length: 4) + + var idNamespace: Int32 = id.namespace + buffer.write(&idNamespace, length: 4) + + var peerId: Int64 = id.peerId.toInt64() + buffer.write(&peerId, length: 8) + + let resourceIdData = resourceId.data(using: .utf8)! + var resourceIdLength = Int32(resourceIdData.count) + buffer.write(&resourceIdLength, length: 4) + buffer.write(resourceIdData) + + self.rawValue = buffer.makeReadBufferAndReset() + } + } + + public let messageId: MessageId + public let resourceId: String + public let timestamp: Int32 + public let isSeen: Bool + + public init(messageId: MessageId, resourceId: String, timestamp: Int32, isSeen: Bool) { + self.messageId = messageId + self.resourceId = resourceId + self.timestamp = timestamp + self.isSeen = isSeen + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: StringCodingKey.self) + + self.messageId = try container.decode(MessageId.self, forKey: "messageId") + self.resourceId = try container.decode(String.self, forKey: "resourceId") + self.timestamp = try container.decode(Int32.self, forKey: "timestamp") + self.isSeen = try container.decodeIfPresent(Bool.self, forKey: "isSeen") ?? false + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: StringCodingKey.self) + + try container.encode(self.messageId, forKey: "messageId") + try container.encode(self.resourceId, forKey: "resourceId") + try container.encode(self.timestamp, forKey: "timestamp") + try container.encode(self.isSeen, forKey: "isSeen") + } + + public static func ==(lhs: RecentDownloadItem, rhs: RecentDownloadItem) -> Bool { + if lhs.messageId != rhs.messageId { + return false + } + if lhs.resourceId != rhs.resourceId { + return false + } + if lhs.timestamp != rhs.timestamp { + return false + } + if lhs.isSeen != rhs.isSeen { + return false + } + return true + } + + func withSeen() -> RecentDownloadItem { + return RecentDownloadItem(messageId: self.messageId, resourceId: self.resourceId, timestamp: self.timestamp, isSeen: true) + } +} + +public final class RenderedRecentDownloadItem: Equatable { + public let message: Message + public let timestamp: Int32 + public let isSeen: Bool + public let resourceId: String + public let size: Int + + public init(message: Message, timestamp: Int32, isSeen: Bool, resourceId: String, size: Int) { + self.message = message + self.timestamp = timestamp + self.isSeen = isSeen + self.resourceId = resourceId + self.size = size + } + + public static func ==(lhs: RenderedRecentDownloadItem, rhs: RenderedRecentDownloadItem) -> Bool { + if lhs.message.id != rhs.message.id { + return false + } + if lhs.message.stableVersion != rhs.message.stableVersion { + return false + } + if lhs.timestamp != rhs.timestamp { + return false + } + if lhs.isSeen != rhs.isSeen { + return false + } + if lhs.resourceId != rhs.resourceId { + return false + } + if lhs.size != rhs.size { + return false + } + return true + } +} + +public func recentDownloadItems(postbox: Postbox) -> Signal<[RenderedRecentDownloadItem], NoError> { + let viewKey: PostboxViewKey = .orderedItemList(id: Namespaces.OrderedItemList.RecentDownloads) + return postbox.combinedView(keys: [viewKey]) + |> mapToSignal { views -> Signal<[RenderedRecentDownloadItem], NoError> in + guard let view = views.views[viewKey] as? OrderedItemListView else { + return .single([]) + } + + return combineLatest(postbox.transaction { transaction -> [RenderedRecentDownloadItem] in + var result: [RenderedRecentDownloadItem] = [] + + for item in view.items { + guard let item = item.contents.get(RecentDownloadItem.self) else { + continue + } + guard let message = transaction.getMessage(item.messageId) else { + continue + } + + var size: Int? + for media in message.media { + if let result = findMediaResourceById(media: media, resourceId: MediaResourceId(item.resourceId)) { + size = result.size + break + } + } + + if let size = size { + result.append(RenderedRecentDownloadItem(message: message, timestamp: item.timestamp, isSeen: item.isSeen, resourceId: item.resourceId, size: size)) + } + } + + return result + }, postbox.mediaBox.didRemoveResources) + |> mapToSignal { items, _ -> Signal<[RenderedRecentDownloadItem], NoError> in + var statusSignals: [Signal] = [] + + for item in items { + statusSignals.append(postbox.mediaBox.resourceStatus(MediaResourceId(item.resourceId), resourceSize: item.size) + |> map { status -> Bool in + switch status { + case .Local: + return true + default: + return false + } + } + |> distinctUntilChanged) + } + + return combineLatest(queue: .mainQueue(), statusSignals) + |> map { statuses -> [RenderedRecentDownloadItem] in + var result: [RenderedRecentDownloadItem] = [] + for i in 0 ..< items.count { + if statuses[i] { + result.append(items[i]) + } + } + return result + } + } + } +} + +public func addRecentDownloadItem(postbox: Postbox, item: RecentDownloadItem) -> Signal { + return postbox.transaction { transaction -> Void in + guard let entry = CodableEntry(item) else { + return + } + transaction.addOrMoveToFirstPositionOrderedItemListItem(collectionId: Namespaces.OrderedItemList.RecentDownloads, item: OrderedItemListEntry(id: RecentDownloadItem.Id(id: item.messageId, resourceId: item.resourceId).rawValue, contents: entry), removeTailIfCountExceeds: 200) + } + |> ignoreValues +} + +public func markRecentDownloadItemsAsSeen(postbox: Postbox, items: [(messageId: MessageId, resourceId: String)]) -> Signal { + return postbox.transaction { transaction -> Void in + var unseenIds: [(messageId: MessageId, resourceId: String)] = [] + for item in items { + guard let listItem = transaction.getOrderedItemListItem(collectionId: Namespaces.OrderedItemList.RecentDownloads, itemId: RecentDownloadItem.Id(id: item.messageId, resourceId: item.resourceId).rawValue) else { + continue + } + guard let listItemValue = listItem.contents.get(RecentDownloadItem.self), !listItemValue.isSeen else { + continue + } + unseenIds.append(item) + } + + if unseenIds.isEmpty { + return + } + + let items = transaction.getOrderedListItems(collectionId: Namespaces.OrderedItemList.RecentDownloads) + transaction.replaceOrderedItemListItems(collectionId: Namespaces.OrderedItemList.RecentDownloads, items: items.compactMap { entry -> OrderedItemListEntry? in + guard let item = entry.contents.get(RecentDownloadItem.self) else { + return nil + } + if unseenIds.contains(where: { $0.messageId == item.messageId && $0.resourceId == item.resourceId }) { + guard let entry = CodableEntry(item.withSeen()) else { + return nil + } + return OrderedItemListEntry(id: RecentDownloadItem.Id(id: item.messageId, resourceId: item.resourceId).rawValue, contents: entry) + } else { + return entry + } + }) + } + |> ignoreValues +} + +public func markAllRecentDownloadItemsAsSeen(postbox: Postbox) -> Signal { + return postbox.transaction { transaction -> Void in + let items = transaction.getOrderedListItems(collectionId: Namespaces.OrderedItemList.RecentDownloads) + var hasUnseen = false + for item in items { + if let item = item.contents.get(RecentDownloadItem.self), !item.isSeen { + hasUnseen = true + break + } + } + if !hasUnseen { + return + } + transaction.replaceOrderedItemListItems(collectionId: Namespaces.OrderedItemList.RecentDownloads, items: items.compactMap { item -> OrderedItemListEntry? in + guard let item = item.contents.get(RecentDownloadItem.self) else { + return nil + } + guard let entry = CodableEntry(item.withSeen()) else { + return nil + } + return OrderedItemListEntry(id: RecentDownloadItem.Id(id: item.messageId, resourceId: item.resourceId).rawValue, contents: entry) + }) + } + |> ignoreValues +} diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Calls/GroupCalls.swift b/submodules/TelegramCore/Sources/TelegramEngine/Calls/GroupCalls.swift index e5f3af8f13..c0405f0ddc 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Calls/GroupCalls.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Calls/GroupCalls.swift @@ -143,7 +143,7 @@ public enum CreateGroupCallError { case scheduledTooLate } -func _internal_createGroupCall(account: Account, peerId: PeerId, title: String?, scheduleDate: Int32?) -> Signal { +func _internal_createGroupCall(account: Account, peerId: PeerId, title: String?, scheduleDate: Int32?, isExternalStream: Bool) -> Signal { return account.postbox.transaction { transaction -> Api.InputPeer? in let callPeer = transaction.getPeer(peerId).flatMap(apiInputPeer) return callPeer @@ -160,6 +160,9 @@ func _internal_createGroupCall(account: Account, peerId: PeerId, title: String?, if let _ = scheduleDate { flags |= (1 << 1) } + if isExternalStream { + flags |= (1 << 2) + } return account.network.request(Api.functions.phone.createGroupCall(flags: flags, peer: inputPeer, randomId: Int32.random(in: Int32.min ... Int32.max), title: title, scheduleDate: scheduleDate)) |> mapError { error -> CreateGroupCallError in if error.errorDescription == "ANONYMOUS_CALLS_DISABLED" { @@ -433,7 +436,7 @@ public enum JoinGroupCallError { public struct JoinGroupCallResult { public enum ConnectionMode { case rtc - case broadcast + case broadcast(isExternalStream: Bool) } public var callInfo: GroupCallInfo @@ -589,12 +592,16 @@ func _internal_joinGroupCall(account: Account, peerId: PeerId, joinAs: PeerId?, let connectionMode: JoinGroupCallResult.ConnectionMode if let clientParamsData = parsedClientParams.data(using: .utf8), let dict = (try? JSONSerialization.jsonObject(with: clientParamsData, options: [])) as? [String: Any] { if let stream = dict["stream"] as? Bool, stream { - connectionMode = .broadcast + var isExternalStream = false + if let rtmp = dict["rtmp"] as? Bool, rtmp { + isExternalStream = true + } + connectionMode = .broadcast(isExternalStream: isExternalStream) } else { connectionMode = .rtc } } else { - connectionMode = .broadcast + connectionMode = .broadcast(isExternalStream: false) } return account.postbox.transaction { transaction -> JoinGroupCallResult in diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Calls/TelegramEngineCalls.swift b/submodules/TelegramCore/Sources/TelegramEngine/Calls/TelegramEngineCalls.swift index 5eef034ba6..56c6a52dce 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Calls/TelegramEngineCalls.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Calls/TelegramEngineCalls.swift @@ -1,5 +1,17 @@ import SwiftSignalKit import Postbox +import TelegramApi +import MtProtoKit + +public struct EngineCallStreamState { + public struct Channel { + public var id: Int32 + public var scale: Int32 + public var latestTimestamp: Int64 + } + + public var channels: [Channel] +} public extension TelegramEngine { final class Calls { @@ -21,8 +33,8 @@ public extension TelegramEngine { return _internal_getCurrentGroupCall(account: self.account, callId: callId, accessHash: accessHash, peerId: peerId) } - public func createGroupCall(peerId: PeerId, title: String?, scheduleDate: Int32?) -> Signal { - return _internal_createGroupCall(account: self.account, peerId: peerId, title: title, scheduleDate: scheduleDate) + public func createGroupCall(peerId: PeerId, title: String?, scheduleDate: Int32?, isExternalStream: Bool) -> Signal { + return _internal_createGroupCall(account: self.account, peerId: peerId, title: title, scheduleDate: scheduleDate, isExternalStream: isExternalStream) } public func startScheduledGroupCall(peerId: PeerId, callId: Int64, accessHash: Int64) -> Signal { @@ -119,5 +131,28 @@ public extension TelegramEngine { } |> take(1) } + + public func requestStreamState(callId: Int64, accessHash: Int64) -> Signal { + return self.account.network.request(Api.functions.phone.getGroupCallStreamChannels(call: .inputGroupCall(id: callId, accessHash: accessHash))) + |> mapToSignal { result -> Signal in + switch result { + case let .groupCallStreamChannels(channels): + let state = EngineCallStreamState(channels: channels.map { channel -> EngineCallStreamState.Channel in + switch channel { + case let .groupCallStreamChannel(channel, scale, lastTimestampMs): + return EngineCallStreamState.Channel(id: channel, scale: scale, latestTimestamp: lastTimestampMs) + } + }) + /*if state.channels.isEmpty { + return .fail(MTRpcError(errorCode: 500, errorDescription: "Generated")) |> delay(10.0, queue: .mainQueue()) + }*/ + return .single(state) + } + } + //|> restartIfError + |> `catch` { _ -> Signal in + return .single(nil) + } + } } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift b/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift index da3d77fbd5..c6377503ad 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift @@ -60,9 +60,10 @@ public final class EngineMediaResource: Equatable { } public enum FetchStatus: Equatable { - case Remote + case Remote(progress: Float) case Local case Fetching(isActive: Bool, progress: Float) + case Paused(progress: Float) } public struct Id: Equatable, Hashable { @@ -105,23 +106,27 @@ public extension EngineMediaResource.ResourceData { public extension EngineMediaResource.FetchStatus { init(_ status: MediaResourceStatus) { switch status { - case .Remote: - self = .Remote + case let .Remote(progress): + self = .Remote(progress: progress) case .Local: self = .Local case let .Fetching(isActive, progress): self = .Fetching(isActive: isActive, progress: progress) + case let .Paused(progress): + self = .Paused(progress: progress) } } func _asStatus() -> MediaResourceStatus { switch self { - case .Remote: - return .Remote + case let .Remote(progress): + return .Remote(progress: progress) case .Local: return .Local case let .Fetching(isActive, progress): return .Fetching(isActive: isActive, progress: progress) + case let .Paused(progress): + return .Paused(progress: progress) } } } diff --git a/submodules/TelegramUI/Resources/Animations/anim_search_downloaded.json b/submodules/TelegramUI/Resources/Animations/anim_search_downloaded.json new file mode 100644 index 0000000000..03885f36d5 --- /dev/null +++ b/submodules/TelegramUI/Resources/Animations/anim_search_downloaded.json @@ -0,0 +1 @@ +{"v":"5.8.1","fr":60,"ip":0,"op":30,"w":24,"h":24,"nm":"ic_downloaded","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Oval","sr":1,"ks":{"o":{"a":0,"k":15,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[12,12,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":10,"s":[16.667,16.667,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":20,"s":[18.333,18.333,100]},{"t":30,"s":[16.667,16.667,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Fill 2","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":10,"s":[0,0,100]},{"t":20,"s":[108,108,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Arrow2","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[12,0,0],"to":[0,2,0],"ti":[0,-2,0]},{"t":30,"s":[12,12,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[16.667,16.667,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.367,0],[0,-0.367],[0,0],[0,0],[-0.26,-0.26],[0.26,-0.26],[0,0],[0.26,0.26],[0,0],[-0.26,0.26],[-0.26,-0.26],[0,0],[0,0]],"o":[[0.367,0],[0,0],[0,0],[0.26,-0.26],[0.26,0.26],[0,0],[-0.26,0.26],[0,0],[-0.26,-0.26],[0.26,-0.26],[0,0],[0,0],[0,-0.367]],"v":[[0,-4.165],[0.665,-3.5],[0.665,1.895],[2.53,0.03],[3.47,0.03],[3.47,0.97],[0.47,3.97],[-0.47,3.97],[-3.47,0.97],[-3.47,0.03],[-2.53,0.03],[-0.665,1.895],[-0.665,-3.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Union","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Mask2","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[92,92,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Arrow3","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[12,0,0],"to":[0,2,0],"ti":[0,-2,0]},{"t":30,"s":[12,12,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[16.667,16.667,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.367,0],[0,-0.367],[0,0],[0,0],[-0.26,-0.26],[0.26,-0.26],[0,0],[0.26,0.26],[0,0],[-0.26,0.26],[-0.26,-0.26],[0,0],[0,0]],"o":[[0.367,0],[0,0],[0,0],[0.26,-0.26],[0.26,0.26],[0,0],[-0.26,0.26],[0,0],[-0.26,-0.26],[0.26,-0.26],[0,0],[0,0],[0,-0.367]],"v":[[0,-4.165],[0.665,-3.5],[0.665,1.895],[2.53,0.03],[3.47,0.03],[3.47,0.97],[0.47,3.97],[-0.47,3.97],[-3.47,0.97],[-3.47,0.03],[-2.53,0.03],[-0.665,1.895],[-0.665,-3.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Union","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Fill","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":10,"s":[0,0,100]},{"t":20,"s":[108,108,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Mask1","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[92,92,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Arrow1","parent":3,"tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[0,72,0],"to":[0,12,0],"ti":[0,-12,0]},{"t":30,"s":[0,144,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.367,0],[0,-0.367],[0,0],[0,0],[-0.26,-0.26],[0.26,-0.26],[0,0],[0.26,0.26],[0,0],[-0.26,0.26],[-0.26,-0.26],[0,0],[0,0]],"o":[[0.367,0],[0,0],[0,0],[0.26,-0.26],[0.26,0.26],[0,0],[-0.26,0.26],[0,0],[-0.26,-0.26],[0.26,-0.26],[0,0],[0,0],[0,-0.367]],"v":[[0,-4.165],[0.665,-3.5],[0.665,1.895],[2.53,0.03],[3.47,0.03],[3.47,0.97],[0.47,3.97],[-0.47,3.97],[-3.47,0.97],[-3.47,0.03],[-2.53,0.03],[-0.665,1.895],[-0.665,-3.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Union","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/submodules/TelegramUI/Resources/Animations/anim_search_downloading.json b/submodules/TelegramUI/Resources/Animations/anim_search_downloading.json new file mode 100644 index 0000000000..4d64a95a2b --- /dev/null +++ b/submodules/TelegramUI/Resources/Animations/anim_search_downloading.json @@ -0,0 +1 @@ +{"v":"5.8.1","fr":60,"ip":0,"op":30,"w":24,"h":24,"nm":"ic_downloading","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Oval","sr":1,"ks":{"o":{"a":0,"k":15,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[12,12,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[16.667,16.667,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Mask2","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[92,92,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Arrow2","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[12,0,0],"to":[0,2,0],"ti":[0,-2,0]},{"t":30,"s":[12,12,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[16.667,16.667,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.367,0],[0,-0.367],[0,0],[0,0],[-0.26,-0.26],[0.26,-0.26],[0,0],[0.26,0.26],[0,0],[-0.26,0.26],[-0.26,-0.26],[0,0],[0,0]],"o":[[0.367,0],[0,0],[0,0],[0.26,-0.26],[0.26,0.26],[0,0],[-0.26,0.26],[0,0],[-0.26,-0.26],[0.26,-0.26],[0,0],[0,0],[0,-0.367]],"v":[[0,-4.165],[0.665,-3.5],[0.665,1.895],[2.53,0.03],[3.47,0.03],[3.47,0.97],[0.47,3.97],[-0.47,3.97],[-3.47,0.97],[-3.47,0.03],[-2.53,0.03],[-0.665,1.895],[-0.665,-3.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Union","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Mask1","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[92,92,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Arrow1","parent":3,"tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[0,72,0],"to":[0,12,0],"ti":[0,-12,0]},{"t":30,"s":[0,144,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.367,0],[0,-0.367],[0,0],[0,0],[-0.26,-0.26],[0.26,-0.26],[0,0],[0.26,0.26],[0,0],[-0.26,0.26],[-0.26,-0.26],[0,0],[0,0]],"o":[[0.367,0],[0,0],[0,0],[0.26,-0.26],[0.26,0.26],[0,0],[-0.26,0.26],[0,0],[-0.26,-0.26],[0.26,-0.26],[0,0],[0,0],[0,-0.367]],"v":[[0,-4.165],[0.665,-3.5],[0.665,1.895],[2.53,0.03],[3.47,0.03],[3.47,0.97],[0.47,3.97],[-0.47,3.97],[-3.47,0.97],[-3.47,0.03],[-2.53,0.03],[-0.665,1.895],[-0.665,-3.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Union","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index a2e8dd14d5..52ddcd3777 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -683,7 +683,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G statusController?.dismiss() } strongSelf.present(statusController, in: .window(.root)) - strongSelf.createVoiceChatDisposable.set((strongSelf.context.engine.calls.createGroupCall(peerId: message.id.peerId, title: nil, scheduleDate: nil) + strongSelf.createVoiceChatDisposable.set((strongSelf.context.engine.calls.createGroupCall(peerId: message.id.peerId, title: nil, scheduleDate: nil, isExternalStream: false) |> deliverOnMainQueue).start(next: { [weak self] info in guard let strongSelf = self else { return diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index d9cd232219..0b902a0b54 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -1815,6 +1815,7 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { var messageIdsWithRefreshMedia: [MessageId] = [] var messageIdsWithUnseenPersonalMention: [MessageId] = [] var messageIdsWithUnseenReactions: [MessageId] = [] + var downloadableResourceIds: [(messageId: MessageId, resourceId: String)] = [] if indexRange.0 <= indexRange.1 { for i in (indexRange.0 ... indexRange.1) { @@ -1875,6 +1876,11 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { } } } + downloadableResourceIds.append((message.id, telegramFile.resource.id.stringRepresentation)) + } else if let image = media as? TelegramMediaImage { + if let representation = image.representations.last { + downloadableResourceIds.append((message.id, representation.resource.id.stringRepresentation)) + } } } if contentRequiredValidation { @@ -1889,6 +1895,7 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { if hasUnseenReactions { messageIdsWithUnseenReactions.append(message.id) } + if case let .replyThread(replyThreadMessage) = self.chatLocation, replyThreadMessage.effectiveTopId == message.id { isTopReplyThreadMessageShownValue = true } @@ -1909,6 +1916,15 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { } } } + for media in message.media { + if let telegramFile = media as? TelegramMediaFile { + downloadableResourceIds.append((message.id, telegramFile.resource.id.stringRepresentation)) + } else if let image = media as? TelegramMediaImage { + if let representation = image.representations.last { + downloadableResourceIds.append((message.id, representation.resource.id.stringRepresentation)) + } + } + } for attribute in message.attributes { if attribute is ViewCountMessageAttribute { if message.id.namespace == Namespaces.Message.Cloud { @@ -2073,6 +2089,9 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { if !messageIdsWithPossibleReactions.isEmpty { self.messageWithReactionsProcessingManager.add(messageIdsWithPossibleReactions) } + if !downloadableResourceIds.isEmpty { + let _ = markRecentDownloadItemsAsSeen(postbox: self.context.account.postbox, items: downloadableResourceIds).start() + } self.currentEarlierPrefetchMessages = toEarlierMediaMessages self.currentLaterPrefetchMessages = toLaterMediaMessages diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift index 1fd6b23263..85bd993b9e 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift @@ -226,7 +226,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { if let cancel = self.fetchControls.with({ return $0?.cancel }) { cancel() } - case .Remote: + case .Remote, .Paused: if let fetch = self.fetchControls.with({ return $0?.fetch }) { fetch() } @@ -249,7 +249,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { if let cancel = self.fetchControls.with({ return $0?.cancel }) { cancel() } - case .Remote: + case .Remote, .Paused: if let fetch = self.fetchControls.with({ return $0?.fetch }) { fetch() } @@ -947,7 +947,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { } else { state = .none } - case .Remote: + case .Remote, .Paused: if isAudio && !isVoice { state = .play } else { @@ -971,7 +971,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { streamingState = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: .init(inset: 1.0, lineWidth: 2.0)) case .Local: streamingState = .none - case .Remote: + case .Remote, .Paused: streamingState = .download } } else { diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift index 411a175a66..e447871341 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift @@ -675,7 +675,7 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { } else { state = .none } - case .Remote: + case .Remote, .Paused: state = .download(messageTheme.mediaOverlayControlColors.foregroundColor) } default: @@ -824,7 +824,7 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { } else { messageMediaFileCancelInteractiveFetch(context: item.context, messageId: item.message.id, file: file) } - case .Remote: + case .Remote, .Paused: if let file = self.media { self.fetchDisposable.set(messageMediaFileInteractiveFetched(context: item.context, message: item.message, file: file, userInitiated: true).start()) } diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift index 02a58fbcb9..14b6960db0 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift @@ -321,7 +321,7 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio if let cancel = self.fetchControls.with({ return $0?.cancel }) { cancel() } - case .Remote: + case .Remote, .Paused: if let fetch = self.fetchControls.with({ return $0?.fetch }) { fetch(true) } @@ -1213,7 +1213,7 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio progressRequired = true } } - case .Remote, .Fetching: + case .Remote, .Fetching, .Paused: if let webpage = webpage, let automaticDownload = self.automaticDownload, case .full = automaticDownload, case let .Loaded(content) = webpage.content { if content.type == "telegram_background" { progressRequired = true @@ -1448,7 +1448,7 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio let durationString = file.isAnimated ? gifTitle : stringForDuration(playerDuration > 0 ? playerDuration : duration, position: playerPosition) badgeContent = .mediaDownload(backgroundColor: messageTheme.mediaDateAndStatusFillColor, foregroundColor: messageTheme.mediaDateAndStatusTextColor, duration: durationString, size: nil, muted: muted, active: false) } - case .Remote: + case .Remote, .Paused: state = .download(messageTheme.mediaOverlayControlColors.foregroundColor) if let file = self.media as? TelegramMediaFile, !file.isVideoSticker { do { diff --git a/submodules/TelegramUI/Sources/GridMessageItem.swift b/submodules/TelegramUI/Sources/GridMessageItem.swift index e8c3a52717..b5aa53490b 100644 --- a/submodules/TelegramUI/Sources/GridMessageItem.swift +++ b/submodules/TelegramUI/Sources/GridMessageItem.swift @@ -250,7 +250,7 @@ final class GridMessageItemNode: GridItemNode { statusState = .progress(color: .white, lineWidth: nil, value: CGFloat(adjustedProgress), cancelEnabled: true, animateRotation: true) case .Local: statusState = .none - case .Remote: + case .Remote, .Paused: statusState = .download(.white) } } @@ -284,7 +284,7 @@ final class GridMessageItemNode: GridItemNode { mediaDownloadState = .compactFetching(progress: 0.0) case .Local: badgeContent = .text(inset: 0.0, backgroundColor: mediaBadgeBackgroundColor, foregroundColor: mediaBadgeTextColor, text: NSAttributedString(string: durationString)) - case .Remote: + case .Remote, .Paused: badgeContent = .text(inset: 12.0, backgroundColor: mediaBadgeBackgroundColor, foregroundColor: mediaBadgeTextColor, text: NSAttributedString(string: durationString)) mediaDownloadState = .compactRemote } @@ -432,7 +432,7 @@ final class GridMessageItemNode: GridItemNode { messageMediaFileCancelInteractiveFetch(context: context, messageId: message.id, file: file) case .Local: let _ = controllerInteraction.openMessage(message, .default) - case .Remote: + case .Remote, .Paused: self.fetchDisposable.set(messageMediaFileInteractiveFetched(context: context, message: message, file: file, userInitiated: true).start()) } } diff --git a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift index 6d75431d50..4b4bb489e9 100644 --- a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift +++ b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift @@ -461,7 +461,7 @@ final class HorizontalListContextResultsChatInputPanelItemNode: ListViewItemNode switch status { case let .Fetching(_, progress): state = .progress(color: statusForegroundColor, lineWidth: nil, value: CGFloat(max(progress, 0.2)), cancelEnabled: false, animateRotation: true) - case .Remote: + case .Remote, .Paused: //state = .download(statusForegroundColor) state = .none case .Local: diff --git a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift b/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift index 04f2464ecb..726a0a5d43 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift @@ -678,13 +678,6 @@ private final class VisualMediaItem: SparseItemGrid.Item { } } -private final class NullActionClass: NSObject, CAAction { - @objc func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { - } -} - -private let nullAction = NullActionClass() - private struct Month: Equatable { var packedValue: Int32 diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index 6e7f6fcca9..1fb69b8e8c 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -404,7 +404,6 @@ private enum PeerInfoSettingsSection { case avatar case edit case proxy - case downloads case savedMessages case recentCalls case devices @@ -664,10 +663,6 @@ private func settingsItems(data: PeerInfoScreenData?, context: AccountContext, p } } - //TODO:localize - items[.shortcuts]!.append(PeerInfoScreenDisclosureItem(id: 0, text: "Downloads", icon: PresentationResourcesSettings.savedMessages, action: { - interaction.openSettings(.downloads) - })) items[.shortcuts]!.append(PeerInfoScreenDisclosureItem(id: 1, text: presentationData.strings.Settings_SavedMessages, icon: PresentationResourcesSettings.savedMessages, action: { interaction.openSettings(.savedMessages) })) @@ -4118,6 +4113,12 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate return } + #if DEBUG + let isExternalStream: Bool = true + #else + let isExternalStream: Bool = false + #endif + var cancelImpl: (() -> Void)? let presentationData = strongSelf.presentationData let progressSignal = Signal { [weak self] subscriber in @@ -4134,7 +4135,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate |> runOn(Queue.mainQueue()) |> delay(0.15, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - let createSignal = strongSelf.context.engine.calls.createGroupCall(peerId: peerId, title: nil, scheduleDate: nil) + let createSignal = strongSelf.context.engine.calls.createGroupCall(peerId: peerId, title: nil, scheduleDate: nil, isExternalStream: isExternalStream) |> afterDisposed { Queue.mainQueue().async { progressDisposable.dispose() @@ -5533,8 +5534,6 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate self.headerNode.navigationButtonContainer.performAction?(.edit, nil, nil) case .proxy: self.controller?.push(proxySettingsController(context: self.context)) - case .downloads: - self.controller?.push(downloadsController(context: self.context, updatedPresentationData: self.controller?.updatedPresentationData)) case .savedMessages: if let controller = self.controller, let navigationController = controller.navigationController as? NavigationController { self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(self.context.account.peerId))) diff --git a/submodules/TelegramUI/Sources/PeerInfoGifPaneNode.swift b/submodules/TelegramUI/Sources/PeerInfoGifPaneNode.swift index 16367af592..86728907f7 100644 --- a/submodules/TelegramUI/Sources/PeerInfoGifPaneNode.swift +++ b/submodules/TelegramUI/Sources/PeerInfoGifPaneNode.swift @@ -260,7 +260,7 @@ private final class VisualMediaItemNode: ASDisplayNode { messageMediaFileCancelInteractiveFetch(context: self.context, messageId: message.id, file: file) case .Local: self.interaction.openMessage(message) - case .Remote: + case .Remote, .Paused: self.fetchDisposable.set(messageMediaFileInteractiveFetched(context: self.context, message: message, file: file, userInitiated: true).start()) } } @@ -348,7 +348,7 @@ private final class VisualMediaItemNode: ASDisplayNode { statusState = .progress(color: .white, lineWidth: nil, value: CGFloat(adjustedProgress), cancelEnabled: true, animateRotation: true) case .Local: statusState = .none - case .Remote: + case .Remote, .Paused: statusState = .download(.white) } } @@ -382,7 +382,7 @@ private final class VisualMediaItemNode: ASDisplayNode { mediaDownloadState = .compactFetching(progress: 0.0) case .Local: badgeContent = .text(inset: 0.0, backgroundColor: mediaBadgeBackgroundColor, foregroundColor: mediaBadgeTextColor, text: NSAttributedString(string: durationString)) - case .Remote: + case .Remote, .Paused: badgeContent = .text(inset: 12.0, backgroundColor: mediaBadgeBackgroundColor, foregroundColor: mediaBadgeTextColor, text: NSAttributedString(string: durationString)) mediaDownloadState = .compactRemote } diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 676fb35257..89baabe529 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -1128,6 +1128,15 @@ public final class SharedAccountContextImpl: SharedAccountContext { navigateToChatControllerImpl(params) } + public func openStorageUsage(context: AccountContext) { + guard let navigationController = self.mainWindow?.viewController as? NavigationController else { + return + } + + let controller = storageUsageController(context: context, isModal: true) + navigationController.pushViewController(controller) + } + public func openLocationScreen(context: AccountContext, messageId: MessageId, navigationController: NavigationController) { var found = false for controller in navigationController.viewControllers.reversed() { diff --git a/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputPanelItem.swift b/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputPanelItem.swift index 7cc0ce5527..02a03e6dc8 100644 --- a/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputPanelItem.swift +++ b/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputPanelItem.swift @@ -364,7 +364,7 @@ final class VerticalListContextResultsChatInputPanelItemNode: ListViewItemNode { switch status { case let .Fetching(_, progress): state = RadialStatusNodeState.progress(color: statusForegroundColor, lineWidth: nil, value: CGFloat(max(progress, 0.2)), cancelEnabled: false, animateRotation: true) - case .Remote: + case .Remote, .Paused: state = .download(statusForegroundColor) case .Local: state = .none @@ -378,7 +378,6 @@ final class VerticalListContextResultsChatInputPanelItemNode: ListViewItemNode { } else { strongSelf.statusNode.transitionToState(.none, completion: { }) } - } }) } diff --git a/submodules/TelegramVoip/Sources/GroupCallContext.swift b/submodules/TelegramVoip/Sources/GroupCallContext.swift index ca4e5cea99..2c6b3ca8b6 100644 --- a/submodules/TelegramVoip/Sources/GroupCallContext.swift +++ b/submodules/TelegramVoip/Sources/GroupCallContext.swift @@ -42,19 +42,29 @@ private final class NetworkBroadcastPartSource: BroadcastPartSource { private let engine: TelegramEngine private let callId: Int64 private let accessHash: Int64 + private let isExternalStream: Bool private var dataSource: AudioBroadcastDataSource? - init(queue: Queue, engine: TelegramEngine, callId: Int64, accessHash: Int64) { + init(queue: Queue, engine: TelegramEngine, callId: Int64, accessHash: Int64, isExternalStream: Bool) { self.queue = queue self.engine = engine self.callId = callId self.accessHash = accessHash + self.isExternalStream = isExternalStream } func requestTime(completion: @escaping (Int64) -> Void) -> Disposable { - return engine.calls.serverTime().start(next: { result in - completion(result) - }) + if self.isExternalStream { + return self.engine.calls.requestStreamState(callId: self.callId, accessHash: self.accessHash).start(next: { result in + if let channel = result?.channels.first { + completion(channel.latestTimestamp) + } + }) + } else { + return self.engine.calls.serverTime().start(next: { result in + completion(result) + }) + } } func requestPart(timestampMilliseconds: Int64, durationMilliseconds: Int64, subject: BroadcastPartSubject, completion: @escaping (OngoingGroupCallBroadcastPart) -> Void, rejoinNeeded: @escaping () -> Void) -> Disposable { @@ -146,11 +156,13 @@ public final class OngoingGroupCallContext { public var engine: TelegramEngine public var callId: Int64 public var accessHash: Int64 + public var isExternalStream: Bool - public init(engine: TelegramEngine, callId: Int64, accessHash: Int64) { + public init(engine: TelegramEngine, callId: Int64, accessHash: Int64, isExternalStream: Bool) { self.engine = engine self.callId = callId self.accessHash = accessHash + self.isExternalStream = isExternalStream } } @@ -361,21 +373,14 @@ public final class OngoingGroupCallContext { private var currentRequestedVideoChannels: [VideoChannel] = [] - private var broadcastPartsSource: BroadcastPartSource? + private let broadcastPartsSource = Atomic(value: nil) - init(queue: Queue, inputDeviceId: String, outputDeviceId: String, video: OngoingCallVideoCapturer?, requestMediaChannelDescriptions: @escaping (Set, @escaping ([MediaChannelDescription]) -> Void) -> Disposable, audioStreamData: AudioStreamData?, rejoinNeeded: @escaping () -> Void, outgoingAudioBitrateKbit: Int32?, videoContentType: VideoContentType, enableNoiseSuppression: Bool, preferX264: Bool) { + init(queue: Queue, inputDeviceId: String, outputDeviceId: String, video: OngoingCallVideoCapturer?, requestMediaChannelDescriptions: @escaping (Set, @escaping ([MediaChannelDescription]) -> Void) -> Disposable, rejoinNeeded: @escaping () -> Void, outgoingAudioBitrateKbit: Int32?, videoContentType: VideoContentType, enableNoiseSuppression: Bool, preferX264: Bool) { self.queue = queue var networkStateUpdatedImpl: ((GroupCallNetworkState) -> Void)? var audioLevelsUpdatedImpl: (([NSNumber]) -> Void)? - if let audioStreamData = audioStreamData { - let broadcastPartsSource = NetworkBroadcastPartSource(queue: queue, engine: audioStreamData.engine, callId: audioStreamData.callId, accessHash: audioStreamData.accessHash) - self.broadcastPartsSource = broadcastPartsSource - } - - let broadcastPartsSource = self.broadcastPartsSource - let _videoContentType: OngoingGroupCallVideoContentType switch videoContentType { case .generic: @@ -385,6 +390,8 @@ public final class OngoingGroupCallContext { case .none: _videoContentType = .none } + + var getBroadcastPartsSource: (() -> BroadcastPartSource?)? self.context = GroupCallThreadLocalContext( queue: ContextQueueImpl(queue: queue), @@ -433,7 +440,7 @@ public final class OngoingGroupCallContext { let disposable = MetaDisposable() queue.async { - disposable.set(broadcastPartsSource?.requestTime(completion: completion)) + disposable.set(getBroadcastPartsSource?()?.requestTime(completion: completion)) } return OngoingGroupCallBroadcastPartTaskImpl(disposable: disposable) @@ -442,7 +449,7 @@ public final class OngoingGroupCallContext { let disposable = MetaDisposable() queue.async { - disposable.set(broadcastPartsSource?.requestPart(timestampMilliseconds: timestampMilliseconds, durationMilliseconds: durationMilliseconds, subject: .audio, completion: completion, rejoinNeeded: { + disposable.set(getBroadcastPartsSource?()?.requestPart(timestampMilliseconds: timestampMilliseconds, durationMilliseconds: durationMilliseconds, subject: .audio, completion: completion, rejoinNeeded: { rejoinNeeded() })) } @@ -464,7 +471,7 @@ public final class OngoingGroupCallContext { @unknown default: mappedQuality = .thumbnail } - disposable.set(broadcastPartsSource?.requestPart(timestampMilliseconds: timestampMilliseconds, durationMilliseconds: durationMilliseconds, subject: .video(channelId: channelId, quality: mappedQuality), completion: completion, rejoinNeeded: { + disposable.set(getBroadcastPartsSource?()?.requestPart(timestampMilliseconds: timestampMilliseconds, durationMilliseconds: durationMilliseconds, subject: .video(channelId: channelId, quality: mappedQuality), completion: completion, rejoinNeeded: { rejoinNeeded() })) } @@ -479,6 +486,11 @@ public final class OngoingGroupCallContext { let queue = self.queue + let broadcastPartsSource = self.broadcastPartsSource + getBroadcastPartsSource = { + return broadcastPartsSource.with { $0 } + } + networkStateUpdatedImpl = { [weak self] state in queue.async { guard let strongSelf = self else { @@ -525,6 +537,13 @@ public final class OngoingGroupCallContext { self.context.setJoinResponsePayload(payload) } + func setAudioStreamData(audioStreamData: AudioStreamData?) { + if let audioStreamData = audioStreamData { + let broadcastPartsSource = NetworkBroadcastPartSource(queue: self.queue, engine: audioStreamData.engine, callId: audioStreamData.callId, accessHash: audioStreamData.accessHash, isExternalStream: audioStreamData.isExternalStream) + let _ = self.broadcastPartsSource.swap(broadcastPartsSource) + } + } + func addSsrcs(ssrcs: [UInt32]) { } @@ -874,10 +893,10 @@ public final class OngoingGroupCallContext { } } - public init(inputDeviceId: String = "", outputDeviceId: String = "", video: OngoingCallVideoCapturer?, requestMediaChannelDescriptions: @escaping (Set, @escaping ([MediaChannelDescription]) -> Void) -> Disposable, audioStreamData: AudioStreamData?, rejoinNeeded: @escaping () -> Void, outgoingAudioBitrateKbit: Int32?, videoContentType: VideoContentType, enableNoiseSuppression: Bool, preferX264: Bool) { + public init(inputDeviceId: String = "", outputDeviceId: String = "", video: OngoingCallVideoCapturer?, requestMediaChannelDescriptions: @escaping (Set, @escaping ([MediaChannelDescription]) -> Void) -> Disposable, rejoinNeeded: @escaping () -> Void, outgoingAudioBitrateKbit: Int32?, videoContentType: VideoContentType, enableNoiseSuppression: Bool, preferX264: Bool) { let queue = self.queue self.impl = QueueLocalObject(queue: queue, generate: { - return Impl(queue: queue, inputDeviceId: inputDeviceId, outputDeviceId: outputDeviceId, video: video, requestMediaChannelDescriptions: requestMediaChannelDescriptions, audioStreamData: audioStreamData, rejoinNeeded: rejoinNeeded, outgoingAudioBitrateKbit: outgoingAudioBitrateKbit, videoContentType: videoContentType, enableNoiseSuppression: enableNoiseSuppression, preferX264: preferX264) + return Impl(queue: queue, inputDeviceId: inputDeviceId, outputDeviceId: outputDeviceId, video: video, requestMediaChannelDescriptions: requestMediaChannelDescriptions, rejoinNeeded: rejoinNeeded, outgoingAudioBitrateKbit: outgoingAudioBitrateKbit, videoContentType: videoContentType, enableNoiseSuppression: enableNoiseSuppression, preferX264: preferX264) }) } @@ -916,17 +935,25 @@ public final class OngoingGroupCallContext { impl.switchAudioInput(deviceId) } } + public func switchAudioOutput(_ deviceId: String) { self.impl.with { impl in impl.switchAudioOutput(deviceId) } } + public func setJoinResponse(payload: String) { self.impl.with { impl in impl.setJoinResponse(payload: payload) } } + public func setAudioStreamData(audioStreamData: AudioStreamData?) { + self.impl.with { impl in + impl.setAudioStreamData(audioStreamData: audioStreamData) + } + } + public func addSsrcs(ssrcs: [UInt32]) { self.impl.with { impl in impl.addSsrcs(ssrcs: ssrcs) diff --git a/submodules/WebSearchUI/Sources/LegacyWebSearchGallery.swift b/submodules/WebSearchUI/Sources/LegacyWebSearchGallery.swift index 4e593cd9bc..2126fb09fb 100644 --- a/submodules/WebSearchUI/Sources/LegacyWebSearchGallery.swift +++ b/submodules/WebSearchUI/Sources/LegacyWebSearchGallery.swift @@ -251,7 +251,7 @@ func legacyWebSearchItem(account: Account, result: ChatContextResult) -> LegacyW switch status { case .Local: return 1.0 - case .Remote: + case .Remote, .Paused: return 0.027 case let .Fetching(_, progress): return max(progress, 0.1) diff --git a/submodules/WebSearchUI/Sources/WebSearchVideoGalleryItem.swift b/submodules/WebSearchUI/Sources/WebSearchVideoGalleryItem.swift index b271a88669..142323c37d 100644 --- a/submodules/WebSearchUI/Sources/WebSearchVideoGalleryItem.swift +++ b/submodules/WebSearchUI/Sources/WebSearchVideoGalleryItem.swift @@ -527,7 +527,7 @@ final class WebSearchVideoGalleryItemNode: ZoomableContentGalleryItemNode { switch fetchStatus { case .Local: videoNode.togglePlayPause() - case .Remote: + case .Remote, .Paused: if self.requiresDownload { self.fetchControls?.fetch() } else {