mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
553 lines
24 KiB
Swift
553 lines
24 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import AsyncDisplayKit
|
|
import TelegramPresentationData
|
|
import ChatPresentationInterfaceState
|
|
import AccountContext
|
|
import ComponentFlow
|
|
import MultilineTextComponent
|
|
import PlainButtonComponent
|
|
import UIKitRuntimeUtils
|
|
import TelegramCore
|
|
import EmojiStatusComponent
|
|
import SwiftSignalKit
|
|
import ContextUI
|
|
import PromptUI
|
|
|
|
final class ChatSearchTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, UIScrollViewDelegate {
|
|
private struct Params: Equatable {
|
|
var width: CGFloat
|
|
var leftInset: CGFloat
|
|
var rightInset: CGFloat
|
|
var interfaceState: ChatPresentationInterfaceState
|
|
|
|
init(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, interfaceState: ChatPresentationInterfaceState) {
|
|
self.width = width
|
|
self.leftInset = leftInset
|
|
self.rightInset = rightInset
|
|
self.interfaceState = interfaceState
|
|
}
|
|
|
|
static func ==(lhs: Params, rhs: Params) -> Bool {
|
|
if lhs.width != rhs.width {
|
|
return false
|
|
}
|
|
if lhs.leftInset != rhs.leftInset {
|
|
return false
|
|
}
|
|
if lhs.rightInset != rhs.rightInset {
|
|
return false
|
|
}
|
|
if lhs.interfaceState != rhs.interfaceState {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
private final class Item {
|
|
let reaction: MessageReaction.Reaction
|
|
let count: Int
|
|
let title: String?
|
|
let file: TelegramMediaFile
|
|
|
|
init(reaction: MessageReaction.Reaction, count: Int, title: String?, file: TelegramMediaFile) {
|
|
self.reaction = reaction
|
|
self.count = count
|
|
self.title = title
|
|
self.file = file
|
|
}
|
|
}
|
|
|
|
private final class ItemView: UIView {
|
|
private let context: AccountContext
|
|
private let action: () -> Void
|
|
|
|
private let extractedContainerNode: ContextExtractedContentContainingNode
|
|
private let containerNode: ContextControllerSourceNode
|
|
|
|
private let containerButton: HighlightTrackingButton
|
|
|
|
private let background: UIImageView
|
|
private let icon = ComponentView<Empty>()
|
|
private let counter = ComponentView<Empty>()
|
|
|
|
init(context: AccountContext, action: @escaping (() -> Void), contextGesture: @escaping (ContextGesture, ContextExtractedContentContainingNode) -> Void) {
|
|
self.context = context
|
|
self.action = action
|
|
|
|
self.extractedContainerNode = ContextExtractedContentContainingNode()
|
|
self.containerNode = ContextControllerSourceNode()
|
|
|
|
self.containerButton = HighlightTrackingButton()
|
|
|
|
self.background = UIImageView()
|
|
if let image = UIImage(bundleImageName: "Chat/Title Panels/SearchTagTab") {
|
|
self.background.image = image.stretchableImage(withLeftCapWidth: 8, topCapHeight: 0).withRenderingMode(.alwaysTemplate)
|
|
}
|
|
|
|
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.addSubview(self.background)
|
|
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
self.containerNode.activated = { [weak self] gesture, _ in
|
|
guard let self else {
|
|
return
|
|
}
|
|
contextGesture(gesture, self.extractedContainerNode)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
return super.hitTest(mappedPoint, with: event)
|
|
}
|
|
|
|
func update(item: Item, isSelected: Bool, theme: PresentationTheme, height: CGFloat, transition: Transition) -> CGSize {
|
|
let spacing: CGFloat = 4.0
|
|
|
|
let reactionSize = CGSize(width: 16.0, height: 16.0)
|
|
var reactionDisplaySize = reactionSize
|
|
if case .builtin = item.reaction {
|
|
reactionDisplaySize = CGSize(width: reactionDisplaySize.width * 2.0, height: reactionDisplaySize.height * 2.0)
|
|
}
|
|
|
|
let _ = self.icon.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(EmojiStatusComponent(
|
|
context: self.context,
|
|
animationCache: self.context.animationCache,
|
|
animationRenderer: self.context.animationRenderer,
|
|
content: .animation(
|
|
content: .file(file: item.file),
|
|
size: reactionDisplaySize,
|
|
placeholderColor: theme.list.mediaPlaceholderColor,
|
|
themeColor: theme.list.itemPrimaryTextColor,
|
|
loopMode: .forever
|
|
),
|
|
isVisibleForAnimations: false,
|
|
useSharedAnimation: true,
|
|
action: nil,
|
|
emojiFileUpdated: nil
|
|
)),
|
|
environment: {},
|
|
containerSize: reactionDisplaySize
|
|
)
|
|
|
|
let title: String
|
|
if let value = item.title, !value.isEmpty {
|
|
title = "\(value) \(item.count)"
|
|
} else {
|
|
title = "\(item.count)"
|
|
}
|
|
let counterSize = self.counter.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: title, font: Font.regular(14.0), textColor: isSelected ? theme.list.itemCheckColors.foregroundColor : theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.6)))
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: 100.0, height: 100.0)
|
|
)
|
|
|
|
let size = CGSize(width: reactionSize.width + spacing + counterSize.width, height: height)
|
|
|
|
let iconFrame = CGRect(origin: CGPoint(x: 0.0, y: floor((size.height - reactionSize.height) * 0.5)), size: reactionSize)
|
|
let counterFrame = CGRect(origin: CGPoint(x: iconFrame.maxX + spacing, y: floor((size.height - counterSize.height) * 0.5)), size: counterSize)
|
|
|
|
if let iconView = self.icon.view {
|
|
if iconView.superview == nil {
|
|
iconView.isUserInteractionEnabled = false
|
|
self.containerButton.addSubview(iconView)
|
|
}
|
|
iconView.frame = reactionDisplaySize.centered(around: iconFrame.center)
|
|
}
|
|
|
|
if let counterView = self.counter.view {
|
|
if counterView.superview == nil {
|
|
counterView.isUserInteractionEnabled = false
|
|
self.containerButton.addSubview(counterView)
|
|
}
|
|
counterView.frame = counterFrame
|
|
}
|
|
|
|
if theme.overallDarkAppearance {
|
|
self.background.tintColor = isSelected ? theme.list.itemCheckColors.fillColor : UIColor(white: 1.0, alpha: 0.1)
|
|
} else {
|
|
self.background.tintColor = isSelected ? theme.list.itemCheckColors.fillColor : theme.list.controlSecondaryColor
|
|
}
|
|
if let image = self.background.image {
|
|
let backgroundFrame = CGRect(origin: CGPoint(x: -6.0, y: floorToScreenPixels((size.height - image.size.height) * 0.5)), size: CGSize(width: size.width + 6.0 + 9.0, height: image.size.height))
|
|
transition.setFrame(view: self.background, frame: backgroundFrame)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
private let context: AccountContext
|
|
|
|
private let scrollView: ScrollView
|
|
|
|
private var params: Params?
|
|
|
|
private var items: [Item] = []
|
|
private var itemViews: [MessageReaction.Reaction: ItemView] = [:]
|
|
|
|
private var itemsDisposable: Disposable?
|
|
|
|
init(context: AccountContext, chatLocation: ChatLocation) {
|
|
self.context = context
|
|
|
|
self.scrollView = ScrollView(frame: CGRect())
|
|
|
|
super.init()
|
|
|
|
self.scrollView.delaysContentTouches = false
|
|
self.scrollView.canCancelContentTouches = true
|
|
self.scrollView.clipsToBounds = false
|
|
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.scrollView.delegate = self
|
|
|
|
self.view.addSubview(self.scrollView)
|
|
|
|
self.scrollView.disablesInteractiveTransitionGestureRecognizer = true
|
|
|
|
let tagsAndFiles: Signal<([MessageReaction.Reaction: Int], [Int64: TelegramMediaFile]), NoError> = context.engine.data.subscribe(
|
|
TelegramEngine.EngineData.Item.Messages.SavedMessageTagStats(peerId: context.account.peerId, threadId: chatLocation.threadId)
|
|
)
|
|
|> distinctUntilChanged
|
|
|> mapToSignal { tags -> Signal<([MessageReaction.Reaction: Int], [Int64: TelegramMediaFile]), NoError> in
|
|
var customFileIds: [Int64] = []
|
|
for (reaction, _) in tags {
|
|
switch reaction {
|
|
case .builtin:
|
|
break
|
|
case let .custom(fileId):
|
|
customFileIds.append(fileId)
|
|
}
|
|
}
|
|
|
|
return context.engine.stickers.resolveInlineStickers(fileIds: customFileIds)
|
|
|> map { files in
|
|
return (tags, files)
|
|
}
|
|
}
|
|
|
|
var isFirstUpdate = true
|
|
self.itemsDisposable = (combineLatest(
|
|
context.engine.stickers.availableReactions(),
|
|
context.engine.stickers.savedMessageTagData(),
|
|
tagsAndFiles
|
|
)
|
|
|> deliverOnMainQueue).start(next: { [weak self] availableReactions, savedMessageTags, tagsAndFiles in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.items.removeAll()
|
|
|
|
let (tags, files) = tagsAndFiles
|
|
for (reaction, count) in tags {
|
|
let title = savedMessageTags?.tags.first(where: { $0.reaction == reaction })?.title
|
|
|
|
switch reaction {
|
|
case .builtin:
|
|
if let availableReactions {
|
|
inner: for availableReaction in availableReactions.reactions {
|
|
if availableReaction.value == reaction {
|
|
if let file = availableReaction.centerAnimation {
|
|
self.items.append(Item(reaction: reaction, count: count, title: title, file: file))
|
|
}
|
|
break inner
|
|
}
|
|
}
|
|
}
|
|
case let .custom(fileId):
|
|
if let file = files[fileId] {
|
|
self.items.append(Item(reaction: reaction, count: count, title: title, file: file))
|
|
}
|
|
}
|
|
}
|
|
self.items.sort(by: { lhs, rhs in
|
|
if lhs.count != rhs.count {
|
|
return lhs.count > rhs.count
|
|
}
|
|
return lhs.reaction < rhs.reaction
|
|
})
|
|
self.update(transition: isFirstUpdate ? .immediate : .animated(duration: 0.3, curve: .easeInOut))
|
|
isFirstUpdate = false
|
|
})
|
|
}
|
|
|
|
deinit {
|
|
self.itemsDisposable?.dispose()
|
|
}
|
|
|
|
private func update(transition: ContainedViewLayoutTransition) {
|
|
if let params = self.params {
|
|
self.update(params: params, transition: transition)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
self.params = params
|
|
self.update(params: params, transition: transition)
|
|
}
|
|
|
|
let panelHeight: CGFloat = 39.0
|
|
|
|
return LayoutResult(backgroundHeight: panelHeight, insetHeight: panelHeight, hitTestSlop: 0.0)
|
|
}
|
|
|
|
private func update(params: Params, transition: ContainedViewLayoutTransition) {
|
|
let panelHeight: CGFloat = 39.0
|
|
|
|
let containerInsets = UIEdgeInsets(top: 0.0, left: params.leftInset + 16.0, bottom: 0.0, right: params.rightInset + 16.0)
|
|
let itemSpacing: CGFloat = 26.0
|
|
|
|
var contentSize = CGSize(width: 0.0, height: panelHeight)
|
|
contentSize.width += containerInsets.left
|
|
|
|
var validIds: [MessageReaction.Reaction] = []
|
|
var isFirst = true
|
|
for item in self.items {
|
|
if isFirst {
|
|
isFirst = false
|
|
} else {
|
|
contentSize.width += itemSpacing
|
|
}
|
|
let itemId = item.reaction
|
|
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 reaction = item.reaction
|
|
itemView = ItemView(context: self.context, action: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
let tag = ReactionsMessageAttribute.messageTag(reaction: reaction)
|
|
|
|
self.interfaceInteraction?.updateHistoryFilter({ filter in
|
|
var tags: [EngineMessage.CustomTag] = filter?.customTags ?? []
|
|
if let index = tags.firstIndex(of: tag) {
|
|
tags.remove(at: index)
|
|
} else {
|
|
tags.append(tag)
|
|
}
|
|
if tags.isEmpty {
|
|
return nil
|
|
} else {
|
|
return ChatPresentationInterfaceState.HistoryFilter(customTags: tags, isActive: filter?.isActive ?? true)
|
|
}
|
|
})
|
|
|
|
if let itemView = self.itemViews[reaction] {
|
|
self.scrollView.scrollRectToVisible(itemView.frame.insetBy(dx: -46.0, dy: 0.0), animated: true)
|
|
}
|
|
}, contextGesture: { [weak self] gesture, sourceNode in
|
|
guard let self, let interfaceInteraction = self.interfaceInteraction, let chatController = interfaceInteraction.chatController() else {
|
|
gesture.cancel()
|
|
return
|
|
}
|
|
|
|
var items: [ContextMenuItem] = []
|
|
|
|
let presentationData = self.context.sharedContext.currentPresentationData.with({ $0 })
|
|
//TODO:localize
|
|
items.append(.action(ContextMenuActionItem(text: "Edit Title", icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor)
|
|
}, action: { [weak self] c, a in
|
|
guard let self else {
|
|
a(.default)
|
|
return
|
|
}
|
|
|
|
c.dismiss(completion: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.openEditTagTitle(reaction: reaction)
|
|
})
|
|
})))
|
|
|
|
let controller = ContextController(presentationData: presentationData, source: .extracted(TagContextExtractedContentSource(controller: chatController, sourceNode: sourceNode, keepInPlace: false)), items: .single(ContextController.Items(content: .list(items))), recognizer: nil, gesture: gesture)
|
|
interfaceInteraction.presentGlobalOverlayController(controller, nil)
|
|
})
|
|
self.itemViews[itemId] = itemView
|
|
self.scrollView.addSubview(itemView)
|
|
}
|
|
|
|
var isSelected = false
|
|
if let historyFilter = params.interfaceState.historyFilter {
|
|
if historyFilter.customTags.contains(ReactionsMessageAttribute.messageTag(reaction: item.reaction)) {
|
|
isSelected = true
|
|
}
|
|
}
|
|
let itemSize = itemView.update(item: item, isSelected: isSelected, theme: params.interfaceState.theme, height: panelHeight, transition: .immediate)
|
|
let itemFrame = CGRect(origin: CGPoint(x: contentSize.width, y: -5.0), size: itemSize)
|
|
|
|
itemTransition.updatePosition(layer: itemView.layer, position: itemFrame.center)
|
|
if animateIn && transition.isAnimated {
|
|
itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
|
|
transition.animateTransformScale(view: itemView, from: 0.001)
|
|
}
|
|
|
|
itemView.bounds = CGRect(origin: CGPoint(), size: itemFrame.size)
|
|
|
|
contentSize.width += itemSize.width
|
|
}
|
|
var removedIds: [MessageReaction.Reaction] = []
|
|
for (id, itemView) in self.itemViews {
|
|
if !validIds.contains(id) {
|
|
removedIds.append(id)
|
|
|
|
if transition.isAnimated {
|
|
itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak itemView] _ in
|
|
itemView?.removeFromSuperview()
|
|
})
|
|
transition.updateTransformScale(layer: itemView.layer, scale: 0.001)
|
|
} else {
|
|
itemView.removeFromSuperview()
|
|
}
|
|
}
|
|
}
|
|
for id in removedIds {
|
|
self.itemViews.removeValue(forKey: id)
|
|
}
|
|
|
|
contentSize.width += containerInsets.right
|
|
|
|
let scrollSize = CGSize(width: params.width, height: contentSize.height)
|
|
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
|
|
}
|
|
}
|
|
|
|
private func openEditTagTitle(reaction: MessageReaction.Reaction) {
|
|
let _ = (self.context.engine.stickers.savedMessageTagData()
|
|
|> take(1)
|
|
|> deliverOnMainQueue).start(next: { [weak self] savedMessageTags in
|
|
guard let self, let savedMessageTags else {
|
|
return
|
|
}
|
|
|
|
//TODO:localize
|
|
let promptController = promptController(sharedContext: self.context.sharedContext, updatedPresentationData: nil, text: "Edit Title", value: savedMessageTags.tags.first(where: { $0.reaction == reaction })?.title ?? "", characterLimit: 10, apply: { [weak self] value in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
if let value {
|
|
let _ = self.context.engine.stickers.setSavedMessageTagTitle(reaction: reaction, title: value.isEmpty ? nil : value).start()
|
|
}
|
|
})
|
|
self.interfaceInteraction?.presentController(promptController, nil)
|
|
})
|
|
}
|
|
}
|
|
|
|
private final class TagContextExtractedContentSource: ContextExtractedContentSource {
|
|
let keepInPlace: Bool
|
|
let ignoreContentTouches: Bool = true
|
|
let blurBackground: Bool = true
|
|
let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center
|
|
|
|
private let controller: ViewController
|
|
private let sourceNode: ContextExtractedContentContainingNode
|
|
|
|
init(controller: ViewController, sourceNode: ContextExtractedContentContainingNode, keepInPlace: Bool) {
|
|
self.controller = controller
|
|
self.sourceNode = sourceNode
|
|
self.keepInPlace = keepInPlace
|
|
}
|
|
|
|
func takeView() -> ContextControllerTakeViewInfo? {
|
|
return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds)
|
|
}
|
|
|
|
func putBack() -> ContextControllerPutBackViewInfo? {
|
|
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds)
|
|
}
|
|
}
|