diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 8eae4eb612..22c3f3be9c 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -1118,6 +1118,7 @@ public protocol SharedAccountContext: AnyObject { func makeCollectibleItemInfoScreen(context: AccountContext, initialData: CollectibleItemInfoScreenInitialData) -> ViewController func makeCollectibleItemInfoScreenInitialData(context: AccountContext, peerId: EnginePeer.Id, subject: CollectibleItemInfoScreenSubject) -> Signal func makeBotSettingsScreen(context: AccountContext, peerId: EnginePeer.Id?) -> ViewController + func makeEditForumTopicScreen(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64, threadInfo: EngineMessageHistoryThread.Info, isHidden: Bool) -> ViewController func navigateToChatController(_ params: NavigateToChatControllerParams) func navigateToForumChannel(context: AccountContext, peerId: EnginePeer.Id, navigationController: NavigationController) diff --git a/submodules/ChatListUI/Sources/ChatContextMenus.swift b/submodules/ChatListUI/Sources/ChatContextMenus.swift index 2dfab03f46..e9a3e09383 100644 --- a/submodules/ChatListUI/Sources/ChatContextMenus.swift +++ b/submodules/ChatListUI/Sources/ChatContextMenus.swift @@ -581,7 +581,7 @@ func chatContextMenuItems(context: AccountContext, peerId: PeerId, promoInfo: Ch } } -func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: Int64, isPinned: Bool?, isClosed: Bool?, chatListController: ChatListControllerImpl?, joined: Bool, canSelect: Bool) -> Signal<[ContextMenuItem], NoError> { +public func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: Int64, isPinned: Bool?, isClosed: Bool?, chatListController: ViewController?, joined: Bool, canSelect: Bool, customEdit: ((ContextController) -> Void)? = nil, customPinUnpin: ((ContextController) -> Void)? = nil, reorder: (() -> Void)? = nil) -> Signal<[ContextMenuItem], NoError> { let presentationData = context.sharedContext.currentPresentationData.with({ $0 }) let strings = presentationData.strings @@ -603,8 +603,17 @@ func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: if let isClosed = isClosed, isClosed && threadId != 1 { } else { - if let isPinned = isPinned, channel.hasPermission(.manageTopics) { - items.append(.action(ContextMenuActionItem(text: isPinned ? presentationData.strings.ChatList_Context_Unpin : presentationData.strings.ChatList_Context_Pin, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: isPinned ? "Chat/Context Menu/Unpin": "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { _, f in + if let isPinned, channel.hasPermission(.manageTopics) { + items.append(.action(ContextMenuActionItem(text: isPinned ? presentationData.strings.ChatList_Context_Unpin : presentationData.strings.ChatList_Context_Pin, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: isPinned ? "Chat/Context Menu/Unpin": "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { c, f in + if let customPinUnpin { + if let c = c as? ContextController { + customPinUnpin(c) + } else { + f(.default) + } + return + } + f(.default) let _ = (context.engine.peers.toggleForumChannelTopicPinned(id: peerId, threadId: threadId) @@ -621,6 +630,15 @@ func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: } }) }))) + + if isPinned, let reorder { + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Reorder", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) }, action: { c, _ in + c?.dismiss(completion: { + }) + reorder() + }))) + } } } @@ -636,6 +654,27 @@ func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: }))) } + var canOpenClose = false + if channel.flags.contains(.isCreator) { + canOpenClose = true + } else if channel.hasPermission(.manageTopics) { + canOpenClose = true + } else if threadData.isOwnedByMe { + canOpenClose = true + } + + if threadId != 1, canOpenClose, let customEdit { + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Edit", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor) }, action: { c, f in + if let c = c as? ContextController { + customEdit(c) + } else { + f(.default) + } + return + }))) + } + var isMuted = false switch threadData.notificationSettings.muteState { case .muted: @@ -863,14 +902,6 @@ func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: }))) if threadId != 1 { - var canOpenClose = false - if channel.flags.contains(.isCreator) { - canOpenClose = true - } else if channel.hasPermission(.manageTopics) { - canOpenClose = true - } else if threadData.isOwnedByMe { - canOpenClose = true - } if canOpenClose { items.append(.action(ContextMenuActionItem(text: threadData.isClosed ? presentationData.strings.ChatList_Context_ReopenTopic : presentationData.strings.ChatList_Context_CloseTopic, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: threadData.isClosed ? "Chat/Context Menu/Play": "Chat/Context Menu/Pause"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) @@ -882,14 +913,36 @@ func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_Delete, textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak chatListController] _, f in f(.default) - chatListController?.deletePeerThread(peerId: peerId, threadId: threadId) + if let chatListController = chatListController as? ChatListControllerImpl { + chatListController.deletePeerThread(peerId: peerId, threadId: threadId) + } else if let chatListController { + let actionSheet = ActionSheetController(presentationData: presentationData) + var items: [ActionSheetItem] = [] + + items.append(ActionSheetTextItem(title: presentationData.strings.ChatList_DeleteTopicConfirmationText, parseMarkdown: true)) + items.append(ActionSheetButtonItem(title: presentationData.strings.ChatList_DeleteTopicConfirmationAction, color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + let _ = context.engine.peers.removeForumChannelThread(id: peerId, threadId: threadId).startStandalone(completed: { + }) + })) + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + chatListController.present(actionSheet, in: .window(.root)) + } }))) } } - if canSelect { + if canSelect, let chatListController = chatListController as? ChatListControllerImpl { items.append(.separator) - items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_Select, textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) }, action: { _, f in + items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_Select, textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) }, action: { [weak chatListController] _, f in f(.default) chatListController?.selectPeerThread(peerId: peerId, threadId: threadId) }))) diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index 5dde9d68e0..d196265547 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -16,8 +16,8 @@ public extension UIView { } } -private extension CALayer { - func animate(from: Any, to: Any, keyPath: String, duration: Double, delay: Double, curve: ComponentTransition.Animation.Curve, removeOnCompletion: Bool, additive: Bool, completion: ((Bool) -> Void)? = nil) { +public extension CALayer { + func animate(from: Any, to: Any, keyPath: String, duration: Double, delay: Double, curve: ComponentTransition.Animation.Curve, removeOnCompletion: Bool, additive: Bool, completion: ((Bool) -> Void)? = nil, key: String? = nil) { let timingFunction: String let mediaTimingFunction: CAMediaTimingFunction? switch curve { @@ -39,7 +39,8 @@ private extension CALayer { mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, - completion: completion + completion: completion, + key: key ) } } diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index 9443d324d1..99044f64e6 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -263,6 +263,7 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel public final var didScrollWithOffset: ((CGFloat, ContainedViewLayoutTransition, ListViewItemNode?, Bool) -> Void)? public final var addContentOffset: ((CGFloat, ListViewItemNode?) -> Void)? public final var shouldStopScrolling: ((CGFloat) -> Bool)? + public final var onContentsUpdated: ((ContainedViewLayoutTransition) -> Void)? public final var updateScrollingIndicator: ((ScrollingIndicatorState?, ContainedViewLayoutTransition) -> Void)? @@ -389,6 +390,7 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel private var reorderScrollUpdateTimestamp: Double? private var reorderLastTimestamp: Double? public var reorderedItemHasShadow = true + public var reorderingRequiresLongPress = false private let waitingForNodesDisposable = MetaDisposable() @@ -518,7 +520,7 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel let itemNodeFrame = itemNode.frame let itemNodeBounds = itemNode.bounds if itemNode.isReorderable(at: point.offsetBy(dx: -itemNodeFrame.minX + itemNodeBounds.minX, dy: -itemNodeFrame.minY + itemNodeBounds.minY)) { - let requiresLongPress = !strongSelf.reorderedItemHasShadow + let requiresLongPress = strongSelf.reorderingRequiresLongPress return (true, requiresLongPress, itemNode) } break @@ -1101,6 +1103,7 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel self.updateVisibleContentOffset() self.updateVisibleItemRange() self.updateItemNodesVisibilities(onlyPositive: false) + self.onContentsUpdated?(.immediate) //CATransaction.commit() } @@ -3509,6 +3512,7 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel } self.updateItemHeaders(leftInset: listInsets.left, rightInset: listInsets.right, synchronousLoad: synchronousLoads, transition: headerNodesTransition, animateInsertion: animated || !requestItemInsertionAnimationsIndices.isEmpty, animateFullTransition: animateFullTransition) + self.onContentsUpdated?(headerNodesTransition.0) if let offset = offset, !offset.isZero { //self.didScrollWithOffset?(-offset, headerNodesTransition.0, nil) @@ -3733,6 +3737,7 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel } else { self.updateItemHeaders(leftInset: listInsets.left, rightInset: listInsets.right, synchronousLoad: synchronousLoads, transition: headerNodesTransition, animateInsertion: animated || !requestItemInsertionAnimationsIndices.isEmpty, animateFullTransition: animateFullTransition) self.updateItemNodesVisibilities(onlyPositive: deferredUpdateVisible) + self.onContentsUpdated?(headerNodesTransition.0) applyHeaderNodesFullTransition() diff --git a/submodules/Display/Source/ListViewAnimation.swift b/submodules/Display/Source/ListViewAnimation.swift index 44f51677a3..137c0fd16f 100644 --- a/submodules/Display/Source/ListViewAnimation.swift +++ b/submodules/Display/Source/ListViewAnimation.swift @@ -129,7 +129,7 @@ public func listViewAnimationCurveFromAnimationOptions(animationOptions: UIView. public final class ListViewAnimation { let from: Interpolatable - let to: Interpolatable + public let to: Interpolatable let duration: Double let startTime: Double let invertOffsetDirection: Bool diff --git a/submodules/TelegramApi/Sources/Api38.swift b/submodules/TelegramApi/Sources/Api38.swift index f244a6ac73..acc66397d7 100644 --- a/submodules/TelegramApi/Sources/Api38.swift +++ b/submodules/TelegramApi/Sources/Api38.swift @@ -8923,13 +8923,14 @@ public extension Api.functions.messages { } } public extension Api.functions.messages { - static func unpinAllMessages(flags: Int32, peer: Api.InputPeer, topMsgId: Int32?) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + static func unpinAllMessages(flags: Int32, peer: Api.InputPeer, topMsgId: Int32?, savedPeerId: Api.InputPeer?) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() - buffer.appendInt32(-299714136) + buffer.appendInt32(103667527) serializeInt32(flags, buffer: buffer, boxed: false) peer.serialize(buffer, true) if Int(flags) & Int(1 << 0) != 0 {serializeInt32(topMsgId!, buffer: buffer, boxed: false)} - return (FunctionDescription(name: "messages.unpinAllMessages", parameters: [("flags", String(describing: flags)), ("peer", String(describing: peer)), ("topMsgId", String(describing: topMsgId))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.messages.AffectedHistory? in + if Int(flags) & Int(1 << 1) != 0 {savedPeerId!.serialize(buffer, true)} + return (FunctionDescription(name: "messages.unpinAllMessages", parameters: [("flags", String(describing: flags)), ("peer", String(describing: peer)), ("topMsgId", String(describing: topMsgId)), ("savedPeerId", String(describing: savedPeerId))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.messages.AffectedHistory? in let reader = BufferReader(buffer) var result: Api.messages.AffectedHistory? if let signature = reader.readInt32() { diff --git a/submodules/TelegramCore/Sources/State/AccountStateManager.swift b/submodules/TelegramCore/Sources/State/AccountStateManager.swift index c81ec2048a..a4ec6abec5 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManager.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManager.swift @@ -2345,7 +2345,8 @@ public func messagesForNotification(transaction: Transaction, id: MessageId, alw var notificationSettingsStack: [TelegramPeerNotificationSettings] = [] - if let threadId = message.threadId, let threadData = transaction.getMessageHistoryThreadInfo(peerId: message.id.peerId, threadId: threadId)?.data.get(MessageHistoryThreadData.self) { + if let peer = peer as? TelegramChannel, peer.isMonoForum { + } else if let threadId = message.threadId, let threadData = transaction.getMessageHistoryThreadInfo(peerId: message.id.peerId, threadId: threadId)?.data.get(MessageHistoryThreadData.self) { notificationSettingsStack.append(threadData.notificationSettings) } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/UpdatePinnedMessage.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/UpdatePinnedMessage.swift index 09ec98fd65..5874962f80 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/UpdatePinnedMessage.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/UpdatePinnedMessage.swift @@ -110,12 +110,18 @@ func _internal_requestUpdatePinnedMessage(account: Account, peerId: PeerId, upda } func _internal_requestUnpinAllMessages(account: Account, peerId: PeerId, threadId: Int64?) -> Signal { - return account.postbox.transaction { transaction -> (Peer?, CachedPeerData?) in - return (transaction.getPeer(peerId), transaction.getPeerCachedData(peerId: peerId)) + return account.postbox.transaction { transaction -> (Peer?, Peer?, CachedPeerData?) in + let peer = transaction.getPeer(peerId) + var subPeer: Peer? + if let channel = peer as? TelegramChannel, channel.isMonoForum, let threadId { + subPeer = transaction.getPeer(PeerId(threadId)) + } + + return (peer, subPeer, transaction.getPeerCachedData(peerId: peerId)) } |> mapError { _ -> UpdatePinnedMessageError in } - |> mapToSignal { peer, cachedPeerData -> Signal in + |> mapToSignal { peer, subPeer, cachedPeerData -> Signal in guard let peer = peer, let inputPeer = apiInputPeer(peer) else { return .fail(.generic) } @@ -148,10 +154,20 @@ func _internal_requestUnpinAllMessages(account: Account, peerId: PeerId, threadI } var flags: Int32 = 0 - if threadId != nil { - flags |= (1 << 0) + var topMsgId: Int32? + var savedPeerId: Api.InputPeer? + if let threadId { + if let channel = peer as? TelegramChannel, channel.isMonoForum { + if let inputSubPeer = subPeer.flatMap(apiInputPeer) { + flags |= (1 << 1) + savedPeerId = inputSubPeer + } + } else { + flags |= (1 << 0) + topMsgId = Int32(clamping: threadId) + } } - let request: Signal = account.network.request(Api.functions.messages.unpinAllMessages(flags: flags, peer: inputPeer, topMsgId: threadId.flatMap(Int32.init(clamping:)))) + let request: Signal = account.network.request(Api.functions.messages.unpinAllMessages(flags: flags, peer: inputPeer, topMsgId: topMsgId, savedPeerId: savedPeerId)) |> mapError { error -> InternalError in return .error(error.errorDescription) } diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 2dbaa6367f..922a08f279 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -479,6 +479,8 @@ swift_library( "//submodules/TelegramUI/Components/PeerInfo/PostSuggestionsSettingsScreen", "//submodules/TelegramUI/Components/ForumSettingsScreen", "//submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel", + "//submodules/TelegramUI/Components/GifVideoLayer", + "//submodules/TelegramUI/Components/BatchVideoRendering", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, "//build-system:ios_sim_arm64": [], diff --git a/submodules/TelegramUI/Components/AsyncListComponent/BUILD b/submodules/TelegramUI/Components/AsyncListComponent/BUILD new file mode 100644 index 0000000000..51c0d2258b --- /dev/null +++ b/submodules/TelegramUI/Components/AsyncListComponent/BUILD @@ -0,0 +1,23 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "AsyncListComponent", + module_name = "AsyncListComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/ComponentFlow", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/MergeLists", + "//submodules/Components/ComponentDisplayAdapters", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/AsyncListComponent/Sources/AsyncListComponent.swift b/submodules/TelegramUI/Components/AsyncListComponent/Sources/AsyncListComponent.swift new file mode 100644 index 0000000000..ac981ff621 --- /dev/null +++ b/submodules/TelegramUI/Components/AsyncListComponent/Sources/AsyncListComponent.swift @@ -0,0 +1,603 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import MergeLists +import ComponentDisplayAdapters + +public final class AsyncListComponent: Component { + public protocol ItemView: UIView { + func isReorderable(at point: CGPoint) -> Bool + } + + public final class OverlayContainerView: UIView { + public override init(frame: CGRect) { + super.init(frame: frame) + + self.layer.anchorPoint = CGPoint() + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func updatePosition(position: CGPoint, transition: ComponentTransition) { + let previousPosition: CGPoint + var forceUpdate = false + if self.layer.animation(forKey: "positionUpdate") != nil, let presentation = self.layer.presentation() { + forceUpdate = true + previousPosition = presentation.position + + if !transition.animation.isImmediate { + self.layer.removeAnimation(forKey: "positionUpdate") + } + } else { + previousPosition = self.layer.position + } + + if previousPosition != position || forceUpdate { + self.center = position + if case let .curve(duration, curve) = transition.animation { + self.layer.animate( + from: NSValue(cgPoint: CGPoint(x: previousPosition.x - position.x, y: previousPosition.y - position.y)), + to: NSValue(cgPoint: CGPoint()), + keyPath: "position", + duration: duration, + delay: 0.0, + curve: curve, + removeOnCompletion: true, + additive: true, + completion: nil, + key: "positionUpdate" + ) + } + } + } + } + + final class ResetScrollingRequest: Equatable { + let requestId: Int + let id: AnyHashable + + init(requestId: Int, id: AnyHashable) { + self.requestId = requestId + self.id = id + } + + static func ==(lhs: ResetScrollingRequest, rhs: ResetScrollingRequest) -> Bool { + if lhs === rhs { + return true + } + if lhs.requestId != rhs.requestId { + return false + } + if lhs.id != rhs.id { + return false + } + return true + } + } + + public final class ExternalState { + public struct Value: Equatable { + var resetScrollingRequest: ResetScrollingRequest? + + public static func ==(lhs: Value, rhs: Value) -> Bool { + if lhs.resetScrollingRequest != rhs.resetScrollingRequest { + return false + } + return true + } + } + + public private(set) var value: Value = Value() + private var nextId: Int = 0 + + public init() { + + } + + public func resetScrolling(id: AnyHashable) { + let requestId = self.nextId + self.nextId += 1 + self.value.resetScrollingRequest = ResetScrollingRequest(requestId: requestId, id: id) + } + } + + public enum Direction { + case vertical + case horizontal + } + + public final class VisibleItem { + public let item: AnyComponentWithIdentity + public let frame: CGRect + + init(item: AnyComponentWithIdentity, frame: CGRect) { + self.item = item + self.frame = frame + } + } + + public final class VisibleItems: Sequence, IteratorProtocol { + private let view: AsyncListComponent.View + private var index: Int = 0 + private let indices: [(Int, CGRect)] + + init(view: AsyncListComponent.View, direction: Direction) { + self.view = view + var indices: [(Int, CGRect)] = [] + view.listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ListItemNodeImpl, let index = itemNode.index { + var itemFrame = itemNode.frame + itemFrame.origin.y -= itemNode.transitionOffset + if let animation = itemNode.animationForKey("height") { + if let height = animation.to as? CGFloat { + itemFrame.size.height = height + } + } + + if case .horizontal = direction { + itemFrame = CGRect(origin: CGPoint(x: itemFrame.minY, y: itemFrame.minX), size: CGSize(width: itemFrame.height, height: itemFrame.width)) + } + + indices.append((index, itemFrame)) + } + } + indices.sort(by: { $0.0 < $1.0 }) + self.indices = indices + } + + public func next() -> VisibleItem? { + if self.index >= self.indices.count { + return nil + } + let index = self.index + self.index += 1 + + if let component = self.view.component { + let (itemIndex, itemFrame) = self.indices[index] + return VisibleItem(item: component.items[itemIndex], frame: itemFrame) + } + + return nil + } + } + + public let externalState: ExternalState + public let externalStateValue: ExternalState.Value + public let items: [AnyComponentWithIdentity] + public let itemSetId: AnyHashable // Changing itemSetId supresses update animations + public let direction: Direction + public let insets: UIEdgeInsets + public let reorderItems: ((Int, Int) -> Bool)? + public let onVisibleItemsUpdated: ((VisibleItems, ComponentTransition) -> Void)? + + public init( + externalState: ExternalState, + items: [AnyComponentWithIdentity], + itemSetId: AnyHashable, + direction: Direction, + insets: UIEdgeInsets, + reorderItems: ((Int, Int) -> Bool)? = nil, + onVisibleItemsUpdated: ((VisibleItems, ComponentTransition) -> Void)? = nil + ) { + self.externalState = externalState + self.externalStateValue = externalState.value + self.items = items + self.itemSetId = itemSetId + self.direction = direction + self.insets = insets + self.reorderItems = reorderItems + self.onVisibleItemsUpdated = onVisibleItemsUpdated + } + + public static func ==(lhs: AsyncListComponent, rhs: AsyncListComponent) -> Bool { + if lhs.externalState !== rhs.externalState { + return false + } + if lhs.items != rhs.items { + return false + } + if lhs.itemSetId != rhs.itemSetId { + return false + } + if lhs.direction != rhs.direction { + return false + } + if lhs.insets != rhs.insets { + return false + } + if (lhs.reorderItems == nil) != (rhs.reorderItems == nil) { + return false + } + return true + } + + private struct ItemEntry: Comparable, Identifiable { + let contents: AnyComponentWithIdentity + let index: Int + + var id: AnyHashable { + return self.contents.id + } + + var stableId: AnyHashable { + return self.id + } + + static func ==(lhs: ItemEntry, rhs: ItemEntry) -> Bool { + if lhs.contents != rhs.contents { + return false + } + if lhs.index != rhs.index { + return false + } + return true + } + + static func <(lhs: ItemEntry, rhs: ItemEntry) -> Bool { + return lhs.index < rhs.index + } + + func item(parentView: AsyncListComponent.View?, direction: Direction) -> ListViewItem { + return ListItemImpl(parentView: parentView, contents: self.contents, direction: direction) + } + } + + private final class ListItemImpl: ListViewItem { + weak var parentView: AsyncListComponent.View? + let contents: AnyComponentWithIdentity + let direction: Direction + + let selectable: Bool = false + + init(parentView: AsyncListComponent.View?, contents: AnyComponentWithIdentity, direction: Direction) { + self.parentView = parentView + self.contents = contents + self.direction = direction + } + + func nodeConfiguredForParams( + async: @escaping (@escaping () -> Void) -> Void, + params: ListViewItemLayoutParams, + synchronousLoads: Bool, + previousItem: ListViewItem?, + nextItem: ListViewItem?, + completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void + ) { + async { + let impl: () -> Void = { + let node = ListItemNodeImpl() + let (nodeLayout, apply) = node.asyncLayout()(self, params) + node.insets = nodeLayout.insets + node.contentSize = nodeLayout.contentSize + + Queue.mainQueue().async { + completion(node, { + return (nil, { _ in + apply(false) + }) + }) + } + } + + if Thread.isMainThread { + impl() + } else { + assert(false) + Queue.mainQueue().async { + impl() + } + } + } + } + + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + assert(node() is ListItemNodeImpl) + if let nodeValue = node() as? ListItemNodeImpl { + let layout = nodeValue.asyncLayout() + async { + let impl: () -> Void = { + let (nodeLayout, apply) = layout(self, params) + Queue.mainQueue().async { + completion(nodeLayout, { _ in + apply(animation.isAnimated) + }) + } + } + + if Thread.isMainThread { + impl() + } else { + assert(false) + Queue.mainQueue().async { + impl() + } + } + } + } + } + } + } + + private final class ListItemNodeImpl: ListViewItemNode { + private let contentsView = ComponentView() + private(set) var item: ListItemImpl? + + init() { + super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false) + } + + deinit { + } + + override func isReorderable(at point: CGPoint) -> Bool { + if let itemView = self.contentsView.view as? ItemView { + return itemView.isReorderable(at: self.view.convert(point, to: itemView)) + } + return false + } + + override func snapshotForReordering() -> UIView? { + return self.view.snapshotView(afterScreenUpdates: false) + } + + func asyncLayout() -> (ListItemImpl, ListViewItemLayoutParams) -> (ListViewItemNodeLayout, (Bool) -> Void) { + return { item, params in + let containerSize: CGSize + switch item.direction { + case .vertical: + containerSize = CGSize(width: params.width, height: 100000.0) + case .horizontal: + containerSize = CGSize(width: 100000.0, height: params.width) + } + + let contentsSize = self.contentsView.update( + transition: .immediate, + component: item.contents.component, + environment: {}, + containerSize: containerSize + ) + + let mappedContentsSize: CGSize + switch item.direction { + case .vertical: + mappedContentsSize = CGSize(width: params.width, height: contentsSize.height) + case .horizontal: + mappedContentsSize = CGSize(width: params.width, height: contentsSize.width) + } + + let itemLayout = ListViewItemNodeLayout(contentSize: mappedContentsSize, insets: UIEdgeInsets()) + return (itemLayout, { animated in + self.item = item + + switch item.direction { + case .vertical: + self.layer.sublayerTransform = CATransform3DIdentity + case .horizontal: + self.layer.sublayerTransform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) + } + + let contentsFrame = CGRect(origin: CGPoint(), size: contentsSize) + + if let contentsComponentView = self.contentsView.view { + if contentsComponentView.superview == nil { + self.view.addSubview(contentsComponentView) + } + contentsComponentView.center = CGPoint(x: mappedContentsSize.width * 0.5, y: mappedContentsSize.height * 0.5) + contentsComponentView.bounds = CGRect(origin: CGPoint(), size: contentsFrame.size) + } + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { + super.animateInsertion(currentTimestamp, duration: duration, options: options) + + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + super.animateRemoved(currentTimestamp, duration: duration) + + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + } + + override func animateAdded(_ currentTimestamp: Double, duration: Double) { + super.animateAdded(currentTimestamp, duration: duration) + + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + + public final class View: UIView { + let listNode: ListView + + private var externalStateValue: ExternalState.Value? + private var isUpdating: Bool = false + private(set) var component: AsyncListComponent? + + private var currentEntries: [ItemEntry] = [] + + private var ignoreUpdateVisibleItems: Bool = false + + public override init(frame: CGRect) { + self.listNode = ListView() + self.listNode.useMainQueueTransactions = true + self.listNode.scroller.delaysContentTouches = false + self.listNode.reorderedItemHasShadow = false + + super.init(frame: frame) + + self.addSubview(self.listNode.view) + + self.listNode.onContentsUpdated = { [weak self] transition in + guard let self else { + return + } + self.updateVisibleItems(transition: ComponentTransition(transition)) + } + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + + public func stopScrolling() { + self.listNode.stopScrolling() + } + + private func updateVisibleItems(transition: ComponentTransition) { + if self.ignoreUpdateVisibleItems { + return + } + guard let component = self.component else { + return + } + if let onVisibleItemsUpdated = component.onVisibleItemsUpdated { + onVisibleItemsUpdated(VisibleItems(view: self, direction: component.direction), transition) + } + } + + func update(component: AsyncListComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + let previousComponent = self.component + self.component = component + + let listSize: CGSize + let listInsets: UIEdgeInsets + switch component.direction { + case .vertical: + self.listNode.transform = CATransform3DIdentity + listSize = CGSize(width: availableSize.width, height: availableSize.height) + listInsets = component.insets + case .horizontal: + self.listNode.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) + listSize = CGSize(width: availableSize.height, height: availableSize.width) + listInsets = UIEdgeInsets(top: component.insets.left, left: component.insets.top, bottom: component.insets.right, right: component.insets.bottom) + } + + var updateSizeAndInsets = ListViewUpdateSizeAndInsets( + size: listSize, + insets: listInsets, + duration: 0.0, + curve: .Default(duration: nil) + ) + + var animateTransition = false + var transactionOptions: ListViewDeleteAndInsertOptions = [] + + if !transition.animation.isImmediate, let previousComponent { + if previousComponent.itemSetId == component.itemSetId { + transactionOptions.insert(.AnimateInsertion) + } + animateTransition = true + + switch transition.animation { + case .none: + break + case let .curve(duration, curve): + updateSizeAndInsets.duration = duration + switch curve { + case .linear, .easeInOut: + updateSizeAndInsets.curve = .Default(duration: duration) + case .spring: + updateSizeAndInsets.curve = .Spring(duration: duration) + case let .custom(a, b, c, d): + updateSizeAndInsets.curve = .Custom(duration: duration, a, b, c, d) + } + } + } + + var entries: [ItemEntry] = [] + for item in component.items { + entries.append(ItemEntry( + contents: item, + index: entries.count + )) + } + + var scrollToItem: ListViewScrollToItem? + if let resetScrollingRequest = component.externalStateValue.resetScrollingRequest, previousComponent?.externalStateValue.resetScrollingRequest != component.externalStateValue.resetScrollingRequest { + //TODO:release calculate direction hint + if let index = entries.firstIndex(where: { $0.id == resetScrollingRequest.id }) { + scrollToItem = ListViewScrollToItem( + index: index, + position: .visible, + animated: animateTransition, + curve: updateSizeAndInsets.curve, + directionHint: .Down + ) + } + } + + self.ignoreUpdateVisibleItems = true + + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: self.currentEntries, rightList: entries) + self.currentEntries = entries + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(parentView: self, direction: component.direction), directionHint: .Down) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(parentView: self, direction: component.direction), directionHint: nil) } + + transactionOptions.insert(.Synchronous) + + self.listNode.transaction( + deleteIndices: deletions, + insertIndicesAndItems: insertions, + updateIndicesAndItems: updates, + options: transactionOptions, + scrollToItem: scrollToItem, + updateSizeAndInsets: updateSizeAndInsets, + stationaryItemRange: nil, + updateOpaqueState: nil, + completion: { _ in } + ) + + let mappedListFrame: CGRect + switch component.direction { + case .vertical: + mappedListFrame = CGRect(origin: CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5), size: listSize) + case .horizontal: + mappedListFrame = CGRect(origin: CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5), size: listSize) + } + self.listNode.position = mappedListFrame.origin + self.listNode.bounds = CGRect(origin: CGPoint(), size: mappedListFrame.size) + + self.listNode.reorderItem = { [weak self] fromIndex, toIndex, _ in + guard let self, let component = self.component else { + return .single(false) + } + guard let reorderItems = component.reorderItems else { + return .single(false) + } + + if reorderItems(fromIndex, toIndex) { + return .single(true) + } else { + return .single(false) + } + } + + self.ignoreUpdateVisibleItems = false + + self.updateVisibleItems(transition: transition) + + return availableSize + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/BUILD b/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/BUILD index 436d275171..7dc886d4b5 100644 --- a/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/BUILD @@ -19,7 +19,8 @@ swift_library( "//submodules/AppBundle", "//submodules/ContextUI", "//submodules/SoftwareVideo", - "//submodules/TelegramUI/Components/MultiplexedVideoNode", + "//submodules/TelegramUI/Components/BatchVideoRendering", + "//submodules/TelegramUI/Components/GifVideoLayer", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/Sources/ChatContextResultPeekContent.swift b/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/Sources/ChatContextResultPeekContent.swift index 5e55312bd9..0b75b02a83 100644 --- a/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/Sources/ChatContextResultPeekContent.swift +++ b/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/Sources/ChatContextResultPeekContent.swift @@ -10,17 +10,21 @@ import PhotoResources import AppBundle import ContextUI import SoftwareVideo -import MultiplexedVideoNode +import BatchVideoRendering +import GifVideoLayer +import AccountContext public final class ChatContextResultPeekContent: PeekControllerContent { - public let account: Account + public let context: AccountContext public let contextResult: ChatContextResult public let menu: [ContextMenuItem] + public let batchVideoContext: BatchVideoRenderingContext - public init(account: Account, contextResult: ChatContextResult, menu: [ContextMenuItem]) { - self.account = account + public init(context: AccountContext, contextResult: ChatContextResult, menu: [ContextMenuItem], batchVideoContext: BatchVideoRenderingContext) { + self.context = context self.contextResult = contextResult self.menu = menu + self.batchVideoContext = batchVideoContext } public func presentation() -> PeekControllerContentPresentation { @@ -36,7 +40,7 @@ public final class ChatContextResultPeekContent: PeekControllerContent { } public func node() -> PeekControllerContentNode & ASDisplayNode { - return ChatContextResultPeekNode(account: self.account, contextResult: self.contextResult) + return ChatContextResultPeekNode(context: self.context, contextResult: self.contextResult, batchVideoContext: self.batchVideoContext) } public func topAccessoryNode() -> ASDisplayNode? { @@ -62,62 +66,29 @@ public final class ChatContextResultPeekContent: PeekControllerContent { } private final class ChatContextResultPeekNode: ASDisplayNode, PeekControllerContentNode { - private let account: Account + private let context: AccountContext private let contextResult: ChatContextResult + private let batchVideoContext: BatchVideoRenderingContext private let imageNodeBackground: ASDisplayNode private let imageNode: TransformImageNode - private var videoLayer: (SoftwareVideoThumbnailNode, SoftwareVideoLayerFrameManager, SampleBufferLayer)? + private var videoLayer: GifVideoLayer? private var currentImageResource: TelegramMediaResource? private var currentVideoFile: TelegramMediaFile? - private let timebase: CMTimebase - - private var displayLink: CADisplayLink? private var ticking: Bool = false { didSet { if self.ticking != oldValue { - if self.ticking { - class DisplayLinkProxy: NSObject { - weak var target: ChatContextResultPeekNode? - init(target: ChatContextResultPeekNode) { - self.target = target - } - - @objc func displayLinkEvent() { - self.target?.displayLinkEvent() - } - } - - let displayLink = CADisplayLink(target: DisplayLinkProxy(target: self), selector: #selector(DisplayLinkProxy.displayLinkEvent)) - self.displayLink = displayLink - displayLink.add(to: RunLoop.main, forMode: .common) - if #available(iOS 10.0, *) { - displayLink.preferredFramesPerSecond = 25 - } else { - displayLink.frameInterval = 2 - } - displayLink.isPaused = false - CMTimebaseSetRate(self.timebase, rate: 1.0) - } else if let displayLink = self.displayLink { - self.displayLink = nil - displayLink.isPaused = true - displayLink.invalidate() - CMTimebaseSetRate(self.timebase, rate: 0.0) - } + self.videoLayer?.shouldBeAnimating = self.ticking } } } - private func displayLinkEvent() { - let timestamp = CMTimebaseGetTime(self.timebase).seconds - self.videoLayer?.1.tick(timestamp: timestamp) - } - - init(account: Account, contextResult: ChatContextResult) { - self.account = account + init(context: AccountContext, contextResult: ChatContextResult, batchVideoContext: BatchVideoRenderingContext) { + self.context = context self.contextResult = contextResult + self.batchVideoContext = batchVideoContext self.imageNodeBackground = ASDisplayNode() self.imageNodeBackground.isLayerBacked = true @@ -128,11 +99,6 @@ private final class ChatContextResultPeekNode: ASDisplayNode, PeekControllerCont self.imageNode.isLayerBacked = !smartInvertColorsEnabled() self.imageNode.displaysAsynchronously = false - var timebase: CMTimebase? - CMTimebaseCreateWithSourceClock(allocator: nil, sourceClock: CMClockGetHostTimeClock(), timebaseOut: &timebase) - CMTimebaseSetRate(timebase!, rate: 0.0) - self.timebase = timebase! - super.init() self.addSubnode(self.imageNodeBackground) @@ -142,10 +108,6 @@ private final class ChatContextResultPeekNode: ASDisplayNode, PeekControllerCont } deinit { - if let displayLink = self.displayLink { - displayLink.isPaused = true - displayLink.invalidate() - } } func ready() -> Signal { @@ -236,7 +198,7 @@ private final class ChatContextResultPeekNode: ASDisplayNode, PeekControllerCont if let imageResource = imageResource { let tmpRepresentation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: Int32(fittedImageDimensions.width * 2.0), height: Int32(fittedImageDimensions.height * 2.0)), resource: imageResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false) let tmpImage = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: [tmpRepresentation], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: []) - updateImageSignal = chatMessagePhoto(postbox: self.account.postbox, userLocation: .other, photoReference: .standalone(media: tmpImage)) + updateImageSignal = chatMessagePhoto(postbox: self.context.account.postbox, userLocation: .other, photoReference: .standalone(media: tmpImage)) } else { updateImageSignal = .complete() } @@ -256,33 +218,26 @@ private final class ChatContextResultPeekNode: ASDisplayNode, PeekControllerCont } if updatedVideoFile { - if let (thumbnailLayer, _, layer) = self.videoLayer { + if let videoLayer = self.videoLayer { self.videoLayer = nil - thumbnailLayer.removeFromSupernode() - layer.layer.removeFromSuperlayer() + videoLayer.removeFromSuperlayer() } - if let videoFileReference = videoFileReference { - let thumbnailLayer = SoftwareVideoThumbnailNode(account: self.account, fileReference: videoFileReference, synchronousLoad: false) - self.addSubnode(thumbnailLayer) - let layerHolder = takeSampleBufferLayer() - layerHolder.layer.videoGravity = AVLayerVideoGravity.resizeAspectFill - self.layer.addSublayer(layerHolder.layer) - let manager = SoftwareVideoLayerFrameManager(account: self.account, userLocation: .other, userContentType: .other, fileReference: videoFileReference, layerHolder: layerHolder) - self.videoLayer = (thumbnailLayer, manager, layerHolder) - thumbnailLayer.ready = { [weak self, weak thumbnailLayer, weak manager] in - if let strongSelf = self, let thumbnailLayer = thumbnailLayer, let manager = manager { - if strongSelf.videoLayer?.0 === thumbnailLayer && strongSelf.videoLayer?.1 === manager { - manager.start() - } - } - } + if let videoFileReference { + let videoLayer = GifVideoLayer( + context: self.context, + batchVideoContext: self.batchVideoContext, + userLocation: .other, + file: videoFileReference, + synchronousLoad: false + ) + self.videoLayer = videoLayer + self.layer.addSublayer(videoLayer) } } - if let (thumbnailLayer, _, layer) = self.videoLayer { - thumbnailLayer.frame = CGRect(origin: CGPoint(), size: croppedImageDimensions) - layer.layer.frame = CGRect(origin: CGPoint(), size: CGSize(width: croppedImageDimensions.width, height: croppedImageDimensions.height)) + if let videoLayer = self.videoLayer { + videoLayer.frame = CGRect(origin: CGPoint(), size: CGSize(width: croppedImageDimensions.width, height: croppedImageDimensions.height)) } if !self.ticking { diff --git a/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/BUILD b/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/BUILD index 0a13c0f284..faaa994060 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/BUILD @@ -24,6 +24,13 @@ swift_library( "//submodules/TelegramUI/Components/EmojiStatusComponent", "//submodules/Components/BundleIconComponent", "//submodules/AvatarNode", + "//submodules/ChatListUI", + "//submodules/ContextUI", + "//submodules/TelegramUI/Components/AsyncListComponent", + "//submodules/TelegramUI/Components/TextBadgeComponent", + "//submodules/TelegramUI/Components/MaskedContainerComponent", + "//submodules/AppBundle", + "//submodules/PresentationDataUtils", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatSideTopicsPanel.swift b/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatSideTopicsPanel.swift index b5b43da910..202a19d8da 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatSideTopicsPanel.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatSideTopicsPanel.swift @@ -14,6 +14,13 @@ import BlurredBackgroundComponent import EmojiStatusComponent import BundleIconComponent import AvatarNode +import ChatListUI +import ContextUI +import AsyncListComponent +import TextBadgeComponent +import MaskedContainerComponent +import AppBundle +import PresentationDataUtils public final class ChatSidePanelEnvironment: Equatable { public let insets: UIEdgeInsets @@ -33,12 +40,19 @@ public final class ChatSidePanelEnvironment: Equatable { public final class ChatSideTopicsPanel: Component { public typealias EnvironmentType = ChatSidePanelEnvironment + public enum Location { + case side + case top + } + let context: AccountContext let theme: PresentationTheme let strings: PresentationStrings + let location: Location let peerId: EnginePeer.Id let isMonoforum: Bool let topicId: Int64? + let controller: () -> ViewController? let togglePanel: () -> Void let updateTopicId: (Int64?, Bool) -> Void @@ -46,18 +60,22 @@ public final class ChatSideTopicsPanel: Component { context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, + location: Location, peerId: EnginePeer.Id, isMonoforum: Bool, topicId: Int64?, + controller: @escaping () -> ViewController?, togglePanel: @escaping () -> Void, updateTopicId: @escaping (Int64?, Bool) -> Void ) { self.context = context self.theme = theme self.strings = strings + self.location = location self.peerId = peerId self.isMonoforum = isMonoforum self.topicId = topicId + self.controller = controller self.togglePanel = togglePanel self.updateTopicId = updateTopicId } @@ -72,6 +90,9 @@ public final class ChatSideTopicsPanel: Component { if lhs.strings !== rhs.strings { return false } + if lhs.location != rhs.location { + return false + } if lhs.peerId != rhs.peerId { return false } @@ -108,223 +129,869 @@ public final class ChatSideTopicsPanel: Component { } } - private final class ItemView: UIView { - private let context: AccountContext - private let action: () -> Void + private protocol ItemComponent: AnyObject { + var item: Item { get } + } + + private final class VerticalItemComponent: Component, ItemComponent { + let context: AccountContext + let item: Item + let isSelected: Bool + let isReordering: Bool + let theme: PresentationTheme + let action: (() -> Void)? + let contextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)? - private let extractedContainerNode: ContextExtractedContentContainingNode - private let containerNode: ContextControllerSourceNode - - private let containerButton: HighlightTrackingButton - - private var icon: ComponentView? - private var avatarNode: AvatarNode? - private let title = ComponentView() - - init(context: AccountContext, action: @escaping (() -> Void), contextGesture: @escaping (ContextGesture, ContextExtractedContentContainingNode) -> Void) { + init(context: AccountContext, item: Item, isSelected: Bool, isReordering: Bool, theme: PresentationTheme, strings: PresentationStrings, action: (() -> Void)?, contextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)?) { self.context = context + self.item = item + self.isSelected = isSelected + self.isReordering = isReordering + self.theme = theme self.action = action + self.contextGesture = contextGesture + } + + static func ==(lhs: VerticalItemComponent, rhs: VerticalItemComponent) -> Bool { + if lhs === rhs { + return true + } + if lhs.context !== rhs.context { + return false + } + if lhs.item != rhs.item { + return false + } + if lhs.isSelected != rhs.isSelected { + return false + } + if lhs.isReordering != rhs.isReordering { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false + } + if (lhs.contextGesture == nil) != (rhs.contextGesture == nil) { + return false + } + return true + } + + final class View: UIView, AsyncListComponent.ItemView { + private let extractedContainerNode: ContextExtractedContentContainingNode + private let containerNode: ContextControllerSourceNode - self.extractedContainerNode = ContextExtractedContentContainingNode() - self.containerNode = ContextControllerSourceNode() + private let containerButton: UIView + private var extractedBackgroundView: UIImageView? - self.containerButton = HighlightTrackingButton() + private var tapRecognizer: UITapGestureRecognizer? - super.init(frame: CGRect()) + private let iconContainer: MaskedContainerView + private var icon: ComponentView? + private var avatarNode: AvatarNode? + private let title = ComponentView() + private var badge: ComponentView? - self.extractedContainerNode.contentNode.view.addSubview(self.containerButton) + private var component: VerticalItemComponent? - self.containerNode.addSubnode(self.extractedContainerNode) - self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode - self.addSubview(self.containerNode.view) - - self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) - self.containerButton.highligthedChanged = { [weak self] highlighted in - if let self, self.bounds.width > 0.0 { - let topScale: CGFloat = (self.bounds.width - 1.0) / self.bounds.width - let maxScale: CGFloat = (self.bounds.width + 1.0) / self.bounds.width + override init(frame: CGRect) { + self.extractedContainerNode = ContextExtractedContentContainingNode() + self.containerNode = ContextControllerSourceNode() + + self.iconContainer = MaskedContainerView() + self.iconContainer.isUserInteractionEnabled = false + + self.containerButton = UIView() + + super.init(frame: frame) + + self.extractedContainerNode.contentNode.view.addSubview(self.containerButton) + + self.containerNode.addSubnode(self.extractedContainerNode) + self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode + self.addSubview(self.containerNode.view) + + self.containerButton.addSubview(self.iconContainer) + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.tapRecognizer = tapRecognizer + self.containerButton.addGestureRecognizer(tapRecognizer) + tapRecognizer.isEnabled = false + + self.containerNode.activated = { [weak self] gesture, _ in + guard let self, let component = self.component else { + return + } + component.contextGesture?(gesture, self.extractedContainerNode) + } + + self.extractedContainerNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in + guard let self, let component = self.component else { + return + } - if highlighted { - self.layer.removeAnimation(forKey: "opacity") - self.layer.removeAnimation(forKey: "sublayerTransform") - let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut) - transition.updateTransformScale(layer: self.layer, scale: topScale) - } else { - let transition: ContainedViewLayoutTransition = .immediate - transition.updateTransformScale(layer: self.layer, scale: 1.0) - - self.layer.animateScale(from: topScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in - guard let self else { - return - } - - self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue) + if isExtracted { + let extractedBackgroundView: UIImageView + if let current = self.extractedBackgroundView { + extractedBackgroundView = current + } else { + extractedBackgroundView = UIImageView(image: generateStretchableFilledCircleImage(diameter: 28.0, color: component.theme.contextMenu.backgroundColor)) + self.extractedBackgroundView = extractedBackgroundView + self.extractedContainerNode.contentNode.view.insertSubview(extractedBackgroundView, at: 0) + extractedBackgroundView.frame = self.extractedContainerNode.contentNode.bounds.insetBy(dx: 2.0, dy: 0.0) + extractedBackgroundView.alpha = 0.0 + } + transition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 1.0) + } else if let extractedBackgroundView = self.extractedBackgroundView { + self.extractedBackgroundView = nil + let alphaTransition: ContainedViewLayoutTransition + if transition.isAnimated { + alphaTransition = .animated(duration: 0.18, curve: .easeInOut) + } else { + alphaTransition = .immediate + } + alphaTransition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 0.0, completion: { [weak extractedBackgroundView] _ in + extractedBackgroundView?.removeFromSuperview() }) } } + + self.containerNode.isGestureEnabled = false } - self.containerNode.isGestureEnabled = false - } - - required init?(coder: NSCoder) { - preconditionFailure() - } - - @objc private func pressed() { - self.action() - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - var mappedPoint = point - if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { - mappedPoint = self.bounds.center + required init?(coder: NSCoder) { + preconditionFailure() } - return super.hitTest(mappedPoint, with: event) - } - - func update(context: AccountContext, item: Item, isSelected: Bool, theme: PresentationTheme, width: CGFloat, transition: ComponentTransition) -> CGSize { - let spacing: CGFloat = 3.0 - let iconSize = CGSize(width: 30.0, height: 30.0) - var avatarIconContent: EmojiStatusComponent.Content? - if case let .forum(topicId) = item.item.id { - if topicId != 1, let threadData = item.item.threadData { - if let fileId = threadData.info.icon, fileId != 0 { - avatarIconContent = .animation(content: .customEmoji(fileId: fileId), size: iconSize, placeholderColor: theme.list.mediaPlaceholderColor, themeColor: theme.list.itemAccentColor, loopMode: .count(0)) - } else { - avatarIconContent = .topic(title: String(threadData.info.title.prefix(1)), color: threadData.info.iconColor, size: iconSize) - } - } else { - avatarIconContent = .image(image: PresentationResourcesChatList.generalTopicIcon(theme), tintColor: theme.rootController.navigationBar.secondaryTextColor) + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.component?.action?() } } - if let avatarIconContent { - let avatarIconComponent = EmojiStatusComponent( - context: context, - animationCache: context.animationCache, - animationRenderer: context.animationRenderer, - content: avatarIconContent, - isVisibleForAnimations: false, - action: nil - ) - let icon: ComponentView - if let current = self.icon { - icon = current - } else { - icon = ComponentView() - self.icon = icon + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + var mappedPoint = point + if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { + mappedPoint = self.bounds.center } - let _ = icon.update( - transition: .immediate, - component: AnyComponent(avatarIconComponent), - environment: {}, - containerSize: iconSize - ) - } else if let icon = self.icon { - self.icon = nil - icon.view?.removeFromSuperview() + return super.hitTest(mappedPoint, with: event) } - let titleText: String - if case let .forum(topicId) = item.item.id { - let _ = topicId - if let threadData = item.item.threadData { - titleText = threadData.info.title - } else { - //TODO:localize - titleText = "General" + func isReorderable(at point: CGPoint) -> Bool { + guard let component = self.component else { + return false } - } else { - titleText = item.item.renderedPeer.chatMainPeer?.compactDisplayTitle ?? " " + return component.isReordering } - if let avatarIconContent, let icon = self.icon { - let avatarIconComponent = EmojiStatusComponent( - context: context, - animationCache: context.animationCache, - animationRenderer: context.animationRenderer, - content: avatarIconContent, - isVisibleForAnimations: false, - action: nil - ) - let _ = icon.update( - transition: .immediate, - component: AnyComponent(avatarIconComponent), - environment: {}, - containerSize: iconSize - ) - } - - let titleSize = self.title.update( - transition: .immediate, - component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: titleText, font: Font.regular(10.0), textColor: isSelected ? theme.rootController.navigationBar.accentTextColor : theme.rootController.navigationBar.secondaryTextColor)), - horizontalAlignment: .center, - maximumNumberOfLines: 2 - )), - environment: {}, - containerSize: CGSize(width: width - 6.0 * 2.0, height: 100.0) - ) - - let contentSize: CGFloat = iconSize.height + spacing + titleSize.height - let size = CGSize(width: width, height: contentSize) - - let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: 0.0), size: iconSize) - let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: iconFrame.maxY + spacing), size: titleSize) - - if let icon = self.icon { - if let avatarNode = self.avatarNode { - self.avatarNode = nil - avatarNode.view.removeFromSuperview() + private func updateIsShaking(animated: Bool) { + guard let component = self.component else { + return } - if let iconView = icon.view { - if iconView.superview == nil { - iconView.isUserInteractionEnabled = false - self.containerButton.addSubview(iconView) + if component.isReordering { + if self.layer.animation(forKey: "shaking_position") == nil { + let degreesToRadians: (_ x: CGFloat) -> CGFloat = { x in + return .pi * x / 180.0 + } + + let duration: Double = 0.4 + let displacement: CGFloat = 1.0 + let degreesRotation: CGFloat = 2.0 + + let negativeDisplacement = -1.0 * displacement + let position = CAKeyframeAnimation.init(keyPath: "position") + position.beginTime = 0.8 + position.duration = duration + position.values = [ + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)), + NSValue(cgPoint: CGPoint(x: 0, y: 0)), + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)), + NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)), + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)) + ] + position.calculationMode = .linear + position.isRemovedOnCompletion = false + position.repeatCount = Float.greatestFiniteMagnitude + position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) + position.isAdditive = true + + let transform = CAKeyframeAnimation.init(keyPath: "transform") + transform.beginTime = 2.6 + transform.duration = 0.3 + transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ) + transform.values = [ + degreesToRadians(-1.0 * degreesRotation), + degreesToRadians(degreesRotation), + degreesToRadians(-1.0 * degreesRotation) + ] + transform.calculationMode = .linear + transform.isRemovedOnCompletion = false + transform.repeatCount = Float.greatestFiniteMagnitude + transform.isAdditive = true + transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) + + self.layer.add(position, forKey: "shaking_position") + self.layer.add(transform, forKey: "shaking_rotation") } - iconView.frame = iconFrame + } else if self.layer.animation(forKey: "shaking_position") != nil { + if let presentationLayer = self.layer.presentation() { + let transition: ComponentTransition = .easeInOut(duration: 0.1) + if presentationLayer.position != self.layer.position { + transition.animatePosition(layer: self.layer, from: CGPoint(x: presentationLayer.position.x - self.layer.position.x, y: presentationLayer.position.y - self.layer.position.y), to: CGPoint(), additive: true) + } + if !CATransform3DIsIdentity(presentationLayer.transform) { + transition.setTransform(layer: self.layer, transform: CATransform3DIdentity) + } + } + + self.layer.removeAnimation(forKey: "shaking_position") + self.layer.removeAnimation(forKey: "shaking_rotation") } - } else { - let avatarNode: AvatarNode - if let current = self.avatarNode { - avatarNode = current - } else { - avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 11.0)) - avatarNode.isUserInteractionEnabled = false - self.avatarNode = avatarNode - self.containerButton.addSubview(avatarNode.view) - } - avatarNode.frame = iconFrame - avatarNode.updateSize(size: iconFrame.size) + } + + func update(component: VerticalItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component - if let peer = item.item.renderedPeer.chatMainPeer { - if peer.smallProfileImage != nil { - avatarNode.setPeerV2(context: context, theme: theme, peer: peer, overrideImage: nil, emptyColor: .gray, clipStyle: .round, synchronousLoad: false, displayDimensions: iconFrame.size) + self.tapRecognizer?.isEnabled = component.action != nil + + self.containerNode.isGestureEnabled = component.contextGesture != nil + self.containerNode.activated = { [weak self] gesture, _ in + guard let self, let component = self.component else { + return + } + component.contextGesture?(gesture, self.extractedContainerNode) + } + + let topInset: CGFloat = 8.0 + let bottomInset: CGFloat = 8.0 + let spacing: CGFloat = 3.0 + let iconSize = CGSize(width: 30.0, height: 30.0) + + var avatarIconContent: EmojiStatusComponent.Content? + if case let .forum(topicId) = component.item.item.id { + if topicId != 1, let threadData = component.item.item.threadData { + if let fileId = threadData.info.icon, fileId != 0 { + avatarIconContent = .animation(content: .customEmoji(fileId: fileId), size: iconSize, placeholderColor: component.theme.list.mediaPlaceholderColor, themeColor: component.theme.list.itemAccentColor, loopMode: .count(0)) + } else { + avatarIconContent = .topic(title: String(threadData.info.title.prefix(1)), color: threadData.info.iconColor, size: iconSize) + } } else { - avatarNode.setPeer(context: context, theme: theme, peer: peer, overrideImage: nil, emptyColor: .gray, clipStyle: .round, synchronousLoad: false, displayDimensions: iconFrame.size) + avatarIconContent = .image(image: PresentationResourcesChatList.generalTopicIcon(component.theme), tintColor: component.theme.rootController.navigationBar.secondaryTextColor) } } - } - - if let titleView = self.title.view { - if titleView.superview == nil { - titleView.isUserInteractionEnabled = false - self.containerButton.addSubview(titleView) + + if let avatarIconContent { + let avatarIconComponent = EmojiStatusComponent( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + content: avatarIconContent, + isVisibleForAnimations: false, + action: nil + ) + let icon: ComponentView + if let current = self.icon { + icon = current + } else { + icon = ComponentView() + self.icon = icon + } + let _ = icon.update( + transition: .immediate, + component: AnyComponent(avatarIconComponent), + environment: {}, + containerSize: iconSize + ) + } else if let icon = self.icon { + self.icon = nil + icon.view?.removeFromSuperview() } - titleView.frame = titleFrame + + let titleText: String + if case let .forum(topicId) = component.item.item.id { + let _ = topicId + if let threadData = component.item.item.threadData { + titleText = threadData.info.title + } else { + //TODO:localize + titleText = "General" + } + } else { + titleText = component.item.item.renderedPeer.chatMainPeer?.compactDisplayTitle ?? " " + } + + if let avatarIconContent, let icon = self.icon { + let avatarIconComponent = EmojiStatusComponent( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + content: avatarIconContent, + isVisibleForAnimations: false, + action: nil + ) + let _ = icon.update( + transition: .immediate, + component: AnyComponent(avatarIconComponent), + environment: {}, + containerSize: iconSize + ) + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleText, font: Font.regular(10.0), textColor: component.isSelected ? component.theme.rootController.navigationBar.accentTextColor : component.theme.rootController.navigationBar.secondaryTextColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 2 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 6.0 * 2.0, height: 100.0) + ) + + let contentSize: CGFloat = topInset + bottomInset + iconSize.height + spacing + titleSize.height + let size = CGSize(width: availableSize.width, height: contentSize) + + let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: topInset), size: iconSize) + let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: iconFrame.maxY + spacing), size: titleSize) + + self.iconContainer.frame = iconFrame + + if let icon = self.icon { + if let avatarNode = self.avatarNode { + self.avatarNode = nil + avatarNode.view.removeFromSuperview() + } + + if let iconView = icon.view { + if iconView.superview == nil { + iconView.isUserInteractionEnabled = false + self.iconContainer.contentView.addSubview(iconView) + } + iconView.frame = CGRect(origin: CGPoint(), size: iconFrame.size) + } + } else { + let avatarNode: AvatarNode + if let current = self.avatarNode { + avatarNode = current + } else { + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 11.0)) + avatarNode.isUserInteractionEnabled = false + self.avatarNode = avatarNode + self.iconContainer.contentView.addSubview(avatarNode.view) + } + avatarNode.frame = CGRect(origin: CGPoint(), size: iconFrame.size) + avatarNode.updateSize(size: iconFrame.size) + + if let peer = component.item.item.renderedPeer.chatMainPeer { + if peer.smallProfileImage != nil { + avatarNode.setPeerV2(context: component.context, theme: component.theme, peer: peer, overrideImage: nil, emptyColor: .gray, clipStyle: .round, synchronousLoad: false, displayDimensions: iconFrame.size) + } else { + avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, overrideImage: nil, emptyColor: .gray, clipStyle: .round, synchronousLoad: false, displayDimensions: iconFrame.size) + } + } + } + + var iconMaskItems: [MaskedContainerView.Item] = [] + if let readCounters = component.item.item.readCounters, readCounters.count > 0 { + let badge: ComponentView + var badgeTransition = transition + if let current = self.badge { + badge = current + } else { + badgeTransition = .immediate + badge = ComponentView() + self.badge = badge + } + + let badgeSize = badge.update( + transition: badgeTransition, + component: AnyComponent(TextBadgeComponent( + text: countString(Int64(readCounters.count)), + font: Font.medium(12.0), + background: component.item.item.isMuted ? component.theme.chatList.unreadBadgeInactiveBackgroundColor : component.theme.chatList.unreadBadgeActiveBackgroundColor, + foreground: component.item.item.isMuted ? component.theme.chatList.unreadBadgeInactiveTextColor : component.theme.chatList.unreadBadgeActiveTextColor, + insets: UIEdgeInsets(top: 1.0, left: 5.0, bottom: 2.0, right: 5.0) + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + let badgeFrame = CGRect(origin: CGPoint(x: iconFrame.maxX + 10.0 - badgeSize.width, y: iconFrame.minY - 6.0), size: badgeSize) + if let badgeView = badge.view { + if badgeView.superview == nil { + self.containerButton.addSubview(badgeView) + } + badgeView.frame = badgeFrame + } + let badgeMaskFrame = badgeFrame.offsetBy(dx: -iconFrame.minX, dy: -iconFrame.minY).insetBy(dx: -1.33, dy: -1.33) + iconMaskItems.append(MaskedContainerView.Item( + frame: badgeMaskFrame, + shape: .roundedRect(cornerRadius: badgeMaskFrame.height * 0.5) + )) + } else if let badge = self.badge { + self.badge = nil + badge.view?.removeFromSuperview() + } + self.iconContainer.update(size: iconFrame.size, items: iconMaskItems, isInverted: true) + self.iconContainer.frame = iconFrame + + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + } + + transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) + + self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size) + self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size) + self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)) + self.containerNode.frame = CGRect(origin: CGPoint(), size: size) + + self.updateIsShaking(animated: !transition.animation.isImmediate) + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } + } + + private final class HorizontalItemComponent: Component, ItemComponent { + let context: AccountContext + let item: Item + let isSelected: Bool + let isReordering: Bool + let theme: PresentationTheme + let action: (() -> Void)? + let contextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)? + + init(context: AccountContext, item: Item, isSelected: Bool, isReordering: Bool, theme: PresentationTheme, strings: PresentationStrings, action: (() -> Void)?, contextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)?) { + self.context = context + self.item = item + self.isSelected = isSelected + self.isReordering = isReordering + self.theme = theme + self.action = action + self.contextGesture = contextGesture + } + + static func ==(lhs: HorizontalItemComponent, rhs: HorizontalItemComponent) -> Bool { + if lhs === rhs { + return true + } + if lhs.context !== rhs.context { + return false + } + if lhs.item != rhs.item { + return false + } + if lhs.isSelected != rhs.isSelected { + return false + } + if lhs.isReordering != rhs.isReordering { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false + } + if (lhs.contextGesture == nil) != (rhs.contextGesture == nil) { + return false + } + return true + } + + final class View: UIView, AsyncListComponent.ItemView { + private let extractedContainerNode: ContextExtractedContentContainingNode + private let containerNode: ContextControllerSourceNode + + private let containerButton: UIView + private var extractedBackgroundView: UIImageView? + + private var tapRecognizer: UITapGestureRecognizer? + + private var icon: ComponentView? + private var avatarNode: AvatarNode? + private let title = ComponentView() + private var badge: ComponentView? + + private var component: HorizontalItemComponent? + + override init(frame: CGRect) { + self.extractedContainerNode = ContextExtractedContentContainingNode() + self.containerNode = ContextControllerSourceNode() + + self.containerButton = UIView() + + super.init(frame: frame) + + self.extractedContainerNode.contentNode.view.addSubview(self.containerButton) + + self.containerNode.addSubnode(self.extractedContainerNode) + self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode + self.addSubview(self.containerNode.view) + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.tapRecognizer = tapRecognizer + self.containerButton.addGestureRecognizer(tapRecognizer) + tapRecognizer.isEnabled = false + + self.containerNode.activated = { [weak self] gesture, _ in + guard let self, let component = self.component else { + return + } + component.contextGesture?(gesture, self.extractedContainerNode) + } + + self.extractedContainerNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in + guard let self, let component = self.component else { + return + } + + if isExtracted { + let extractedBackgroundView: UIImageView + if let current = self.extractedBackgroundView { + extractedBackgroundView = current + } else { + extractedBackgroundView = UIImageView(image: generateStretchableFilledCircleImage(diameter: 28.0, color: component.theme.contextMenu.backgroundColor)) + self.extractedBackgroundView = extractedBackgroundView + self.extractedContainerNode.contentNode.view.insertSubview(extractedBackgroundView, at: 0) + extractedBackgroundView.frame = self.extractedContainerNode.contentNode.bounds.insetBy(dx: 2.0, dy: 0.0) + extractedBackgroundView.alpha = 0.0 + } + transition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 1.0) + } else if let extractedBackgroundView = self.extractedBackgroundView { + self.extractedBackgroundView = nil + let alphaTransition: ContainedViewLayoutTransition + if transition.isAnimated { + alphaTransition = .animated(duration: 0.18, curve: .easeInOut) + } else { + alphaTransition = .immediate + } + alphaTransition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 0.0, completion: { [weak extractedBackgroundView] _ in + extractedBackgroundView?.removeFromSuperview() + }) + } + } + + self.containerNode.isGestureEnabled = false } - transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) + required init?(coder: NSCoder) { + preconditionFailure() + } - self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size) - self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size) - self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)) - self.containerNode.frame = CGRect(origin: CGPoint(), size: size) + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.component?.action?() + } + } - return size + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + var mappedPoint = point + if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { + mappedPoint = self.bounds.center + } + return super.hitTest(mappedPoint, with: event) + } + + func isReorderable(at point: CGPoint) -> Bool { + guard let component = self.component else { + return false + } + return component.isReordering + } + + private func updateIsShaking(animated: Bool) { + guard let component = self.component else { + return + } + + if component.isReordering { + if self.layer.animation(forKey: "shaking_position") == nil { + let degreesToRadians: (_ x: CGFloat) -> CGFloat = { x in + return .pi * x / 180.0 + } + + let duration: Double = 0.4 + let displacement: CGFloat = 1.0 + let degreesRotation: CGFloat = 2.0 + + let negativeDisplacement = -1.0 * displacement + let position = CAKeyframeAnimation.init(keyPath: "position") + position.beginTime = 0.8 + position.duration = duration + position.values = [ + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)), + NSValue(cgPoint: CGPoint(x: 0, y: 0)), + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)), + NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)), + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)) + ] + position.calculationMode = .linear + position.isRemovedOnCompletion = false + position.repeatCount = Float.greatestFiniteMagnitude + position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) + position.isAdditive = true + + let transform = CAKeyframeAnimation.init(keyPath: "transform") + transform.beginTime = 2.6 + transform.duration = 0.3 + transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ) + transform.values = [ + degreesToRadians(-1.0 * degreesRotation), + degreesToRadians(degreesRotation), + degreesToRadians(-1.0 * degreesRotation) + ] + transform.calculationMode = .linear + transform.isRemovedOnCompletion = false + transform.repeatCount = Float.greatestFiniteMagnitude + transform.isAdditive = true + transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) + + self.layer.add(position, forKey: "shaking_position") + self.layer.add(transform, forKey: "shaking_rotation") + } + } else if self.layer.animation(forKey: "shaking_position") != nil { + if let presentationLayer = self.layer.presentation() { + let transition: ComponentTransition = .easeInOut(duration: 0.1) + if presentationLayer.position != self.layer.position { + transition.animatePosition(layer: self.layer, from: CGPoint(x: presentationLayer.position.x - self.layer.position.x, y: presentationLayer.position.y - self.layer.position.y), to: CGPoint(), additive: true) + } + if !CATransform3DIsIdentity(presentationLayer.transform) { + transition.setTransform(layer: self.layer, transform: CATransform3DIdentity) + } + } + + self.layer.removeAnimation(forKey: "shaking_position") + self.layer.removeAnimation(forKey: "shaking_rotation") + } + } + + func update(component: HorizontalItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + + self.tapRecognizer?.isEnabled = component.action != nil + + self.containerNode.isGestureEnabled = component.contextGesture != nil + self.containerNode.activated = { [weak self] gesture, _ in + guard let self, let component = self.component else { + return + } + component.contextGesture?(gesture, self.extractedContainerNode) + } + + let leftInset: CGFloat = 12.0 + let rightInset: CGFloat = 12.0 + let spacing: CGFloat = 4.0 + let badgeSpacing: CGFloat = 4.0 + let iconSize = CGSize(width: 18.0, height: 18.0) + + var avatarIconContent: EmojiStatusComponent.Content? + if case let .forum(topicId) = component.item.item.id { + if topicId != 1, let threadData = component.item.item.threadData { + if let fileId = threadData.info.icon, fileId != 0 { + avatarIconContent = .animation(content: .customEmoji(fileId: fileId), size: iconSize, placeholderColor: component.theme.list.mediaPlaceholderColor, themeColor: component.theme.list.itemAccentColor, loopMode: .count(0)) + } else { + avatarIconContent = .topic(title: String(threadData.info.title.prefix(1)), color: threadData.info.iconColor, size: iconSize) + } + } else { + avatarIconContent = .image(image: PresentationResourcesChatList.generalTopicIcon(component.theme), tintColor: component.theme.rootController.navigationBar.secondaryTextColor) + } + } + + if let avatarIconContent { + let avatarIconComponent = EmojiStatusComponent( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + content: avatarIconContent, + isVisibleForAnimations: false, + action: nil + ) + let icon: ComponentView + if let current = self.icon { + icon = current + } else { + icon = ComponentView() + self.icon = icon + } + let _ = icon.update( + transition: .immediate, + component: AnyComponent(avatarIconComponent), + environment: {}, + containerSize: iconSize + ) + } else if let icon = self.icon { + self.icon = nil + icon.view?.removeFromSuperview() + } + + let titleText: String + if case let .forum(topicId) = component.item.item.id { + let _ = topicId + if let threadData = component.item.item.threadData { + titleText = threadData.info.title + } else { + //TODO:localize + titleText = "General" + } + } else { + titleText = component.item.item.renderedPeer.chatMainPeer?.compactDisplayTitle ?? " " + } + + if let avatarIconContent, let icon = self.icon { + let avatarIconComponent = EmojiStatusComponent( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + content: avatarIconContent, + isVisibleForAnimations: false, + action: nil + ) + let _ = icon.update( + transition: .immediate, + component: AnyComponent(avatarIconComponent), + environment: {}, + containerSize: iconSize + ) + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleText, font: Font.medium(14.0), textColor: component.isSelected ? component.theme.rootController.navigationBar.accentTextColor : component.theme.rootController.navigationBar.secondaryTextColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 2 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 6.0 * 2.0, height: 100.0) + ) + + var badgeSize: CGSize? + if let readCounters = component.item.item.readCounters, readCounters.count > 0 { + let badge: ComponentView + var badgeTransition = transition + if let current = self.badge { + badge = current + } else { + badgeTransition = .immediate + badge = ComponentView() + self.badge = badge + } + + badgeSize = badge.update( + transition: badgeTransition, + component: AnyComponent(TextBadgeComponent( + text: countString(Int64(readCounters.count)), + font: Font.medium(12.0), + background: component.item.item.isMuted ? component.theme.chatList.unreadBadgeInactiveBackgroundColor : component.theme.chatList.unreadBadgeActiveBackgroundColor, + foreground: component.item.item.isMuted ? component.theme.chatList.unreadBadgeInactiveTextColor : component.theme.chatList.unreadBadgeActiveTextColor, + insets: UIEdgeInsets(top: 1.0, left: 5.0, bottom: 2.0, right: 5.0) + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + } else if let badge = self.badge { + self.badge = nil + badge.view?.removeFromSuperview() + } + + var contentSize: CGFloat = leftInset + rightInset + iconSize.width + spacing + titleSize.width + if let badgeSize { + contentSize += badgeSize.width + badgeSpacing + } + let size = CGSize(width: contentSize, height: availableSize.height) + + let iconFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - iconSize.height) * 0.5)), size: iconSize) + let titleFrame = CGRect(origin: CGPoint(x: iconFrame.maxX + spacing, y: floor((size.height - titleSize.height) * 0.5)), size: titleSize) + + if let icon = self.icon { + if let avatarNode = self.avatarNode { + self.avatarNode = nil + avatarNode.view.removeFromSuperview() + } + + if let iconView = icon.view { + if iconView.superview == nil { + iconView.isUserInteractionEnabled = false + self.containerButton.addSubview(iconView) + } + iconView.frame = iconFrame + } + } else { + let avatarNode: AvatarNode + if let current = self.avatarNode { + avatarNode = current + } else { + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 11.0)) + avatarNode.isUserInteractionEnabled = false + self.avatarNode = avatarNode + self.containerButton.addSubview(avatarNode.view) + } + avatarNode.frame = iconFrame + avatarNode.updateSize(size: iconFrame.size) + + if let peer = component.item.item.renderedPeer.chatMainPeer { + if peer.smallProfileImage != nil { + avatarNode.setPeerV2(context: component.context, theme: component.theme, peer: peer, overrideImage: nil, emptyColor: .gray, clipStyle: .round, synchronousLoad: false, displayDimensions: iconFrame.size) + } else { + avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, overrideImage: nil, emptyColor: .gray, clipStyle: .round, synchronousLoad: false, displayDimensions: iconFrame.size) + } + } + } + + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + } + + if let badge = self.badge, let badgeSize { + let badgeFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + badgeSpacing, y: floor((size.height - badgeSize.height) * 0.5)), size: badgeSize) + if let badgeView = badge.view { + if badgeView.superview == nil { + self.containerButton.addSubview(badgeView) + } + badgeView.frame = badgeFrame + } + } + + transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) + + self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size) + self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size) + self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)) + self.containerNode.frame = CGRect(origin: CGPoint(), size: size) + + self.updateIsShaking(animated: !transition.animation.isImmediate) + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } @@ -337,7 +1004,9 @@ public final class ChatSideTopicsPanel: Component { private let containerButton: HighlightTrackingButton - private let icon = ComponentView() + private var icon = ComponentView() + + private var isReordering: Bool = false init(context: AccountContext, action: @escaping (() -> Void)) { self.context = context @@ -394,20 +1063,31 @@ public final class ChatSideTopicsPanel: Component { } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - var mappedPoint = point - if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { - mappedPoint = self.bounds.center - } - return super.hitTest(mappedPoint, with: event) + return super.hitTest(point, with: event) } - func update(context: AccountContext, theme: PresentationTheme, width: CGFloat, transition: ComponentTransition) -> CGSize { + func update(context: AccountContext, theme: PresentationTheme, width: CGFloat, location: Location, isReordering: Bool, transition: ComponentTransition) -> CGSize { + let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2) + + var animateIconIn = false + if self.isReordering != isReordering { + self.isReordering = isReordering + if let iconView = self.icon.view { + self.icon = ComponentView() + transition.setScale(view: iconView, scale: 0.001) + alphaTransition.setAlpha(view: iconView, alpha: 0.0, completion: { [weak iconView] _ in + iconView?.removeFromSuperview() + }) + animateIconIn = true + } + } + let iconSize = self.icon.update( transition: .immediate, component: AnyComponent(BundleIconComponent( - name: "Chat/Title Panels/SidebarIcon", - tintColor: theme.rootController.navigationBar.accentTextColor, - maxSize: nil, + name: isReordering ? "Media Editor/Done" : "Chat/Title Panels/SidebarIcon", + tintColor: location == .side ? theme.rootController.navigationBar.accentTextColor : theme.rootController.navigationBar.secondaryTextColor, + maxSize: CGSize(width: 24.0, height: 24.0), scaleFactor: 1.0 )), environment: {}, @@ -415,7 +1095,7 @@ public final class ChatSideTopicsPanel: Component { ) let topInset: CGFloat = 10.0 - let bottomInset: CGFloat = 12.0 + let bottomInset: CGFloat = 2.0 let contentSize: CGFloat = topInset + iconSize.height + bottomInset let size = CGSize(width: width, height: contentSize) @@ -428,6 +1108,10 @@ public final class ChatSideTopicsPanel: Component { self.containerButton.addSubview(iconView) } iconView.frame = iconFrame + if animateIconIn { + alphaTransition.animateAlpha(view: iconView, from: 0.0, to: 1.0) + transition.animateScale(view: iconView, from: 0.001, to: 1.0) + } } transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) @@ -441,206 +1125,330 @@ public final class ChatSideTopicsPanel: Component { } } - private final class AllItemView: UIView { - private let context: AccountContext - private let action: () -> Void + private protocol AllItemComponent: AnyObject { + } + + private final class VerticalAllItemComponent: Component, AllItemComponent { + let isSelected: Bool + let theme: PresentationTheme + let strings: PresentationStrings + let action: (() -> Void)? - private let extractedContainerNode: ContextExtractedContentContainingNode - private let containerNode: ContextControllerSourceNode - - private let containerButton: HighlightTrackingButton - - private let icon = ComponentView() - private let title = ComponentView() - - init(context: AccountContext, action: @escaping (() -> Void)) { - self.context = context + init(isSelected: Bool, theme: PresentationTheme, strings: PresentationStrings, action: (() -> Void)?) { + self.isSelected = isSelected + self.theme = theme + self.strings = strings self.action = action - - self.extractedContainerNode = ContextExtractedContentContainingNode() - self.containerNode = ContextControllerSourceNode() - - self.containerButton = HighlightTrackingButton() - - super.init(frame: CGRect()) - - self.extractedContainerNode.contentNode.view.addSubview(self.containerButton) - - self.containerNode.addSubnode(self.extractedContainerNode) - self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode - self.addSubview(self.containerNode.view) - - self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) - self.containerButton.highligthedChanged = { [weak self] highlighted in - if let self, self.bounds.width > 0.0 { - let topScale: CGFloat = (self.bounds.width - 1.0) / self.bounds.width - let maxScale: CGFloat = (self.bounds.width + 1.0) / self.bounds.width - - if highlighted { - self.layer.removeAnimation(forKey: "opacity") - self.layer.removeAnimation(forKey: "sublayerTransform") - let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut) - transition.updateTransformScale(layer: self.layer, scale: topScale) - } else { - let transition: ContainedViewLayoutTransition = .immediate - transition.updateTransformScale(layer: self.layer, scale: 1.0) - - self.layer.animateScale(from: topScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in - guard let self else { - return - } - - self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue) - }) - } - } + } + + static func ==(lhs: VerticalAllItemComponent, rhs: VerticalAllItemComponent) -> Bool { + if lhs === rhs { + return true } - - self.containerNode.isGestureEnabled = false - } - - required init?(coder: NSCoder) { - preconditionFailure() - } - - @objc private func pressed() { - self.action() - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - var mappedPoint = point - if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { - mappedPoint = self.bounds.center + if lhs.isSelected != rhs.isSelected { + return false } - return super.hitTest(mappedPoint, with: event) - } - - func update(context: AccountContext, isSelected: Bool, theme: PresentationTheme, width: CGFloat, transition: ComponentTransition) -> CGSize { - let spacing: CGFloat = 3.0 - - let iconSize = self.icon.update( - transition: .immediate, - component: AnyComponent(BundleIconComponent( - name: "Chat List/Tabs/IconChats", - tintColor: isSelected ? theme.rootController.navigationBar.accentTextColor : theme.rootController.navigationBar.secondaryTextColor - )), - environment: {}, - containerSize: CGSize(width: 100.0, height: 100.0) - ) - - //TODO:localize - let titleText: String = "All" - let titleSize = self.title.update( - transition: .immediate, - component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: titleText, font: Font.regular(10.0), textColor: isSelected ? theme.rootController.navigationBar.accentTextColor : theme.rootController.navigationBar.secondaryTextColor)), - maximumNumberOfLines: 2 - )), - environment: {}, - containerSize: CGSize(width: width - 4.0 * 2.0, height: 100.0) - ) - - let contentSize: CGFloat = iconSize.height + spacing + titleSize.height - let size = CGSize(width: width, height: contentSize) - - let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: 0.0), size: iconSize) - let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: iconFrame.maxY + spacing), size: titleSize) - - if let iconView = self.icon.view { - if iconView.superview == nil { - iconView.isUserInteractionEnabled = false - self.containerButton.addSubview(iconView) - } - iconView.frame = iconFrame + if lhs.theme !== rhs.theme { + return false } - - if let titleView = self.title.view { - if titleView.superview == nil { - titleView.isUserInteractionEnabled = false - self.containerButton.addSubview(titleView) - } - titleView.frame = titleFrame + if lhs.strings !== rhs.strings { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false } - - transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) - - self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size) - self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size) - self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)) - self.containerNode.frame = CGRect(origin: CGPoint(), size: size) - - return size - } - } - - private final class ScrollView: UIScrollView { - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - return super.hitTest(point, with: event) - } - - override func touchesShouldCancel(in view: UIView) -> Bool { return true } + + final class View: UIView { + private let containerButton: UIView + + private let icon = ComponentView() + private let title = ComponentView() + + private var tapRecognizer: UITapGestureRecognizer? + + private var component: VerticalAllItemComponent? + + override init(frame: CGRect) { + self.containerButton = UIView() + + super.init(frame: frame) + + self.addSubview(self.containerButton) + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.tapRecognizer = tapRecognizer + self.containerButton.addGestureRecognizer(tapRecognizer) + tapRecognizer.isEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.component?.action?() + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + var mappedPoint = point + if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { + mappedPoint = self.bounds.center + } + return super.hitTest(mappedPoint, with: event) + } + + func update(component: VerticalAllItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + + self.tapRecognizer?.isEnabled = component.action != nil + + let topInset: CGFloat = 6.0 + let bottomInset: CGFloat = 8.0 + + let spacing: CGFloat = 1.0 + + let iconSize = self.icon.update( + transition: .immediate, + component: AnyComponent(BundleIconComponent( + name: "Chat List/Tabs/IconChats", + tintColor: component.isSelected ? component.theme.rootController.navigationBar.accentTextColor : component.theme.rootController.navigationBar.secondaryTextColor + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + + //TODO:localize + let titleText: String = "All" + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleText, font: Font.regular(10.0), textColor: component.isSelected ? component.theme.rootController.navigationBar.accentTextColor : component.theme.rootController.navigationBar.secondaryTextColor)), + maximumNumberOfLines: 2 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 4.0 * 2.0, height: 100.0) + ) + + let contentSize: CGFloat = topInset + bottomInset + iconSize.height + spacing + titleSize.height + let size = CGSize(width: availableSize.width, height: contentSize) + + let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: topInset), size: iconSize) + let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: iconFrame.maxY + spacing), size: titleSize) + + if let iconView = self.icon.view { + if iconView.superview == nil { + iconView.isUserInteractionEnabled = false + self.containerButton.addSubview(iconView) + } + iconView.frame = iconFrame + } + + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + } + + transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } } - private enum ScrollId: Equatable { + private final class HorizontalAllItemComponent: Component, AllItemComponent { + let isSelected: Bool + let theme: PresentationTheme + let strings: PresentationStrings + let action: (() -> Void)? + + init(isSelected: Bool, theme: PresentationTheme, strings: PresentationStrings, action: (() -> Void)?) { + self.isSelected = isSelected + self.theme = theme + self.strings = strings + self.action = action + } + + static func ==(lhs: HorizontalAllItemComponent, rhs: HorizontalAllItemComponent) -> Bool { + if lhs === rhs { + return true + } + if lhs.isSelected != rhs.isSelected { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false + } + return true + } + + final class View: UIView { + private let containerButton: UIView + + private let title = ComponentView() + + private var tapRecognizer: UITapGestureRecognizer? + + private var component: HorizontalAllItemComponent? + + override init(frame: CGRect) { + self.containerButton = UIView() + + super.init(frame: frame) + + self.addSubview(self.containerButton) + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.tapRecognizer = tapRecognizer + self.containerButton.addGestureRecognizer(tapRecognizer) + tapRecognizer.isEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.component?.action?() + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + var mappedPoint = point + if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { + mappedPoint = self.bounds.center + } + return super.hitTest(mappedPoint, with: event) + } + + func update(component: HorizontalAllItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + + self.tapRecognizer?.isEnabled = component.action != nil + + let leftInset: CGFloat = 6.0 + let rightInset: CGFloat = 12.0 + + //TODO:localize + let titleText: String = "All" + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleText, font: Font.medium(14.0), textColor: component.isSelected ? component.theme.rootController.navigationBar.accentTextColor : component.theme.rootController.navigationBar.secondaryTextColor)), + maximumNumberOfLines: 2 + )), + environment: {}, + containerSize: CGSize(width: 200.0, height: 200.0) + ) + + let contentSize: CGFloat = leftInset + rightInset + titleSize.height + let size = CGSize(width: contentSize, height: availableSize.height) + + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - titleSize.height) * 0.5)), size: titleSize) + + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + } + + transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } + } + + private enum ScrollId: Hashable { case all case topic(Int64) } public final class View: UIView { - private let scrollView: ScrollView + private let list = ComponentView() + private let listState = AsyncListComponent.ExternalState() private let scrollContainerView: UIView private let scrollViewMask: UIImageView - private let background = ComponentView() - private let separatorLayer: SimpleLayer - private let selectedLineView: UIImageView + private var background: ComponentView? + private var separatorLayer: SimpleLayer? + + private let selectedLineContainer: AsyncListComponent.OverlayContainerView + private let selectedLineView: UIImageView + private let pinnedBackgroundContainer: AsyncListComponent.OverlayContainerView + private let pinnedBackgroundView: UIImageView + private let pinnedIconView: UIImageView - private var items: [Item] = [] - private var itemViews: [Item.Id: ItemView] = [:] - private var allItemView: AllItemView? private var tabItemView: TabItemView? + private var rawItems: [Item] = [] + private var reorderingItems: [Item]? + private var resetReorderingOnNextUpdate: Bool = false + private var itemsContentVersion: Int = 0 + + private var isTogglingPinnedItem: Bool = false + private weak var dismissContextControllerOnNextUpdate: ContextController? + private var component: ChatSideTopicsPanel? private weak var state: EmptyComponentState? private var isUpdating: Bool = false private var appliedScrollToId: ScrollId? + private var isReordering: Bool = false private var itemsDisposable: Disposable? override public init(frame: CGRect) { self.selectedLineView = UIImageView() - self.scrollView = ScrollView(frame: CGRect()) + self.selectedLineView.isHidden = true + self.selectedLineContainer = AsyncListComponent.OverlayContainerView() + self.selectedLineContainer.addSubview(self.selectedLineView) + + self.pinnedIconView = UIImageView() + self.pinnedBackgroundView = UIImageView() + self.pinnedBackgroundContainer = AsyncListComponent.OverlayContainerView() + self.pinnedBackgroundContainer.addSubview(self.pinnedIconView) + self.pinnedBackgroundContainer.addSubview(self.pinnedBackgroundView) + self.pinnedBackgroundContainer.isHidden = true self.scrollContainerView = UIView() - self.scrollViewMask = UIImageView(image: generateGradientImage(size: CGSize(width: 8.0, height: 8.0), colors: [ - UIColor(white: 1.0, alpha: 0.0), - UIColor(white: 1.0, alpha: 1.0) - ], locations: [0.0, 1.0], direction: .vertical)?.stretchableImage(withLeftCapWidth: 0, topCapHeight: 8)) + self.scrollViewMask = UIImageView() self.scrollContainerView.mask = self.scrollViewMask - self.separatorLayer = SimpleLayer() - super.init(frame: frame) - self.scrollView.delaysContentTouches = false - self.scrollView.canCancelContentTouches = true - self.scrollView.clipsToBounds = true - self.scrollView.contentInsetAdjustmentBehavior = .never - if #available(iOS 13.0, *) { - self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false - } - self.scrollView.showsVerticalScrollIndicator = false - self.scrollView.showsHorizontalScrollIndicator = false - self.scrollView.alwaysBounceHorizontal = false - self.scrollView.alwaysBounceVertical = false - self.scrollView.scrollsToTop = false - self.addSubview(self.scrollContainerView) - self.scrollContainerView.addSubview(self.scrollView) - self.scrollView.addSubview(self.selectedLineView) + self.scrollContainerView.addSubview(self.pinnedBackgroundContainer) + self.scrollContainerView.addSubview(self.selectedLineContainer) } required public init?(coder: NSCoder) { @@ -652,14 +1460,22 @@ public final class ChatSideTopicsPanel: Component { } public func updateGlobalOffset(globalOffset: CGFloat, transition: ComponentTransition) { + guard let component = self.component else { + return + } if let tabItemView = self.tabItemView { - transition.setTransform(view: tabItemView, transform: CATransform3DMakeTranslation(-globalOffset, 0.0, 0.0)) + switch component.location { + case .side: + transition.setTransform(view: tabItemView, transform: CATransform3DMakeTranslation(-globalOffset, 0.0, 0.0)) + case .top: + transition.setTransform(view: tabItemView, transform: CATransform3DMakeTranslation(0.0, -globalOffset, 0.0)) + } } } public func topicIndex(threadId: Int64?) -> Int? { if let threadId { - if let value = self.items.firstIndex(where: { item in + if let value = self.rawItems.firstIndex(where: { item in if item.id == .chatList(PeerId(threadId)) { return true } else if item.id == .forum(threadId) { @@ -677,6 +1493,139 @@ public final class ChatSideTopicsPanel: Component { } } + private func updateListOverlays(visibleItems: AsyncListComponent.VisibleItems, transition: ComponentTransition) { + guard let component = self.component, let listView = self.list.view else { + return + } + + var selectedItemFrame: CGRect? + var beforePinnedItemsPosition: CGFloat? + var afterPinnedItemsPosition: CGFloat? + var seenPinnedItems = false + for item in visibleItems { + if let _ = item.item.component.wrapped as? AllItemComponent { + if component.topicId == nil { + switch component.location { + case .side: + selectedItemFrame = item.frame + case .top: + selectedItemFrame = CGRect(origin: CGPoint(x: item.frame.minX + 5.0, y: item.frame.minY), size: CGSize(width: item.frame.width - 4.0 - 11.0, height: item.frame.height)) + } + } + if !seenPinnedItems { + switch component.location { + case .side: + beforePinnedItemsPosition = item.frame.maxY + case .top: + beforePinnedItemsPosition = item.frame.maxX + } + } + } else if let itemComponent = item.item.component.wrapped as? ItemComponent { + let topicId: Int64 + switch itemComponent.item.item.id { + case let .chatList(peerId): + topicId = peerId.toInt64() + case let .forum(topicIdValue): + topicId = topicIdValue + } + if topicId == component.topicId { + selectedItemFrame = item.frame + } + + var isPinned = false + if case let .forum(pinnedIndex, _, _, _, _) = itemComponent.item.item.index { + if case .index = pinnedIndex { + isPinned = true + } + } + if isPinned { + seenPinnedItems = true + } else { + if !seenPinnedItems { + switch component.location { + case .side: + beforePinnedItemsPosition = item.frame.maxY + case .top: + beforePinnedItemsPosition = item.frame.maxX + } + } else { + if afterPinnedItemsPosition == nil { + switch component.location { + case .side: + afterPinnedItemsPosition = item.frame.minY + case .top: + afterPinnedItemsPosition = item.frame.minX + } + } + } + } + } + } + + if seenPinnedItems { + if beforePinnedItemsPosition == nil { + beforePinnedItemsPosition = -500.0 + } + if afterPinnedItemsPosition == nil { + switch component.location { + case .side: + afterPinnedItemsPosition = listView.bounds.height + 500.0 + case .top: + afterPinnedItemsPosition = listView.bounds.width + 500.0 + } + } + } + + if let selectedItemFrame { + var lineTransition = transition + if self.selectedLineView.isHidden { + self.selectedLineView.isHidden = false + lineTransition = .immediate + } + let selectedLineFrame: CGRect + switch component.location { + case .side: + selectedLineFrame = CGRect(origin: CGPoint(x: 0.0, y: selectedItemFrame.minY), size: CGSize(width: 4.0, height: selectedItemFrame.height)) + case .top: + selectedLineFrame = CGRect(origin: CGPoint(x: selectedItemFrame.minX, y: listView.frame.maxY - 4.0), size: CGSize(width: selectedItemFrame.width, height: 4.0)) + } + + self.selectedLineContainer.updatePosition(position: selectedLineFrame.origin, transition: lineTransition) + lineTransition.setFrame(view: self.selectedLineView, frame: CGRect(origin: CGPoint(), size: selectedLineFrame.size)) + } else { + self.selectedLineView.isHidden = true + } + + if let beforePinnedItemsPosition, let afterPinnedItemsPosition, afterPinnedItemsPosition > beforePinnedItemsPosition { + var pinnedItemsTransition = transition + if self.pinnedBackgroundContainer.isHidden { + self.pinnedBackgroundContainer.isHidden = false + pinnedItemsTransition = .immediate + } + let pinnedItemsBackgroundFrame: CGRect + switch component.location { + case .side: + pinnedItemsBackgroundFrame = CGRect(origin: CGPoint(x: 5.0, y: beforePinnedItemsPosition), size: CGSize(width: listView.bounds.width - 5.0 - 4.0, height: afterPinnedItemsPosition - beforePinnedItemsPosition)) + case .top: + pinnedItemsBackgroundFrame = CGRect(origin: CGPoint(x: beforePinnedItemsPosition, y: 4.0), size: CGSize(width: afterPinnedItemsPosition - beforePinnedItemsPosition, height: listView.bounds.height - 5.0 - 4.0)) + } + self.pinnedBackgroundContainer.updatePosition(position: pinnedItemsBackgroundFrame.origin, transition: pinnedItemsTransition) + pinnedItemsTransition.setFrame(view: self.pinnedBackgroundView, frame: CGRect(origin: CGPoint(), size: pinnedItemsBackgroundFrame.size)) + + let pinnedIconFrame = CGRect(origin: CGPoint(x: 2.0, y: 2.0), size: CGSize(width: 12.0, height: 12.0)) + pinnedItemsTransition.setFrame(view: self.pinnedIconView, frame: pinnedIconFrame) + } else { + self.pinnedBackgroundContainer.isHidden = true + } + } + + private func updateIsReordering(isReordering: Bool) { + self.isReordering = isReordering + if !self.isUpdating { + self.state?.updated(transition: .spring(duration: 0.4)) + } + } + func update(component: ChatSideTopicsPanel, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { @@ -685,6 +1634,12 @@ public final class ChatSideTopicsPanel: Component { self.state = state + if self.resetReorderingOnNextUpdate { + self.resetReorderingOnNextUpdate = false + self.reorderingItems = nil + self.isReordering = false + } + if self.component == nil { let threadListSignal: Signal = component.context.sharedContext.subscribeChatListData(context: component.context, location: component.isMonoforum ? .savedMessagesChats(peerId: component.peerId) : .forum(peerId: component.peerId)) @@ -693,59 +1648,120 @@ public final class ChatSideTopicsPanel: Component { guard let self else { return } - self.items.removeAll() + let wasEmpty = self.rawItems.isEmpty + + self.rawItems.removeAll() for item in chatList.items.reversed() { - self.items.append(Item(item: item)) + self.rawItems.append(Item(item: item)) + } + + if self.reorderingItems != nil { + self.reorderingItems = self.rawItems } if !self.isUpdating { - self.state?.updated(transition: .immediate) + self.state?.updated(transition: (wasEmpty || self.isTogglingPinnedItem) ? .immediate : .spring(duration: 0.4)) } }) + + switch component.location { + case .side: + self.scrollViewMask.image = generateGradientImage(size: CGSize(width: 8.0, height: 8.0), colors: [ + UIColor(white: 1.0, alpha: 0.0), + UIColor(white: 1.0, alpha: 1.0) + ], locations: [0.0, 1.0], direction: .vertical)?.stretchableImage(withLeftCapWidth: 0, topCapHeight: 8) + case .top: + self.scrollViewMask.image = generateGradientImage(size: CGSize(width: 8.0, height: 8.0), colors: [ + UIColor(white: 1.0, alpha: 0.0), + UIColor(white: 1.0, alpha: 1.0) + ], locations: [0.0, 1.0], direction: .horizontal)?.stretchableImage(withLeftCapWidth: 8, topCapHeight: 0) + } } let themeUpdated = self.component?.theme !== component.theme self.component = component - let _ = self.background.update( - transition: transition, - component: AnyComponent(BlurredBackgroundComponent( - color: component.theme.rootController.navigationBar.blurredBackgroundColor - )), - environment: {}, - containerSize: availableSize - ) - self.separatorLayer.backgroundColor = component.theme.rootController.navigationBar.separatorColor.cgColor - - if let backgroundView = self.background.view { - if backgroundView.superview == nil { - self.insertSubview(backgroundView, at: 0) + if case .side = component.location { + let background: ComponentView + if let current = self.background { + background = current + } else { + background = ComponentView() + self.background = background } - transition.setFrame(view: backgroundView, frame: CGRect(origin: CGPoint(), size: availableSize)) + let _ = background.update( + transition: transition, + component: AnyComponent(BlurredBackgroundComponent( + color: component.theme.rootController.navigationBar.blurredBackgroundColor + )), + environment: {}, + containerSize: availableSize + ) + + if let backgroundView = background.view { + if backgroundView.superview == nil { + self.insertSubview(backgroundView, at: 0) + } + transition.setFrame(view: backgroundView, frame: CGRect(origin: CGPoint(), size: availableSize)) + } + + let separatorLayer: SimpleLayer + if let current = self.separatorLayer { + separatorLayer = current + } else { + separatorLayer = SimpleLayer() + self.separatorLayer = separatorLayer + self.layer.addSublayer(separatorLayer) + } + if themeUpdated { + separatorLayer.backgroundColor = component.theme.rootController.navigationBar.separatorColor.cgColor + } + + transition.setFrame(layer: separatorLayer, frame: CGRect(origin: CGPoint(x: availableSize.width, y: 0.0), size: CGSize(width: UIScreenPixel, height: availableSize.height))) } - if self.separatorLayer.superlayer == nil { - self.layer.addSublayer(self.separatorLayer) - } - transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: availableSize.width, y: 0.0), size: CGSize(width: UIScreenPixel, height: availableSize.height))) if themeUpdated { - self.selectedLineView.image = generateImage(CGSize(width: 4.0, height: 7.0), rotatedContext: { size, context in - context.clear(CGRect(origin: CGPoint(), size: size)) - context.setFillColor(component.theme.rootController.navigationBar.accentTextColor.cgColor) - context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - size.height, y: 0.0), size: CGSize(width: size.height, height: size.height))) - })?.stretchableImage(withLeftCapWidth: 1, topCapHeight: 4) + switch component.location { + case .side: + self.selectedLineView.image = generateImage(CGSize(width: 4.0, height: 7.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(component.theme.rootController.navigationBar.accentTextColor.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - size.height, y: 0.0), size: CGSize(width: size.height, height: size.height))) + })?.stretchableImage(withLeftCapWidth: 1, topCapHeight: 4) + case .top: + self.selectedLineView.image = generateImage(CGSize(width: 4.0, height: 2.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(component.theme.rootController.navigationBar.accentTextColor.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.width))) + })?.stretchableImage(withLeftCapWidth: 2, topCapHeight: 1) + } + + if self.pinnedIconView.image == nil { + self.pinnedIconView.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/Pinned"), color: .white)?.withRenderingMode(.alwaysTemplate) + } + self.pinnedIconView.tintColor = component.theme.chatList.unreadBadgeInactiveBackgroundColor + + if self.pinnedBackgroundView.image == nil { + self.pinnedBackgroundView.image = generateStretchableFilledCircleImage(diameter: 10.0, color: .white)?.withRenderingMode(.alwaysTemplate) + } + var pinnedBackgroundColor = component.theme.rootController.navigationSearchBar.inputFillColor + if pinnedBackgroundColor.distance(to: component.theme.list.blocksBackgroundColor) < 100 { + pinnedBackgroundColor = pinnedBackgroundColor.withMultipliedBrightnessBy(0.8) + } + self.pinnedBackgroundView.tintColor = pinnedBackgroundColor } - let hadItemViews = !self.itemViews.isEmpty - let environment = environment[EnvironmentType.self].value let containerInsets = environment.insets - let panelWidth: CGFloat = availableSize.width - containerInsets.left - let itemSpacing: CGFloat = 24.0 - - var topContainerInset: CGFloat = containerInsets.top + var directionContainerInset: CGFloat + switch component.location { + case .side: + directionContainerInset = containerInsets.top + case .top: + directionContainerInset = containerInsets.left + } do { var itemTransition = transition @@ -760,69 +1776,51 @@ public final class ChatSideTopicsPanel: Component { guard let self, let component = self.component else { return } - component.togglePanel() + if self.isReordering { + if let reorderingItems = self.reorderingItems { + var threadIds: [Int64] = [] + for item in reorderingItems { + if case let .forum(pinnedIndex, _, threadId, _, _) = item.item.index, case .index = pinnedIndex { + threadIds.append(threadId) + } + } + + var currentThreadIds: [Int64] = [] + for item in self.rawItems { + if case let .forum(pinnedIndex, _, threadId, _, _) = item.item.index, case .index = pinnedIndex { + currentThreadIds.append(threadId) + } + } + + if threadIds != currentThreadIds { + let _ = component.context.engine.peers.setForumChannelPinnedTopics(id: component.peerId, threadIds: threadIds).startStandalone() + self.resetReorderingOnNextUpdate = true + } else { + self.reorderingItems = nil + self.isReordering = false + self.state?.updated(transition: .spring(duration: 0.4)) + } + } else { + self.isReordering = false + self.state?.updated(transition: .spring(duration: 0.4)) + } + } else { + component.togglePanel() + } }) self.tabItemView = itemView self.addSubview(itemView) } - - let itemSize = itemView.update(context: component.context, theme: component.theme, width: panelWidth, transition: .immediate) - let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: topContainerInset), size: itemSize) - itemTransition.setPosition(layer: itemView.layer, position: itemFrame.center) - itemTransition.setBounds(layer: itemView.layer, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) - - if animateIn && !transition.animation.isImmediate { - itemView.layer.animateAlpha(from: 0.0, to: itemView.alpha, duration: 0.15) - transition.containedViewLayoutTransition.animateTransformScale(view: itemView, from: 0.001) - } - - topContainerInset += itemSize.height - topContainerInset -= 24.0 - } - - var contentSize = CGSize(width: panelWidth, height: 0.0) - contentSize.height += 36.0 - - var validIds: [Item.Id] = [] - var isFirst = true - var selectedItemFrame: CGRect? - - do { - if isFirst { - isFirst = false - } else { - contentSize.height += itemSpacing - } - - var itemTransition = transition - var animateIn = false - let itemView: AllItemView - if let current = self.allItemView { - itemView = current - } else { - itemTransition = .immediate - animateIn = true - itemView = AllItemView(context: component.context, action: { [weak self] in - guard let self, let component = self.component else { - return - } - - component.updateTopicId(nil, false) - }) - self.allItemView = itemView - self.scrollView.addSubview(itemView) - } - - var isSelected = false - if component.topicId == nil { - isSelected = true - } - let itemSize = itemView.update(context: component.context, isSelected: isSelected, theme: component.theme, width: panelWidth, transition: .immediate) - let itemFrame = CGRect(origin: CGPoint(x: containerInsets.left, y: contentSize.height), size: itemSize) - - if isSelected { - selectedItemFrame = itemFrame + let itemSize = itemView.update(context: component.context, theme: component.theme, width: 72.0, location: component.location, isReordering: self.isReordering, transition: itemTransition) + let itemFrame: CGRect + switch component.location { + case .side: + itemFrame = CGRect(origin: CGPoint(x: 0.0, y: directionContainerInset), size: itemSize) + directionContainerInset += itemSize.height + case .top: + itemFrame = CGRect(origin: CGPoint(x: directionContainerInset, y: 0.0), size: itemSize) + directionContainerInset += itemSize.width - 14.0 } itemTransition.setPosition(layer: itemView.layer, position: itemFrame.center) @@ -832,122 +1830,24 @@ public final class ChatSideTopicsPanel: Component { itemView.layer.animateAlpha(from: 0.0, to: itemView.alpha, duration: 0.15) transition.containedViewLayoutTransition.animateTransformScale(view: itemView, from: 0.001) } - - contentSize.height += itemSize.height } - for item in self.items { - if isFirst { - isFirst = false - } else { - contentSize.height += itemSpacing - } - let itemId = item.id - validIds.append(itemId) - - var itemTransition = transition - var animateIn = false - let itemView: ItemView - if let current = self.itemViews[itemId] { - itemView = current - } else { - itemTransition = .immediate - animateIn = true - let chatListItem = item.item - itemView = ItemView(context: component.context, action: { [weak self] in - guard let self, let component = self.component else { - return - } - - let topicId: Int64 - if case let .forum(topicIdValue) = chatListItem.id { - topicId = topicIdValue - } else { - topicId = chatListItem.renderedPeer.peerId.toInt64() - } - - var direction = true - if let lhsIndex = self.topicIndex(threadId: component.topicId), let rhsIndex = self.topicIndex(threadId: topicId) { - direction = lhsIndex < rhsIndex - } - - component.updateTopicId(topicId, direction) - }, contextGesture: { gesture, sourceNode in - }) - self.itemViews[itemId] = itemView - self.scrollView.addSubview(itemView) - } - - var isSelected = false - if case let .forum(topicId) = item.item.id { - isSelected = component.topicId == topicId - } else { - isSelected = component.topicId == item.item.renderedPeer.peerId.toInt64() - } - let itemSize = itemView.update(context: component.context, item: item, isSelected: isSelected, theme: component.theme, width: panelWidth, transition: .immediate) - let itemFrame = CGRect(origin: CGPoint(x: containerInsets.left, y: contentSize.height), size: itemSize) - - if isSelected { - selectedItemFrame = itemFrame - } - - itemTransition.setPosition(layer: itemView.layer, position: itemFrame.center) - itemTransition.setBounds(layer: itemView.layer, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) - - if animateIn && !transition.animation.isImmediate { - itemView.layer.animateAlpha(from: 0.0, to: itemView.alpha, duration: 0.15) - transition.containedViewLayoutTransition.animateTransformScale(view: itemView, from: 0.001) - } - - contentSize.height += itemSize.height + let scrollSize: CGSize + let scrollFrame: CGRect + let listContentInsets: UIEdgeInsets + switch component.location { + case .side: + scrollSize = CGSize(width: availableSize.width, height: availableSize.height - directionContainerInset) + scrollFrame = CGRect(origin: CGPoint(x: 0.0, y: directionContainerInset), size: scrollSize) + listContentInsets = UIEdgeInsets(top: 8.0, left: 0.0, bottom: 8.0, right: 0.0) + case .top: + scrollSize = CGSize(width: availableSize.width - directionContainerInset, height: availableSize.height) + scrollFrame = CGRect(origin: CGPoint(x: directionContainerInset, y: 0.0), size: scrollSize) + listContentInsets = UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0) } - contentSize.height += 12.0 - - var removedIds: [Item.Id] = [] - for (id, itemView) in self.itemViews { - if !validIds.contains(id) { - removedIds.append(id) - - if !transition.animation.isImmediate { - itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak itemView] _ in - itemView?.removeFromSuperview() - }) - transition.setScale(layer: itemView.layer, scale: 0.001) - } else { - itemView.removeFromSuperview() - } - } - } - for id in removedIds { - self.itemViews.removeValue(forKey: id) - } - - if let selectedItemFrame { - let lineFrame = CGRect(origin: CGPoint(x: containerInsets.left, y: selectedItemFrame.minY), size: CGSize(width: 4.0, height: selectedItemFrame.height + 4.0)) - if self.selectedLineView.isHidden { - self.selectedLineView.isHidden = false - self.selectedLineView.frame = lineFrame - } else { - transition.setFrame(view: self.selectedLineView, frame: lineFrame) - } - } else { - self.selectedLineView.isHidden = true - } - - contentSize.height += containerInsets.bottom - - let scrollSize = CGSize(width: availableSize.width, height: availableSize.height - topContainerInset) - - self.scrollContainerView.frame = CGRect(origin: CGPoint(x: 0.0, y: topContainerInset), size: scrollSize) - self.scrollViewMask.frame = CGRect(origin: CGPoint(x: 0.0, y: topContainerInset), size: scrollSize) - - if self.scrollView.bounds.size != scrollSize { - self.scrollView.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: scrollSize) - } - if self.scrollView.contentSize != contentSize { - self.scrollView.contentSize = contentSize - } + self.scrollContainerView.frame = scrollFrame + self.scrollViewMask.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: scrollSize) let scrollToId: ScrollId if let threadId = component.topicId { @@ -956,17 +1856,297 @@ public final class ChatSideTopicsPanel: Component { scrollToId = .all } if self.appliedScrollToId != scrollToId { - if case let .topic(threadId) = scrollToId { - if let itemView = self.itemViews[.forum(threadId)] { - self.appliedScrollToId = scrollToId - self.scrollView.scrollRectToVisible(itemView.frame.insetBy(dx: -46.0, dy: 0.0), animated: hadItemViews) + self.appliedScrollToId = scrollToId + self.listState.resetScrolling(id: AnyHashable(scrollToId)) + } + + var listItems: [AnyComponentWithIdentity] = [] + switch component.location { + case .side: + listItems.append(AnyComponentWithIdentity( + id: ScrollId.all, + component: AnyComponent(VerticalAllItemComponent( + isSelected: component.topicId == nil, + theme: component.theme, + strings: component.strings, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.updateTopicId(nil, false) + } + ))) + ) + case .top: + listItems.append(AnyComponentWithIdentity( + id: ScrollId.all, + component: AnyComponent(HorizontalAllItemComponent( + isSelected: component.topicId == nil, + theme: component.theme, + strings: component.strings, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.updateTopicId(nil, false) + } + ))) + ) + } + for item in self.reorderingItems ?? self.rawItems { + let scrollId: ScrollId + let topicId: Int64 + var isItemReordering = false + switch item.item.id { + case let .chatList(peerId): + topicId = peerId.toInt64() + case let .forum(topicIdValue): + topicId = topicIdValue + if self.isReordering { + if case let .forum(pinnedIndex, _, _, _, _) = item.item.index, case .index = pinnedIndex { + isItemReordering = true + } } - } else if case .all = scrollToId { - self.appliedScrollToId = scrollToId - self.scrollView.scrollRectToVisible(CGRect(origin: CGPoint(), size: CGSize(width: 1.0, height: 1.0)), animated: hadItemViews) - } else { - self.appliedScrollToId = scrollToId } + scrollId = .topic(topicId) + + let itemAction: (() -> Void)? = self.isReordering ? nil : { [weak self] in + guard let self, let component = self.component else { + return + } + + let direction: Bool + if let lhsIndex = self.topicIndex(threadId: component.topicId), let rhsIndex = self.topicIndex(threadId: topicId) { + direction = lhsIndex < rhsIndex + } else { + direction = false + } + component.updateTopicId(topicId, direction) + } + let itemContextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)? = (self.isReordering && !component.isMonoforum) ? nil : { [weak self] gesture, sourceNode in + guard let self, let component = self.component else { + return + } + guard let controller = component.controller() else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }) + + if let listView = self.list.view as? AsyncListComponent.View { + listView.stopScrolling() + } + + let topicId: Int64 + switch item.item.id { + case let .chatList(peerId): + topicId = peerId.toInt64() + case let .forum(topicIdValue): + topicId = topicIdValue + } + + var isPinned = false + if case let .forum(pinnedIndex, _, _, _, _) = item.item.index { + if case .index = pinnedIndex { + isPinned = true + } + } + let isClosed = item.item.threadData?.isClosed + let threadData = item.item.threadData + + let _ = (chatForumTopicMenuItems( + context: component.context, + peerId: component.peerId, + threadId: topicId, + isPinned: isPinned, + isClosed: isClosed, + chatListController: controller, + joined: true, + canSelect: false, + customEdit: { [weak self] contextController in + contextController.dismiss(completion: { + guard let self, let component = self.component, let threadData else { + return + } + let editController = component.context.sharedContext.makeEditForumTopicScreen( + context: component.context, + peerId: component.peerId, + threadId: topicId, + threadInfo: threadData.info, + isHidden: threadData.isHidden + ) + component.controller()?.push(editController) + }) + }, + customPinUnpin: { [weak self] contextController in + guard let self, let component = self.component else { + contextController.dismiss(completion: {}) + return + } + + self.isTogglingPinnedItem = true + self.dismissContextControllerOnNextUpdate = contextController + + let _ = (component.context.engine.peers.toggleForumChannelTopicPinned(id: component.peerId, threadId: topicId) + |> deliverOnMainQueue).startStandalone(error: { [weak self, weak contextController] error in + guard let self, let component = self.component else { + contextController?.dismiss(completion: {}) + return + } + + switch error { + case let .limitReached(count): + contextController?.dismiss(completion: {}) + if let controller = component.controller() { + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + let text = presentationData.strings.ChatList_MaxThreadPinsFinalText(Int32(count)) + controller.present(textAlertController(context: component.context, title: presentationData.strings.Premium_LimitReached, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})], parseMarkdown: true), in: .window(.root)) + } + default: + break + } + }) + }, + reorder: { [weak self] in + guard let self else { + return + } + self.updateIsReordering(isReordering: true) + } + ) + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak self, weak sourceNode, weak gesture] items in + guard let self, let component = self.component else { + return + } + guard let controller = component.controller() else { + return + } + guard let sourceNode else { + return + } + + let contextController = ContextController( + presentationData: presentationData, + source: .extracted(ItemExtractedContentSource( + sourceNode: sourceNode, + containerView: self, + keepInPlace: false + )), + items: .single(ContextController.Items(content: .list(items))), + recognizer: nil, + gesture: gesture + ) + controller.presentInGlobalOverlay(contextController) + }) + } + + switch component.location { + case .side: + listItems.append(AnyComponentWithIdentity( + id: scrollId, + component: AnyComponent(VerticalItemComponent( + context: component.context, + item: item, + isSelected: component.topicId == topicId, + isReordering: isItemReordering, + theme: component.theme, + strings: component.strings, + action: itemAction, + contextGesture: itemContextGesture + ))) + ) + case .top: + listItems.append(AnyComponentWithIdentity( + id: scrollId, + component: AnyComponent(HorizontalItemComponent( + context: component.context, + item: item, + isSelected: component.topicId == topicId, + isReordering: isItemReordering, + theme: component.theme, + strings: component.strings, + action: itemAction, + contextGesture: itemContextGesture + ))) + ) + } + } + + let _ = self.list.update( + transition: transition, + component: AnyComponent(AsyncListComponent( + externalState: self.listState, + items: listItems, + itemSetId: AnyHashable(self.itemsContentVersion), + direction: component.location == .side ? .vertical : .horizontal, + insets: listContentInsets, + reorderItems: { [weak self] fromIndex, toIndex in + guard let self else { + return false + } + if !self.isReordering { + return false + } + + if self.reorderingItems == nil { + self.reorderingItems = self.rawItems + } + if var reorderingItems = self.reorderingItems { + var maxToIndex = -1 + for item in reorderingItems { + if case let .forum(pinnedIndex, _, _, _, _) = item.item.index, case .index = pinnedIndex { + maxToIndex += 1 + } else { + break + } + } + + let fromItemIndex = fromIndex - 1 + // Account for synthesized "all" item: [all, item_0, item_1, ...] + let toItemIndex = max(0, min(maxToIndex, toIndex - 1)) + if fromItemIndex == toItemIndex { + return false + } + + let reorderingItem = reorderingItems[fromItemIndex] + if toItemIndex < fromItemIndex { + reorderingItems.remove(at: fromItemIndex) + reorderingItems.insert(reorderingItem, at: toItemIndex) + } else { + reorderingItems.insert(reorderingItem, at: toItemIndex + 1) + reorderingItems.remove(at: fromItemIndex) + } + + self.reorderingItems = reorderingItems + self.state?.updated(transition: .spring(duration: 0.4)) + } + + return true + }, + onVisibleItemsUpdated: { [weak self] visibleItems, transition in + guard let self else { + return + } + self.updateListOverlays(visibleItems: visibleItems, transition: transition) + } + )), + environment: {}, + containerSize: scrollSize + ) + if let listView = self.list.view { + if listView.superview == nil { + self.scrollContainerView.addSubview(listView) + } + transition.setFrame(view: listView, frame: CGRect(origin: CGPoint(), size: scrollSize)) + } + + if self.isTogglingPinnedItem { + self.isTogglingPinnedItem = false + } + if let dismissContextControllerOnNextUpdate = self.dismissContextControllerOnNextUpdate { + self.dismissContextControllerOnNextUpdate = nil + dismissContextControllerOnNextUpdate.dismiss(completion: {}) } return availableSize @@ -981,3 +2161,35 @@ public final class ChatSideTopicsPanel: Component { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } + +private final class ItemExtractedContentSource: ContextExtractedContentSource { + let keepInPlace: Bool + let ignoreContentTouches: Bool = true + let blurBackground: Bool = true + let adjustContentForSideInset: Bool = true + + private let sourceNode: ContextExtractedContentContainingNode + private weak var containerView: UIView? + + init(sourceNode: ContextExtractedContentContainingNode, containerView: UIView, keepInPlace: Bool) { + self.sourceNode = sourceNode + self.containerView = containerView + self.keepInPlace = keepInPlace + } + + func takeView() -> ContextControllerTakeViewInfo? { + var contentArea: CGRect? + if let containerView = self.containerView { + contentArea = containerView.convert(containerView.bounds, to: nil) + } + + return ContextControllerTakeViewInfo( + containingItem: .node(self.sourceNode), + contentAreaInScreenSpace: contentArea ?? UIScreen.main.bounds + ) + } + + func putBack() -> ContextControllerPutBackViewInfo? { + return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) + } +} diff --git a/submodules/TelegramUI/Components/MaskedContainerComponent/BUILD b/submodules/TelegramUI/Components/MaskedContainerComponent/BUILD new file mode 100644 index 0000000000..4f2e41640c --- /dev/null +++ b/submodules/TelegramUI/Components/MaskedContainerComponent/BUILD @@ -0,0 +1,19 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "MaskedContainerComponent", + module_name = "MaskedContainerComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/MaskedContainerComponent/Sources/MaskedContainerComponent.swift b/submodules/TelegramUI/Components/MaskedContainerComponent/Sources/MaskedContainerComponent.swift new file mode 100644 index 0000000000..c83fff8f83 --- /dev/null +++ b/submodules/TelegramUI/Components/MaskedContainerComponent/Sources/MaskedContainerComponent.swift @@ -0,0 +1,94 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +public final class MaskedContainerView: UIView { + public struct Item: Equatable { + public enum Shape: Equatable { + case ellipse + case roundedRect(cornerRadius: CGFloat) + } + + public var frame: CGRect + public var shape: Shape + + public init(frame: CGRect, shape: Shape) { + self.frame = frame + self.shape = shape + } + } + + private struct Params: Equatable { + let size: CGSize + let items: [Item] + let isInverted: Bool + + init(size: CGSize, items: [Item], isInverted: Bool) { + self.size = size + self.items = items + self.isInverted = isInverted + } + } + + public let contentView: UIView + public let contentMaskView: UIImageView + + private var params: Params? + + override public init(frame: CGRect) { + self.contentView = UIView() + self.contentMaskView = UIImageView() + + super.init(frame: frame) + + self.addSubview(self.contentView) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func update(size: CGSize, items: [Item], isInverted: Bool) { + let params = Params(size: size, items: items, isInverted: isInverted) + if self.params == params { + return + } + self.params = params + self.contentView.frame = CGRect(origin: CGPoint(), size: size) + self.contentMaskView.frame = CGRect(origin: CGPoint(), size: size) + + if items.isEmpty { + self.contentMaskView.image = nil + self.contentView.mask = nil + } else { + let renderer = UIGraphicsImageRenderer(bounds: CGRect(origin: CGPoint(), size: size)) + let image = renderer.image { context in + UIGraphicsPushContext(context.cgContext) + + if isInverted { + context.cgContext.setFillColor(UIColor.black.cgColor) + context.cgContext.fill(CGRect(origin: CGPoint(), size: size)) + + context.cgContext.setFillColor(UIColor.clear.cgColor) + context.cgContext.setBlendMode(.copy) + } + + for item in items { + switch item.shape { + case .ellipse: + context.cgContext.fillEllipse(in: item.frame) + case let .roundedRect(cornerRadius): + context.cgContext.addPath(UIBezierPath(roundedRect: item.frame, cornerRadius: cornerRadius).cgPath) + context.cgContext.fillPath() + } + } + + UIGraphicsPopContext() + } + self.contentMaskView.image = image + + self.contentView.mask = self.contentMaskView + } + } +} diff --git a/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift b/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift index b5340ad252..a34c56bc77 100644 --- a/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift +++ b/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift @@ -198,7 +198,7 @@ private func generateBorderImage(theme: PresentationTheme, bordered: Bool, selec } -private final class ThemeCarouselThemeItemIconNode : ListViewItemNode { +private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { private let containerNode: ASDisplayNode private let emojiContainerNode: ASDisplayNode private let imageNode: TransformImageNode diff --git a/submodules/TelegramUI/Components/TextBadgeComponent/BUILD b/submodules/TelegramUI/Components/TextBadgeComponent/BUILD new file mode 100644 index 0000000000..1ddb36aac1 --- /dev/null +++ b/submodules/TelegramUI/Components/TextBadgeComponent/BUILD @@ -0,0 +1,19 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "TextBadgeComponent", + module_name = "TextBadgeComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/TextBadgeComponent/Sources/TextBadgeComponent.swift b/submodules/TelegramUI/Components/TextBadgeComponent/Sources/TextBadgeComponent.swift new file mode 100644 index 0000000000..03476f4996 --- /dev/null +++ b/submodules/TelegramUI/Components/TextBadgeComponent/Sources/TextBadgeComponent.swift @@ -0,0 +1,171 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +public final class TextBadgeComponent: Component { + public let text: String + public let font: UIFont + public let background: UIColor + public let foreground: UIColor + public let insets: UIEdgeInsets + + public init( + text: String, + font: UIFont, + background: UIColor, + foreground: UIColor, + insets: UIEdgeInsets + ) { + self.text = text + self.font = font + self.background = background + self.foreground = foreground + self.insets = insets + } + + public static func ==(lhs: TextBadgeComponent, rhs: TextBadgeComponent) -> Bool { + if lhs.text != rhs.text { + return false + } + if lhs.font != rhs.font { + return false + } + if lhs.background != rhs.background { + return false + } + if lhs.foreground != rhs.foreground { + return false + } + if lhs.insets != rhs.insets { + return false + } + return true + } + + private struct TextLayout { + var size: CGSize + var opticalBounds: CGRect + + init(size: CGSize, opticalBounds: CGRect) { + self.size = size + self.opticalBounds = opticalBounds + } + } + + public final class View: UIView { + private let backgroundView: UIImageView + private let textContentsView: UIImageView + + private var textLayout: TextLayout? + + private var component: TextBadgeComponent? + + override public init(frame: CGRect) { + self.backgroundView = UIImageView() + + self.textContentsView = UIImageView() + self.textContentsView.layer.anchorPoint = CGPoint() + + super.init(frame: frame) + + self.addSubview(self.backgroundView) + self.addSubview(self.textContentsView) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: TextBadgeComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + let previousComponent = self.component + self.component = component + + if component.text != previousComponent?.text || component.font != previousComponent?.font { + let attributedText = NSAttributedString(string: component.text, attributes: [ + NSAttributedString.Key.font: component.font, + NSAttributedString.Key.foregroundColor: UIColor.white + ]) + + var boundingRect = attributedText.boundingRect(with: availableSize, options: .usesLineFragmentOrigin, context: nil) + boundingRect.size.width = ceil(boundingRect.size.width) + boundingRect.size.height = ceil(boundingRect.size.height) + + if let context = DrawingContext(size: boundingRect.size, scale: 0.0, opaque: false, clear: true) { + context.withContext { c in + UIGraphicsPushContext(c) + defer { + UIGraphicsPopContext() + } + + attributedText.draw(at: CGPoint()) + } + var minFilledLineY = Int(context.scaledSize.height) - 1 + var maxFilledLineY = 0 + var minFilledLineX = Int(context.scaledSize.width) - 1 + var maxFilledLineX = 0 + for y in 0 ..< Int(context.scaledSize.height) { + let linePtr = context.bytes.advanced(by: max(0, y) * context.bytesPerRow).assumingMemoryBound(to: UInt32.self) + + for x in 0 ..< Int(context.scaledSize.width) { + let pixelPtr = linePtr.advanced(by: x) + if pixelPtr.pointee != 0 { + minFilledLineY = min(y, minFilledLineY) + maxFilledLineY = max(y, maxFilledLineY) + minFilledLineX = min(x, minFilledLineX) + maxFilledLineX = max(x, maxFilledLineX) + } + } + } + + var opticalBounds = CGRect() + if minFilledLineX <= maxFilledLineX && minFilledLineY <= maxFilledLineY { + opticalBounds.origin.x = CGFloat(minFilledLineX) / context.scale + opticalBounds.origin.y = CGFloat(minFilledLineY) / context.scale + opticalBounds.size.width = CGFloat(maxFilledLineX - minFilledLineX) / context.scale + opticalBounds.size.height = CGFloat(maxFilledLineY - minFilledLineY) / context.scale + } + + self.textContentsView.image = context.generateImage()?.withRenderingMode(.alwaysTemplate) + self.textLayout = TextLayout(size: boundingRect.size, opticalBounds: opticalBounds) + } else { + self.textLayout = TextLayout(size: boundingRect.size, opticalBounds: CGRect(origin: CGPoint(), size: boundingRect.size)) + } + } + + let textSize = self.textLayout?.size ?? CGSize(width: 1.0, height: 1.0) + + var size = CGSize(width: textSize.width + component.insets.left + component.insets.right, height: textSize.height + component.insets.top + component.insets.bottom) + size.width = max(size.width, size.height) + + let backgroundFrame = CGRect(origin: CGPoint(), size: size) + transition.setFrame(view: self.backgroundView, frame: backgroundFrame) + + let textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) * 0.5), y: component.insets.top + UIScreenPixel), size: textSize) + /*if let textLayout = self.textLayout { + textFrame.origin.x = textLayout.opticalBounds.minX + floorToScreenPixels((backgroundFrame.width - textLayout.opticalBounds.width) * 0.5) + textFrame.origin.y = textLayout.opticalBounds.minY + floorToScreenPixels((backgroundFrame.height - textLayout.opticalBounds.height) * 0.5) + }*/ + + transition.setPosition(view: self.textContentsView, position: textFrame.origin) + self.textContentsView.bounds = CGRect(origin: CGPoint(), size: textFrame.size) + + if size.height != self.backgroundView.image?.size.height { + self.backgroundView.image = generateStretchableFilledCircleImage(diameter: size.height, color: .white)?.withRenderingMode(.alwaysTemplate) + } + + self.backgroundView.tintColor = component.background + self.textContentsView.tintColor = component.foreground + + return size + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift b/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift index 1513f1b4fd..392c7e0d92 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift @@ -271,9 +271,13 @@ func sidePanelForChatPresentationInterfaceState(_ chatPresentationInterfaceState context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, + location: .side, peerId: peerId, isMonoforum: true, topicId: chatPresentationInterfaceState.chatLocation.threadId, + controller: { [weak interfaceInteraction] in + return interfaceInteraction?.chatController() + }, togglePanel: { [weak interfaceInteraction] in interfaceInteraction?.toggleChatSidebarMode() }, @@ -292,9 +296,13 @@ func sidePanelForChatPresentationInterfaceState(_ chatPresentationInterfaceState context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, + location: .side, peerId: peerId, isMonoforum: false, topicId: chatPresentationInterfaceState.chatLocation.threadId, + controller: { [weak interfaceInteraction] in + return interfaceInteraction?.chatController() + }, togglePanel: { [weak interfaceInteraction] in interfaceInteraction?.toggleChatSidebarMode() }, diff --git a/submodules/TelegramUI/Sources/ChatTopicListTitleAccessoryPanelNode.swift b/submodules/TelegramUI/Sources/ChatTopicListTitleAccessoryPanelNode.swift index ed821174ce..3ceac34a9f 100644 --- a/submodules/TelegramUI/Sources/ChatTopicListTitleAccessoryPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTopicListTitleAccessoryPanelNode.swift @@ -14,173 +14,9 @@ import EmojiStatusComponent import SwiftSignalKit import BundleIconComponent import AvatarNode - -private final class CustomBadgeComponent: Component { - public let text: String - public let font: UIFont - public let background: UIColor - public let foreground: UIColor - public let insets: UIEdgeInsets - - public init( - text: String, - font: UIFont, - background: UIColor, - foreground: UIColor, - insets: UIEdgeInsets - ) { - self.text = text - self.font = font - self.background = background - self.foreground = foreground - self.insets = insets - } - - public static func ==(lhs: CustomBadgeComponent, rhs: CustomBadgeComponent) -> Bool { - if lhs.text != rhs.text { - return false - } - if lhs.font != rhs.font { - return false - } - if lhs.background != rhs.background { - return false - } - if lhs.foreground != rhs.foreground { - return false - } - if lhs.insets != rhs.insets { - return false - } - return true - } - - private struct TextLayout { - var size: CGSize - var opticalBounds: CGRect - - init(size: CGSize, opticalBounds: CGRect) { - self.size = size - self.opticalBounds = opticalBounds - } - } - - public final class View: UIView { - private let backgroundView: UIImageView - private let textContentsView: UIImageView - - private var textLayout: TextLayout? - - private var component: CustomBadgeComponent? - - override public init(frame: CGRect) { - self.backgroundView = UIImageView() - - self.textContentsView = UIImageView() - self.textContentsView.layer.anchorPoint = CGPoint() - - super.init(frame: frame) - - self.addSubview(self.backgroundView) - self.addSubview(self.textContentsView) - } - - required public init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(component: CustomBadgeComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { - let previousComponent = self.component - self.component = component - - if component.text != previousComponent?.text || component.font != previousComponent?.font { - let attributedText = NSAttributedString(string: component.text, attributes: [ - NSAttributedString.Key.font: component.font, - NSAttributedString.Key.foregroundColor: UIColor.white - ]) - - var boundingRect = attributedText.boundingRect(with: availableSize, options: .usesLineFragmentOrigin, context: nil) - boundingRect.size.width = ceil(boundingRect.size.width) - boundingRect.size.height = ceil(boundingRect.size.height) - - if let context = DrawingContext(size: boundingRect.size, scale: 0.0, opaque: false, clear: true) { - context.withContext { c in - UIGraphicsPushContext(c) - defer { - UIGraphicsPopContext() - } - - attributedText.draw(at: CGPoint()) - } - var minFilledLineY = Int(context.scaledSize.height) - 1 - var maxFilledLineY = 0 - var minFilledLineX = Int(context.scaledSize.width) - 1 - var maxFilledLineX = 0 - for y in 0 ..< Int(context.scaledSize.height) { - let linePtr = context.bytes.advanced(by: max(0, y) * context.bytesPerRow).assumingMemoryBound(to: UInt32.self) - - for x in 0 ..< Int(context.scaledSize.width) { - let pixelPtr = linePtr.advanced(by: x) - if pixelPtr.pointee != 0 { - minFilledLineY = min(y, minFilledLineY) - maxFilledLineY = max(y, maxFilledLineY) - minFilledLineX = min(x, minFilledLineX) - maxFilledLineX = max(x, maxFilledLineX) - } - } - } - - var opticalBounds = CGRect() - if minFilledLineX <= maxFilledLineX && minFilledLineY <= maxFilledLineY { - opticalBounds.origin.x = CGFloat(minFilledLineX) / context.scale - opticalBounds.origin.y = CGFloat(minFilledLineY) / context.scale - opticalBounds.size.width = CGFloat(maxFilledLineX - minFilledLineX) / context.scale - opticalBounds.size.height = CGFloat(maxFilledLineY - minFilledLineY) / context.scale - } - - self.textContentsView.image = context.generateImage()?.withRenderingMode(.alwaysTemplate) - self.textLayout = TextLayout(size: boundingRect.size, opticalBounds: opticalBounds) - } else { - self.textLayout = TextLayout(size: boundingRect.size, opticalBounds: CGRect(origin: CGPoint(), size: boundingRect.size)) - } - } - - let textSize = self.textLayout?.size ?? CGSize(width: 1.0, height: 1.0) - - var size = CGSize(width: textSize.width + component.insets.left + component.insets.right, height: textSize.height + component.insets.top + component.insets.bottom) - size.width = max(size.width, size.height) - - let backgroundFrame = CGRect(origin: CGPoint(), size: size) - transition.setFrame(view: self.backgroundView, frame: backgroundFrame) - - let textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) * 0.5), y: component.insets.top + UIScreenPixel), size: textSize) - /*if let textLayout = self.textLayout { - textFrame.origin.x = textLayout.opticalBounds.minX + floorToScreenPixels((backgroundFrame.width - textLayout.opticalBounds.width) * 0.5) - textFrame.origin.y = textLayout.opticalBounds.minY + floorToScreenPixels((backgroundFrame.height - textLayout.opticalBounds.height) * 0.5) - }*/ - - transition.setPosition(view: self.textContentsView, position: textFrame.origin) - self.textContentsView.bounds = CGRect(origin: CGPoint(), size: textFrame.size) - - if size.height != self.backgroundView.image?.size.height { - self.backgroundView.image = generateStretchableFilledCircleImage(diameter: size.height, color: .white)?.withRenderingMode(.alwaysTemplate) - } - - self.backgroundView.tintColor = component.background - self.textContentsView.tintColor = component.foreground - - return size - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { - return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) - } -} +import TextBadgeComponent +import ChatSideTopicsPanel +import ComponentDisplayAdapters final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, ChatControllerCustomNavigationPanelNode { private struct Params: Equatable { @@ -213,31 +49,7 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C } } - private final class Item: Equatable { - typealias Id = EngineChatList.Item.Id - - let item: EngineChatList.Item - - var id: Id { - return self.item.id - } - - init(item: EngineChatList.Item) { - self.item = item - } - - public static func ==(lhs: Item, rhs: Item) -> Bool { - if lhs === rhs { - return true - } - if lhs.item != rhs.item { - return false - } - return true - } - } - - private final class ItemView: UIView { + /*private final class ItemView: UIView { private let context: AccountContext private let action: () -> Void @@ -396,11 +208,11 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C badgeSize = badge.update( transition: badgeTransition, - component: AnyComponent(CustomBadgeComponent( - text: "\(readCounters.count)", + component: AnyComponent(TextBadgeComponent( + text: countString(Int64(readCounters.count)), font: Font.regular(12.0), - background: theme.list.itemCheckColors.fillColor, - foreground: theme.list.itemCheckColors.foregroundColor, + background: item.item.isMuted ? theme.chatList.unreadBadgeInactiveBackgroundColor : theme.chatList.unreadBadgeActiveBackgroundColor, + foreground: item.item.isMuted ? theme.chatList.unreadBadgeInactiveTextColor : theme.chatList.unreadBadgeActiveTextColor, insets: UIEdgeInsets(top: 1.0, left: 5.0, bottom: 2.0, right: 5.0) )), environment: {}, @@ -715,99 +527,26 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C return size } - } - - private final class ScrollView: UIScrollView { - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - return super.hitTest(point, with: event) - } - - override func touchesShouldCancel(in view: UIView) -> Bool { - return true - } - } - - private enum ScrollId: Equatable { - case all - case topic(Int64) - } - - private let context: AccountContext - private let isMonoforum: Bool - - private let scrollView: ScrollView - private let scrollViewContainer: UIView - private let scrollViewMask: UIImageView + }*/ private var params: Params? - private var items: [Item] = [] - private var itemViews: [Item.Id: ItemView] = [:] - private var allItemView: AllItemView? - private var tabItemView: TabItemView? - private let selectedLineView: UIImageView - - private var itemsDisposable: Disposable? - - private var appliedScrollToId: ScrollId? + private let context: AccountContext + private let peerId: EnginePeer.Id + private let isMonoforum: Bool + private let panel = ComponentView() init(context: AccountContext, peerId: EnginePeer.Id, isMonoforum: Bool) { self.context = context + self.peerId = peerId self.isMonoforum = isMonoforum - self.selectedLineView = UIImageView() - - self.scrollView = ScrollView(frame: CGRect()) - self.scrollViewMask = UIImageView(image: generateGradientImage(size: CGSize(width: 8.0, height: 8.0), colors: [ - UIColor(white: 1.0, alpha: 0.0), - UIColor(white: 1.0, alpha: 1.0) - ], locations: [0.0, 1.0], direction: .horizontal)?.stretchableImage(withLeftCapWidth: 8, topCapHeight: 0)) - - self.scrollViewContainer = UIView() - super.init() - self.scrollView.delaysContentTouches = false - self.scrollView.canCancelContentTouches = true - self.scrollView.clipsToBounds = true - self.scrollView.contentInsetAdjustmentBehavior = .never - if #available(iOS 13.0, *) { - self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false - } - self.scrollView.showsVerticalScrollIndicator = false - self.scrollView.showsHorizontalScrollIndicator = false - self.scrollView.alwaysBounceHorizontal = false - self.scrollView.alwaysBounceVertical = false - self.scrollView.scrollsToTop = false - self.scrollViewContainer.addSubview(self.scrollView) - self.scrollViewContainer.mask = self.scrollViewMask - - self.view.addSubview(self.scrollViewContainer) - - self.scrollView.addSubview(self.selectedLineView) - - self.scrollView.disablesInteractiveTransitionGestureRecognizer = true - - let threadListSignal: Signal = context.sharedContext.subscribeChatListData(context: context, location: isMonoforum ? .savedMessagesChats(peerId: peerId) : .forum(peerId: peerId)) - - self.itemsDisposable = (threadListSignal - |> deliverOnMainQueue).startStrict(next: { [weak self] chatList in - guard let self else { - return - } - self.items.removeAll() - - for item in chatList.items.reversed() { - self.items.append(Item(item: item)) - } - - self.update(transition: .immediate) - }) } deinit { - self.itemsDisposable?.dispose() } private func update(transition: ContainedViewLayoutTransition) { @@ -819,14 +558,6 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> LayoutResult { let params = Params(width: width, leftInset: leftInset, rightInset: rightInset, interfaceState: interfaceState) if self.params != params { - if self.params?.interfaceState.theme !== params.interfaceState.theme { - self.selectedLineView.image = generateImage(CGSize(width: 7.0, height: 4.0), rotatedContext: { size, context in - context.clear(CGRect(origin: CGPoint(), size: size)) - context.setFillColor(params.interfaceState.theme.rootController.navigationBar.accentTextColor.cgColor) - context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.width))) - })?.stretchableImage(withLeftCapWidth: 4, topCapHeight: 1) - } - self.params = params self.update(params: params, transition: transition) } @@ -841,14 +572,54 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C } private func update(params: Params, transition: ContainedViewLayoutTransition) { - let hadItemViews = !self.itemViews.isEmpty + let panelHeight: CGFloat = 44.0 - var transition = transition - if !hadItemViews { - transition = .immediate + let panelFrame = CGRect(origin: CGPoint(), size: CGSize(width: params.width, height: panelHeight)) + let _ = self.panel.update( + transition: ComponentTransition(transition), + component: AnyComponent(ChatSideTopicsPanel( + context: self.context, + theme: params.interfaceState.theme, + strings: params.interfaceState.strings, + location: .top, + peerId: self.peerId, + isMonoforum: self.isMonoforum, + topicId: params.interfaceState.chatLocation.threadId, + controller: { [weak self] in + return self?.interfaceInteraction?.chatController() + }, + togglePanel: { [weak self] in + guard let self else { + return + } + self.interfaceInteraction?.toggleChatSidebarMode() + }, + updateTopicId: { [weak self] topicId, direction in + guard let self else { + return + } + self.interfaceInteraction?.updateChatLocationThread(topicId, direction ? .right : .left) + } + )), + environment: { + ChatSidePanelEnvironment(insets: UIEdgeInsets( + top: 0.0, + left: params.leftInset, + bottom: 0.0, + right: params.rightInset + )) + }, + containerSize: panelFrame.size + ) + if let panelView = self.panel.view { + if panelView.superview == nil { + panelView.disablesInteractiveTransitionGestureRecognizer = true + self.view.addSubview(panelView) + } + transition.updateFrame(view: panelView, frame: panelFrame) } - let panelHeight: CGFloat = 44.0 + /* let containerInsets = UIEdgeInsets(top: 0.0, left: params.leftInset + 16.0, bottom: 0.0, right: params.rightInset + 16.0) let itemSpacing: CGFloat = 24.0 @@ -1068,17 +839,24 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C } else { self.appliedScrollToId = scrollToId } - } + }*/ } public func updateGlobalOffset(globalOffset: CGFloat, transition: ComponentTransition) { - if let tabItemView = self.tabItemView { - transition.setTransform(view: tabItemView, transform: CATransform3DMakeTranslation(0.0, -globalOffset, 0.0)) + if let panelView = self.panel.view as? ChatSideTopicsPanel.View { + panelView.updateGlobalOffset(globalOffset: globalOffset, transition: transition) + //transition.setTransform(view: tabItemView, transform: CATransform3DMakeTranslation(0.0, -globalOffset, 0.0)) } } public func topicIndex(threadId: Int64?) -> Int? { - if let threadId { + if let panelView = self.panel.view as? ChatSideTopicsPanel.View { + return panelView.topicIndex(threadId: threadId) + } else { + return nil + } + + /*if let threadId { if let value = self.items.firstIndex(where: { item in if item.id == .chatList(PeerId(threadId)) { return true @@ -1094,6 +872,6 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C } } else { return 0 - } + }*/ } } diff --git a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift index dd14e47777..b05b3ca8eb 100644 --- a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift @@ -18,6 +18,7 @@ import PremiumUI import ChatControllerInteraction import ChatContextResultPeekContent import ChatInputContextPanelNode +import BatchVideoRendering private struct ChatContextResultStableId: Hashable { let result: ChatContextResult @@ -48,8 +49,8 @@ private struct HorizontalListContextResultsChatInputContextPanelEntry: Comparabl return lhs.index < rhs.index } - func item(context: AccountContext, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> ListViewItem { - return HorizontalListContextResultsChatInputPanelItem(context: context, theme: self.theme, result: self.result, resultSelected: resultSelected) + func item(context: AccountContext, batchVideoContext: QueueLocalObject, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> ListViewItem { + return HorizontalListContextResultsChatInputPanelItem(context: context, theme: self.theme, result: self.result, batchVideoContext: batchVideoContext, resultSelected: resultSelected) } } @@ -71,12 +72,12 @@ private final class HorizontalListContextResultsOpaqueState { } } -private func preparedTransition(from fromEntries: [HorizontalListContextResultsChatInputContextPanelEntry], to toEntries: [HorizontalListContextResultsChatInputContextPanelEntry], hasMore: Bool, context: AccountContext, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> HorizontalListContextResultsChatInputContextPanelTransition { +private func preparedTransition(from fromEntries: [HorizontalListContextResultsChatInputContextPanelEntry], to toEntries: [HorizontalListContextResultsChatInputContextPanelEntry], hasMore: Bool, context: AccountContext, batchVideoContext: QueueLocalObject, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> HorizontalListContextResultsChatInputContextPanelTransition { 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, resultSelected: resultSelected), directionHint: nil) } - let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, resultSelected: resultSelected), directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, batchVideoContext: batchVideoContext, resultSelected: resultSelected), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, batchVideoContext: batchVideoContext, resultSelected: resultSelected), directionHint: nil) } return HorizontalListContextResultsChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates, entryCount: toEntries.count, hasMore: hasMore) } @@ -93,6 +94,8 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont private var enqueuedTransitions: [(HorizontalListContextResultsChatInputContextPanelTransition, Bool)] = [] private var hasValidLayout = false + private let batchVideoContext: QueueLocalObject + override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { self.separatorNode = ASDisplayNode() self.separatorNode.isLayerBacked = true @@ -108,6 +111,10 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont return strings.VoiceOver_ScrollStatus(row, count).string } + self.batchVideoContext = QueueLocalObject(queue: .mainQueue(), generate: { + return BatchVideoRenderingContext(context: context) + }) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.isOpaque = false @@ -136,7 +143,7 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont self.listView.view.disablesInteractiveTransitionGestureRecognizer = true self.listView.view.disablesInteractiveKeyboardGestureRecognizer = true - self.view.addGestureRecognizer(PeekControllerGestureRecognizer(contentAtPoint: { [weak self] point in + self.view.addGestureRecognizer(PeekControllerGestureRecognizer(contentAtPoint: { [weak self] point -> Signal<(UIView, CGRect, PeekControllerContent)?, NoError>? in if let strongSelf = self { let convertedPoint = strongSelf.listView.view.convert(point, from: strongSelf.view) @@ -183,7 +190,7 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont let controller = PremiumIntroScreen(context: strongSelf.context, source: .stickers) strongSelf.interfaceInteraction?.getNavigationController()?.pushViewController(controller) })) - } else { + } else if let batchVideoContext = strongSelf.batchVideoContext.unsafeGet() { var menuItems: [ContextMenuItem] = [] if case let .internalReference(internalReference) = item.result, let file = internalReference.file, file.isAnimated { menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.Preview_SaveGif, icon: { theme in @@ -229,7 +236,7 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont f(.default) let _ = item.resultSelected(item.result, itemNode, itemNode.bounds) }))) - selectedItemNodeAndContent = (itemNode.view, itemNode.bounds, ChatContextResultPeekContent(account: item.context.account, contextResult: item.result, menu: menuItems)) + selectedItemNodeAndContent = (itemNode.view, itemNode.bounds, ChatContextResultPeekContent(context: item.context, contextResult: item.result, menu: menuItems, batchVideoContext: batchVideoContext)) } } } @@ -312,7 +319,7 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont } let firstTime = self.currentEntries == nil - let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, hasMore: results.nextOffset != nil, context: self.context, resultSelected: { [weak self] result, node, rect in + let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, hasMore: results.nextOffset != nil, context: self.context, batchVideoContext: self.batchVideoContext, resultSelected: { [weak self] result, node, rect in if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction { return interfaceInteraction.sendContextResult(results, result, node, rect) } else { diff --git a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift index 94770716c2..4209c7fd69 100644 --- a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift +++ b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift @@ -15,20 +15,23 @@ import TelegramPresentationData import AccountContext import ShimmerEffect import SoftwareVideo -import MultiplexedVideoNode +import BatchVideoRendering +import GifVideoLayer final class HorizontalListContextResultsChatInputPanelItem: ListViewItem { let context: AccountContext let theme: PresentationTheme let result: ChatContextResult + let batchVideoContext: QueueLocalObject let resultSelected: (ChatContextResult, ASDisplayNode, CGRect) -> Bool let selectable: Bool = true - public init(context: AccountContext, theme: PresentationTheme, result: ChatContextResult, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) { + public init(context: AccountContext, theme: PresentationTheme, result: ChatContextResult, batchVideoContext: QueueLocalObject, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) { self.context = context self.theme = theme self.result = result + self.batchVideoContext = batchVideoContext self.resultSelected = resultSelected } @@ -90,7 +93,7 @@ final class HorizontalListContextResultsChatInputPanelItemNode: ListViewItemNode private let imageNode: TransformImageNode private var animationNode: AnimatedStickerNode? private var placeholderNode: StickerShimmerEffectNode? - private var videoLayer: (SoftwareVideoThumbnailNode, SoftwareVideoLayerFrameManager, SampleBufferLayer)? + private var videoLayer: GifVideoLayer? private var currentImageResource: TelegramMediaResource? private var currentVideoFile: TelegramMediaFile? private var currentAnimatedStickerFile: TelegramMediaFile? @@ -103,58 +106,17 @@ final class HorizontalListContextResultsChatInputPanelItemNode: ListViewItemNode override var visibility: ListViewItemNodeVisibility { didSet { - switch visibility { - case .visible: - self.ticking = true - default: - self.ticking = false + switch self.visibility { + case .visible: + self.videoLayer?.shouldBeAnimating = true + case .none: + self.videoLayer?.shouldBeAnimating = false } } } private let timebase: CMTimebase - private var displayLink: CADisplayLink? - private var ticking: Bool = false { - didSet { - if self.ticking != oldValue { - if self.ticking { - class DisplayLinkProxy: NSObject { - weak var target: HorizontalListContextResultsChatInputPanelItemNode? - init(target: HorizontalListContextResultsChatInputPanelItemNode) { - self.target = target - } - - @objc func displayLinkEvent() { - self.target?.displayLinkEvent() - } - } - - let displayLink = CADisplayLink(target: DisplayLinkProxy(target: self), selector: #selector(DisplayLinkProxy.displayLinkEvent)) - self.displayLink = displayLink - displayLink.add(to: RunLoop.main, forMode: .common) - if #available(iOS 10.0, *) { - displayLink.preferredFramesPerSecond = 25 - } else { - displayLink.frameInterval = 2 - } - displayLink.isPaused = false - CMTimebaseSetRate(self.timebase, rate: 1.0) - } else if let displayLink = self.displayLink { - self.displayLink = nil - displayLink.isPaused = true - displayLink.invalidate() - CMTimebaseSetRate(self.timebase, rate: 0.0) - } - } - } - } - - private func displayLinkEvent() { - let timestamp = CMTimebaseGetTime(self.timebase).seconds - self.videoLayer?.1.tick(timestamp: timestamp) - } - init() { self.imageNodeBackground = ASDisplayNode() self.imageNodeBackground.isLayerBacked = true @@ -197,10 +159,6 @@ final class HorizontalListContextResultsChatInputPanelItemNode: ListViewItemNode } deinit { - if let displayLink = self.displayLink { - displayLink.isPaused = true - displayLink.invalidate() - } self.statusDisposable.dispose() self.fetchDisposable.dispose() } @@ -384,30 +342,25 @@ final class HorizontalListContextResultsChatInputPanelItemNode: ListViewItemNode } if updatedVideoFile { - if let (thumbnailLayer, _, layer) = strongSelf.videoLayer { + if let videoLayer = strongSelf.videoLayer { strongSelf.videoLayer = nil - thumbnailLayer.removeFromSupernode() - layer.layer.removeFromSuperlayer() + videoLayer.removeFromSuperlayer() } - if let videoFile = videoFile { - let thumbnailLayer = SoftwareVideoThumbnailNode(account: item.context.account, fileReference: .standalone(media: videoFile), synchronousLoad: synchronousLoads) - thumbnailLayer.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) - strongSelf.addSubnode(thumbnailLayer) - let layerHolder = takeSampleBufferLayer() - layerHolder.layer.videoGravity = AVLayerVideoGravity.resizeAspectFill - layerHolder.layer.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) - strongSelf.layer.addSublayer(layerHolder.layer) + if let videoFile, let batchVideoContext = item.batchVideoContext.unsafeGet() { + let videoLayer = GifVideoLayer( + context: item.context, + batchVideoContext: batchVideoContext, + userLocation: .other, + file: .standalone(media: videoFile), + synchronousLoad: synchronousLoads + ) + videoLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill + videoLayer.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) + strongSelf.layer.addSublayer(videoLayer) - let manager = SoftwareVideoLayerFrameManager(account: item.context.account, userLocation: .other, userContentType: .other, fileReference: .standalone(media: videoFile), layerHolder: layerHolder) - strongSelf.videoLayer = (thumbnailLayer, manager, layerHolder) - thumbnailLayer.ready = { [weak thumbnailLayer, weak manager] in - if let strongSelf = self, let thumbnailLayer = thumbnailLayer, let manager = manager { - if strongSelf.videoLayer?.0 === thumbnailLayer && strongSelf.videoLayer?.1 === manager { - manager.start() - } - } - } + strongSelf.videoLayer = videoLayer + videoLayer.shouldBeAnimating = strongSelf.visibility != .none } } @@ -477,11 +430,9 @@ final class HorizontalListContextResultsChatInputPanelItemNode: ListViewItemNode strongSelf.statusNode.transitionToState(.none, completion: { }) } - if let (thumbnailLayer, _, layer) = strongSelf.videoLayer { - thumbnailLayer.bounds = CGRect(origin: CGPoint(), size: CGSize(width: croppedImageDimensions.width, height: croppedImageDimensions.height)) - thumbnailLayer.position = CGPoint(x: height / 2.0, y: (nodeLayout.contentSize.height - sideInset) / 2.0 + sideInset) - layer.layer.bounds = CGRect(origin: CGPoint(), size: CGSize(width: croppedImageDimensions.width, height: croppedImageDimensions.height)) - layer.layer.position = CGPoint(x: height / 2.0, y: (nodeLayout.contentSize.height - sideInset) / 2.0 + sideInset) + if let videoLayer = strongSelf.videoLayer { + videoLayer.bounds = CGRect(origin: CGPoint(), size: CGSize(width: croppedImageDimensions.width, height: croppedImageDimensions.height)) + videoLayer.position = CGPoint(x: height / 2.0, y: (nodeLayout.contentSize.height - sideInset) / 2.0 + sideInset) } if let animationNode = strongSelf.animationNode { diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 3ce34c88eb..a46c39cb12 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -85,6 +85,7 @@ import GiftStoreScreen import SendInviteLinkScreen import PostSuggestionsSettingsScreen import ForumSettingsScreen +import ForumCreateTopicScreen private final class AccountUserInterfaceInUseContext { let subscribers = Bag<(Bool) -> Void>() @@ -2609,6 +2610,25 @@ public final class SharedAccountContextImpl: SharedAccountContext { } } + public func makeEditForumTopicScreen(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64, threadInfo: EngineMessageHistoryThread.Info, isHidden: Bool) -> ViewController { + let controller = ForumCreateTopicScreen(context: context, peerId: peerId, mode: .edit(threadId: threadId, threadInfo: threadInfo, isHidden: isHidden)) + controller.navigationPresentation = .modal + controller.completion = { [weak controller] title, fileId, _, isHidden in + let _ = (context.engine.peers.editForumChannelTopic(id: peerId, threadId: threadId, title: title, iconFileId: fileId) + |> deliverOnMainQueue).startStandalone(completed: { + controller?.dismiss() + }) + + if let isHidden { + let _ = (context.engine.peers.setForumChannelTopicHidden(id: peerId, threadId: threadId, isHidden: isHidden) + |> deliverOnMainQueue).startStandalone(completed: { + controller?.dismiss() + }) + } + } + return controller + } + private func mapIntroSource(source: PremiumIntroSource) -> PremiumSource { let mappedSource: PremiumSource switch source {