From 1e18bb4c80242f15e9823114f0ae83007b01da6d Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Fri, 1 Mar 2024 15:39:57 +0400 Subject: [PATCH 1/2] Clear shortcut id when all remote messages are deleted --- .../Sources/AutomaticBusinessMessageSetupChatContents.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupChatContents.swift b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupChatContents.swift index 3fae6b81b8..93dc1ed4d4 100644 --- a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupChatContents.swift +++ b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupChatContents.swift @@ -65,6 +65,11 @@ final class AutomaticBusinessMessageSetupChatContents: ChatCustomContentsProtoco self.nextUpdateIsHoleFill = false self.sourceHistoryView = view + + if !view.entries.contains(where: { $0.message.id.namespace == Namespaces.Message.QuickReplyCloud }) { + self.shortcutId = nil + } + self.updateHistoryView(updateType: nextUpdateIsHoleFill ? .FillHole : .Generic) }) } From 9ce2c2dc9ea4eca975d4c1efb6741c5597915a17 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Fri, 1 Mar 2024 18:32:29 +0400 Subject: [PATCH 2/2] Business fixes --- .../Sources/State/AccountViewTracker.swift | 18 + .../Peers/ChatListFiltering.swift | 1 + .../Sources/ListActionItemComponent.swift | 2 +- .../ListItemSwipeOptionContainer/BUILD | 21 + .../ListItemSwipeOptionContainer.swift | 905 ++++++++++++++++++ .../AutomaticBusinessMessageSetupScreen.swift | 32 +- .../Sources/ChatbotSetupScreen.swift | 32 +- .../Stories/PeerListItemComponent/BUILD | 1 + .../Sources/PeerListItemComponent.swift | 119 ++- .../ChatInterfaceStateContextQueries.swift | 4 +- 10 files changed, 1127 insertions(+), 8 deletions(-) create mode 100644 submodules/TelegramUI/Components/ListItemSwipeOptionContainer/BUILD create mode 100644 submodules/TelegramUI/Components/ListItemSwipeOptionContainer/Sources/ListItemSwipeOptionContainer.swift diff --git a/submodules/TelegramCore/Sources/State/AccountViewTracker.swift b/submodules/TelegramCore/Sources/State/AccountViewTracker.swift index 28e635f2fc..8061d14d5d 100644 --- a/submodules/TelegramCore/Sources/State/AccountViewTracker.swift +++ b/submodules/TelegramCore/Sources/State/AccountViewTracker.swift @@ -348,6 +348,9 @@ public final class AccountViewTracker { var resetPeerHoleManagement: ((PeerId) -> Void)? + private var quickRepliesUpdateDisposable: Disposable? + private var quickRepliesUpdateTimestamp: Double = 0.0 + init(account: Account) { self.account = account self.accountPeerId = account.peerId @@ -373,6 +376,7 @@ public final class AccountViewTracker { self.updatedViewCountDisposables.dispose() self.updatedReactionsDisposables.dispose() self.externallyUpdatedPeerIdDisposable.dispose() + self.quickRepliesUpdateDisposable?.dispose() } func reset() { @@ -2548,6 +2552,20 @@ public final class AccountViewTracker { } } } + + public func keepQuickRepliesApproximatelyUpdated() { + self.queue.async { + guard let account = self.account else { + return + } + let timestamp = CFAbsoluteTimeGetCurrent() + if self.quickRepliesUpdateTimestamp + 16 * 60 * 60 < timestamp { + self.quickRepliesUpdateTimestamp = timestamp + self.quickRepliesUpdateDisposable?.dispose() + self.quickRepliesUpdateDisposable = _internal_keepShortcutMessagesUpdated(account: account).startStrict() + } + } + } } public final class ExtractedChatListItemCachedData: Hashable { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChatListFiltering.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChatListFiltering.swift index a51d704453..2b6554bdee 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChatListFiltering.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChatListFiltering.swift @@ -1414,6 +1414,7 @@ private func synchronizeChatListFilters(transaction: Transaction, accountPeerId: state.filters = remoteFilters state.remoteFilters = state.filters state.displayTags = remoteTagsEnabled + state.remoteDisplayTags = state.displayTags return state }) } diff --git a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift index 36fd9dc3a1..66a8d57b8d 100644 --- a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift +++ b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift @@ -197,7 +197,7 @@ public final class ListActionItemComponent: Component { contentHeight += component.contentInsets.top if component.leftIcon != nil { - contentLeftInset += 52.0 + contentLeftInset += 46.0 } let titleSize = self.title.update( diff --git a/submodules/TelegramUI/Components/ListItemSwipeOptionContainer/BUILD b/submodules/TelegramUI/Components/ListItemSwipeOptionContainer/BUILD new file mode 100644 index 0000000000..c5634af405 --- /dev/null +++ b/submodules/TelegramUI/Components/ListItemSwipeOptionContainer/BUILD @@ -0,0 +1,21 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ListItemSwipeOptionContainer", + module_name = "ListItemSwipeOptionContainer", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + "//submodules/Components/MultilineTextComponent", + "//submodules/Components/ComponentDisplayAdapters", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/ListItemSwipeOptionContainer/Sources/ListItemSwipeOptionContainer.swift b/submodules/TelegramUI/Components/ListItemSwipeOptionContainer/Sources/ListItemSwipeOptionContainer.swift new file mode 100644 index 0000000000..eeec875a32 --- /dev/null +++ b/submodules/TelegramUI/Components/ListItemSwipeOptionContainer/Sources/ListItemSwipeOptionContainer.swift @@ -0,0 +1,905 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import ComponentDisplayAdapters +import AppBundle +import MultilineTextComponent + +private let titleFontWithIcon = Font.medium(13.0) +private let titleFontWithoutIcon = Font.regular(17.0) + +private final class SwipeOptionsGestureRecognizer: UIPanGestureRecognizer { + public var validatedGesture = false + public var firstLocation: CGPoint = CGPoint() + + public var allowAnyDirection = false + public var lastVelocity: CGPoint = CGPoint() + + override public init(target: Any?, action: Selector?) { + super.init(target: target, action: action) + + if #available(iOS 13.4, *) { + self.allowedScrollTypesMask = .continuous + } + + self.maximumNumberOfTouches = 1 + } + + override public func reset() { + super.reset() + + self.validatedGesture = false + } + + public func becomeCancelled() { + self.state = .cancelled + } + + override public func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + + let touch = touches.first! + self.firstLocation = touch.location(in: self.view) + } + + override public func touchesMoved(_ touches: Set, with event: UIEvent) { + let location = touches.first!.location(in: self.view) + let translation = CGPoint(x: location.x - self.firstLocation.x, y: location.y - self.firstLocation.y) + + if !self.validatedGesture { + if !self.allowAnyDirection && translation.x > 0.0 { + self.state = .failed + } else if abs(translation.y) > 4.0 && abs(translation.y) > abs(translation.x) * 2.5 { + self.state = .failed + } else if abs(translation.x) > 4.0 && abs(translation.y) * 2.5 < abs(translation.x) { + self.validatedGesture = true + } + } + + if self.validatedGesture { + self.lastVelocity = self.velocity(in: self.view) + super.touchesMoved(touches, with: event) + } + } +} + +open class ListItemSwipeOptionContainer: UIView, UIGestureRecognizerDelegate { + public struct Option: Equatable { + public enum Icon: Equatable { + case none + case image(image: UIImage) + + public static func ==(lhs: Icon, rhs: Icon) -> Bool { + switch lhs { + case .none: + if case .none = rhs { + return true + } else { + return false + } + case let .image(lhsImage): + if case let .image(rhsImage) = rhs, lhsImage == rhsImage { + return true + } else { + return false + } + } + } + } + + public let key: AnyHashable + public let title: String + public let icon: Icon + public let color: UIColor + public let textColor: UIColor + + public init(key: AnyHashable, title: String, icon: Icon, color: UIColor, textColor: UIColor) { + self.key = key + self.title = title + self.icon = icon + self.color = color + self.textColor = textColor + } + + public static func ==(lhs: Option, rhs: Option) -> Bool { + if lhs.key != rhs.key { + return false + } + if lhs.title != rhs.title { + return false + } + if !lhs.color.isEqual(rhs.color) { + return false + } + if !lhs.textColor.isEqual(rhs.textColor) { + return false + } + if lhs.icon != rhs.icon { + return false + } + return true + } + } + + private enum OptionAlignment { + case left + case right + } + + private final class OptionView: UIView { + private let backgroundView: UIView + private let title = ComponentView() + private var iconView: UIImageView? + + private let titleString: String + private let textColor: UIColor + + private var titleSize: CGSize? + + var alignment: OptionAlignment? + var isExpanded: Bool = false + + init(title: String, icon: Option.Icon, color: UIColor, textColor: UIColor) { + self.titleString = title + self.textColor = textColor + + self.backgroundView = UIView() + + switch icon { + case let .image(image): + let iconView = UIImageView() + iconView.image = image.withRenderingMode(.alwaysTemplate) + iconView.tintColor = textColor + self.iconView = iconView + case .none: + self.iconView = nil + } + + super.init(frame: CGRect()) + + self.addSubview(self.backgroundView) + if let iconView = self.iconView { + self.addSubview(iconView) + } + self.backgroundView.backgroundColor = color + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateLayout( + isFirst: Bool, + isLeft: Bool, + baseSize: CGSize, + alignment: OptionAlignment, + isExpanded: Bool, + extendedWidth: CGFloat, + sideInset: CGFloat, + transition: Transition, + additive: Bool, + revealFactor: CGFloat, + animateIconMovement: Bool + ) { + var animateAdditive = false + if additive && !transition.animation.isImmediate && self.isExpanded != isExpanded { + animateAdditive = true + } + + let backgroundFrame: CGRect + if isFirst { + backgroundFrame = CGRect(origin: CGPoint(x: isLeft ? -400.0 : 0.0, y: 0.0), size: CGSize(width: extendedWidth + 400.0, height: baseSize.height)) + } else { + backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: extendedWidth, height: baseSize.height)) + } + let deltaX: CGFloat + if animateAdditive { + let previousFrame = self.backgroundView.frame + self.backgroundView.frame = backgroundFrame + if isLeft { + deltaX = previousFrame.width - backgroundFrame.width + } else { + deltaX = -(previousFrame.width - backgroundFrame.width) + } + if !animateIconMovement { + transition.animatePosition(view: self.backgroundView, from: CGPoint(x: deltaX, y: 0.0), to: CGPoint(), additive: true) + } + } else { + deltaX = 0.0 + transition.setFrame(view: self.backgroundView, frame: backgroundFrame) + } + + self.alignment = alignment + self.isExpanded = isExpanded + let titleSize = self.titleSize ?? CGSize(width: 32.0, height: 10.0) + var contentRect = CGRect(origin: CGPoint(), size: baseSize) + switch alignment { + case .left: + contentRect.origin.x = 0.0 + case .right: + contentRect.origin.x = extendedWidth - contentRect.width + } + + if let iconView = self.iconView, let imageSize = iconView.image?.size { + let iconOffset: CGFloat = -9.0 + let titleIconSpacing: CGFloat = 11.0 + let iconFrame = CGRect(origin: CGPoint(x: contentRect.minX + floor((baseSize.width - imageSize.width + sideInset) / 2.0), y: contentRect.midY - imageSize.height / 2.0 + iconOffset), size: imageSize) + if animateAdditive { + let iconOffsetX = animateIconMovement ? iconView.frame.minX - iconFrame.minX : deltaX + iconView.frame = iconFrame + transition.animatePosition(view: iconView, from: CGPoint(x: iconOffsetX, y: 0.0), to: CGPoint(), additive: true) + } else { + transition.setFrame(view: iconView, frame: iconFrame) + } + + let titleFrame = CGRect(origin: CGPoint(x: contentRect.minX + floor((baseSize.width - titleSize.width + sideInset) / 2.0), y: contentRect.midY + titleIconSpacing), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + if animateAdditive { + let titleOffsetX = animateIconMovement ? titleView.frame.minX - titleFrame.minX : deltaX + titleView.frame = titleFrame + transition.animatePosition(view: titleView, from: CGPoint(x: titleOffsetX, y: 0.0), to: CGPoint(), additive: true) + } else { + transition.setFrame(view: titleView, frame: titleFrame) + } + } + } else { + let titleFrame = CGRect(origin: CGPoint(x: contentRect.minX + floor((baseSize.width - titleSize.width + sideInset) / 2.0), y: contentRect.minY + floor((baseSize.height - titleSize.height) / 2.0)), size: titleSize) + + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + if animateAdditive { + let titleOffsetX = animateIconMovement ? titleView.frame.minX - titleFrame.minX : deltaX + titleView.frame = titleFrame + transition.animatePosition(view: titleView, from: CGPoint(x: titleOffsetX, y: 0.0), to: CGPoint(), additive: true) + } else { + transition.setFrame(view: titleView, frame: titleFrame) + } + } + } + } + + func calculateSize(_ constrainedSize: CGSize) -> CGSize { + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: self.titleString, font: self.iconView == nil ? titleFontWithoutIcon : titleFontWithIcon, textColor: self.textColor)) + )), + environment: {}, + containerSize: CGSize(width: 200.0, height: 100.0) + ) + self.titleSize = titleSize + + var maxWidth = titleSize.width + if let iconView = self.iconView, let image = iconView.image { + maxWidth = max(image.size.width, maxWidth) + } + return CGSize(width: max(74.0, maxWidth + 20.0), height: constrainedSize.height) + } + } + + public final class OptionsView: UIView { + private let optionSelected: (Option) -> Void + private let tapticAction: () -> Void + + private var options: [Option] = [] + private var isLeft: Bool = false + + private var optionViews: [OptionView] = [] + private var revealOffset: CGFloat = 0.0 + private var sideInset: CGFloat = 0.0 + + public init(optionSelected: @escaping (Option) -> Void, tapticAction: @escaping () -> Void) { + self.optionSelected = optionSelected + self.tapticAction = tapticAction + + super.init(frame: CGRect()) + + let gestureRecognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + gestureRecognizer.tapActionAtPoint = { _ in + return .waitForSingleTap + } + self.addGestureRecognizer(gestureRecognizer) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func setOptions(_ options: [Option], isLeft: Bool) { + if self.options != options || self.isLeft != isLeft { + self.options = options + self.isLeft = isLeft + for optionView in self.optionViews { + optionView.removeFromSuperview() + } + self.optionViews = options.map { option in + return OptionView(title: option.title, icon: option.icon, color: option.color, textColor: option.textColor) + } + if isLeft { + for optionView in self.optionViews.reversed() { + self.addSubview(optionView) + } + } else { + for optionView in self.optionViews { + self.addSubview(optionView) + } + } + } + } + + func calculateSize(_ constrainedSize: CGSize) -> CGSize { + var maxWidth: CGFloat = 0.0 + for optionView in self.optionViews { + let nodeSize = optionView.calculateSize(constrainedSize) + maxWidth = max(nodeSize.width, maxWidth) + } + return CGSize(width: maxWidth * CGFloat(self.optionViews.count), height: constrainedSize.height) + } + + public func updateRevealOffset(offset: CGFloat, sideInset: CGFloat, transition: Transition) { + self.revealOffset = offset + self.sideInset = sideInset + self.updateNodesLayout(transition: transition) + } + + private func updateNodesLayout(transition: Transition) { + let size = self.bounds.size + if size.width.isLessThanOrEqualTo(0.0) || self.optionViews.isEmpty { + return + } + let basicNodeWidth = floor((size.width - abs(self.sideInset)) / CGFloat(self.optionViews.count)) + let lastNodeWidth = size.width - basicNodeWidth * CGFloat(self.optionViews.count - 1) + let revealFactor = self.revealOffset / size.width + let boundaryRevealFactor: CGFloat + if self.optionViews.count > 2 { + boundaryRevealFactor = 1.0 + 16.0 / size.width + } else { + boundaryRevealFactor = 1.0 + basicNodeWidth / size.width + } + let startingOffset: CGFloat + if self.isLeft { + startingOffset = size.width + max(0.0, abs(revealFactor) - 1.0) * size.width + } else { + startingOffset = 0.0 + } + + var completionCount = self.optionViews.count + let intermediateCompletion = { + } + + var i = self.isLeft ? (self.optionViews.count - 1) : 0 + while i >= 0 && i < self.optionViews.count { + let optionView = self.optionViews[i] + let nodeWidth = i == (self.optionViews.count - 1) ? lastNodeWidth : basicNodeWidth + var nodeTransition = transition + var isExpanded = false + if (self.isLeft && i == 0) || (!self.isLeft && i == self.optionViews.count - 1) { + if abs(revealFactor) > boundaryRevealFactor { + isExpanded = true + } + } + if let _ = optionView.alignment, optionView.isExpanded != isExpanded { + nodeTransition = !transition.animation.isImmediate ? transition : .easeInOut(duration: 0.2) + if transition.animation.isImmediate { + self.tapticAction() + } + } + + var sideInset: CGFloat = 0.0 + if i == self.optionViews.count - 1 { + sideInset = self.sideInset + } + + let extendedWidth: CGFloat + let nodeLeftOffset: CGFloat + if isExpanded { + nodeLeftOffset = 0.0 + extendedWidth = size.width * max(1.0, abs(revealFactor)) + } else if self.isLeft { + let offset = basicNodeWidth * CGFloat(self.optionViews.count - 1 - i) + extendedWidth = (size.width - offset) * max(1.0, abs(revealFactor)) + nodeLeftOffset = startingOffset - extendedWidth - floorToScreenPixels(offset * abs(revealFactor)) + } else { + let offset = basicNodeWidth * CGFloat(i) + extendedWidth = (size.width - offset) * max(1.0, abs(revealFactor)) + nodeLeftOffset = startingOffset + floorToScreenPixels(offset * abs(revealFactor)) + } + + transition.setFrame(view: optionView, frame: CGRect(origin: CGPoint(x: nodeLeftOffset, y: 0.0), size: CGSize(width: extendedWidth, height: size.height)), completion: { _ in + completionCount -= 1 + intermediateCompletion() + }) + + var nodeAlignment: OptionAlignment + if (self.optionViews.count > 1) { + nodeAlignment = self.isLeft ? .right : .left + } else { + if self.isLeft { + nodeAlignment = isExpanded ? .right : .left + } else { + nodeAlignment = isExpanded ? .left : .right + } + } + let animateIconMovement = self.optionViews.count == 1 + optionView.updateLayout(isFirst: (self.isLeft && i == 0) || (!self.isLeft && i == self.optionViews.count - 1), isLeft: self.isLeft, baseSize: CGSize(width: nodeWidth, height: size.height), alignment: nodeAlignment, isExpanded: isExpanded, extendedWidth: extendedWidth, sideInset: sideInset, transition: nodeTransition, additive: transition.animation.isImmediate, revealFactor: revealFactor, animateIconMovement: animateIconMovement) + + if self.isLeft { + i -= 1 + } else { + i += 1 + } + } + } + + @objc private func tapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { + if case .ended = recognizer.state, let gesture = recognizer.lastRecognizedGestureAndLocation?.0, case .tap = gesture { + let location = recognizer.location(in: self) + var selectedOption: Int? + + var i = self.isLeft ? 0 : (self.optionViews.count - 1) + while i >= 0 && i < self.optionViews.count { + if self.optionViews[i].frame.contains(location) { + selectedOption = i + break + } + if self.isLeft { + i += 1 + } else { + i -= 1 + } + } + if let selectedOption { + self.optionSelected(self.options[selectedOption]) + } + } + } + + public func isDisplayingExtendedAction() -> Bool { + return self.optionViews.contains(where: { $0.isExpanded }) + } + } + + private var validLayout: (size: CGSize, leftInset: CGFloat, reftInset: CGFloat)? + + private var leftRevealView: OptionsView? + private var rightRevealView: OptionsView? + private var revealOptions: (left: [Option], right: [Option]) = ([], []) + + private var initialRevealOffset: CGFloat = 0.0 + public private(set) var revealOffset: CGFloat = 0.0 + + private var recognizer: SwipeOptionsGestureRecognizer? + private var tapRecognizer: UITapGestureRecognizer? + private var hapticFeedback: HapticFeedback? + + private var allowAnyDirection: Bool = false + + public var updateRevealOffset: ((CGFloat, Transition) -> Void)? + public var revealOptionsInteractivelyOpened: (() -> Void)? + public var revealOptionsInteractivelyClosed: (() -> Void)? + public var revealOptionSelected: ((Option, Bool) -> Void)? + + open var controlsContainer: UIView { + return self + } + + public var isDisplayingRevealedOptions: Bool { + return !self.revealOffset.isZero + } + + override public init(frame: CGRect) { + super.init(frame: frame) + + let recognizer = SwipeOptionsGestureRecognizer(target: self, action: #selector(self.revealGesture(_:))) + self.recognizer = recognizer + recognizer.delegate = self + recognizer.allowAnyDirection = self.allowAnyDirection + self.addGestureRecognizer(recognizer) + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.revealTapGesture(_:))) + self.tapRecognizer = tapRecognizer + tapRecognizer.delegate = self + self.addGestureRecognizer(tapRecognizer) + + self.disablesInteractiveTransitionGestureRecognizer = self.allowAnyDirection + + self.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in + guard let self else { + return false + } + if !self.revealOffset.isZero { + return true + } + return false + } + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + open func setRevealOptions(_ options: (left: [Option], right: [Option])) { + if self.revealOptions == options { + return + } + let previousOptions = self.revealOptions + let wasEmpty = self.revealOptions.left.isEmpty && self.revealOptions.right.isEmpty + self.revealOptions = options + let isEmpty = options.left.isEmpty && options.right.isEmpty + if options.left.isEmpty { + if let _ = self.leftRevealView { + self.recognizer?.becomeCancelled() + self.updateRevealOffsetInternal(offset: 0.0, transition: .spring(duration: 0.3)) + } + } else if previousOptions.left != options.left { + } + if options.right.isEmpty { + if let _ = self.rightRevealView { + self.recognizer?.becomeCancelled() + self.updateRevealOffsetInternal(offset: 0.0, transition: .spring(duration: 0.3)) + } + } else if previousOptions.right != options.right { + if let _ = self.rightRevealView { + } + } + if wasEmpty != isEmpty { + self.recognizer?.isEnabled = !isEmpty + } + let allowAnyDirection = !options.left.isEmpty || !self.revealOffset.isZero + if allowAnyDirection != self.allowAnyDirection { + self.allowAnyDirection = allowAnyDirection + self.recognizer?.allowAnyDirection = allowAnyDirection + self.disablesInteractiveTransitionGestureRecognizer = allowAnyDirection + } + } + + override open func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if let recognizer = self.recognizer, gestureRecognizer == self.tapRecognizer { + return abs(self.revealOffset) > 0.0 && !recognizer.validatedGesture + } else if let recognizer = self.recognizer, gestureRecognizer == self.recognizer, recognizer.numberOfTouches == 0 { + let translation = recognizer.velocity(in: recognizer.view) + if abs(translation.y) > 4.0 && abs(translation.y) > abs(translation.x) * 2.5 { + return false + } + } + return true + } + + open func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + if let recognizer = self.recognizer, otherGestureRecognizer == recognizer { + return true + } else { + return false + } + } + + open func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { + /*if gestureRecognizer === self.recognizer && otherGestureRecognizer is InteractiveTransitionGestureRecognizer { + return true + }*/ + return false + } + + @objc private func revealTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.updateRevealOffsetInternal(offset: 0.0, transition: .spring(duration: 0.3)) + self.revealOptionsInteractivelyClosed?() + } + } + + @objc private func revealGesture(_ recognizer: SwipeOptionsGestureRecognizer) { + guard let (size, _, _) = self.validLayout else { + return + } + switch recognizer.state { + case .began: + if let leftRevealView = self.leftRevealView { + let revealSize = leftRevealView.bounds.size + let location = recognizer.location(in: self) + if location.x < revealSize.width { + recognizer.becomeCancelled() + } else { + self.initialRevealOffset = self.revealOffset + } + } else if let rightRevealView = self.rightRevealView { + let revealSize = rightRevealView.bounds.size + let location = recognizer.location(in: self) + if location.x > size.width - revealSize.width { + recognizer.becomeCancelled() + } else { + self.initialRevealOffset = self.revealOffset + } + } else { + if self.revealOptions.left.isEmpty && self.revealOptions.right.isEmpty { + recognizer.becomeCancelled() + } + self.initialRevealOffset = self.revealOffset + } + case .changed: + var translation = recognizer.translation(in: self) + translation.x += self.initialRevealOffset + if self.revealOptions.left.isEmpty { + translation.x = min(0.0, translation.x) + } + if self.leftRevealView == nil && CGFloat(0.0).isLess(than: translation.x) { + self.setupAndAddLeftRevealNode() + self.revealOptionsInteractivelyOpened?() + } else if self.rightRevealView == nil && translation.x.isLess(than: 0.0) { + self.setupAndAddRightRevealNode() + self.revealOptionsInteractivelyOpened?() + } + self.updateRevealOffsetInternal(offset: translation.x, transition: .immediate) + if self.leftRevealView == nil && self.rightRevealView == nil { + self.revealOptionsInteractivelyClosed?() + } + case .ended, .cancelled: + guard let recognizer = self.recognizer else { + break + } + + if let leftRevealView = self.leftRevealView { + let velocity = recognizer.velocity(in: self) + let revealSize = leftRevealView.bounds.size + var reveal = false + if abs(velocity.x) < 100.0 { + if self.initialRevealOffset.isZero && self.revealOffset > 0.0 { + reveal = true + } else if self.revealOffset > revealSize.width { + reveal = true + } else { + reveal = false + } + } else { + if velocity.x > 0.0 { + reveal = true + } else { + reveal = false + } + } + + var selectedOption: Option? + if reveal && leftRevealView.isDisplayingExtendedAction() { + reveal = false + selectedOption = self.revealOptions.left.first + } else { + self.updateRevealOffsetInternal(offset: reveal ? revealSize.width : 0.0, transition: .spring(duration: 0.3)) + } + + if let selectedOption = selectedOption { + self.revealOptionSelected?(selectedOption, true) + } else { + if !reveal { + self.revealOptionsInteractivelyClosed?() + } + } + } else if let rightRevealView = self.rightRevealView { + let velocity = recognizer.velocity(in: self) + let revealSize = rightRevealView.bounds.size + var reveal = false + if abs(velocity.x) < 100.0 { + if self.initialRevealOffset.isZero && self.revealOffset < 0.0 { + reveal = true + } else if self.revealOffset < -revealSize.width { + reveal = true + } else { + reveal = false + } + } else { + if velocity.x < 0.0 { + reveal = true + } else { + reveal = false + } + } + + var selectedOption: Option? + if reveal && rightRevealView.isDisplayingExtendedAction() { + reveal = false + selectedOption = self.revealOptions.right.last + } else { + self.updateRevealOffsetInternal(offset: reveal ? -revealSize.width : 0.0, transition: .spring(duration: 0.3)) + } + + if let selectedOption = selectedOption { + self.revealOptionSelected?(selectedOption, true) + } else { + if !reveal { + self.revealOptionsInteractivelyClosed?() + } + } + } + default: + break + } + } + + private func setupAndAddLeftRevealNode() { + if !self.revealOptions.left.isEmpty { + let revealView = OptionsView(optionSelected: { [weak self] option in + self?.revealOptionSelected?(option, false) + }, tapticAction: { [weak self] in + self?.hapticImpact() + }) + revealView.setOptions(self.revealOptions.left, isLeft: true) + self.leftRevealView = revealView + + if let (size, leftInset, _) = self.validLayout { + var revealSize = revealView.calculateSize(CGSize(width: CGFloat.greatestFiniteMagnitude, height: size.height)) + revealSize.width += leftInset + + revealView.frame = CGRect(origin: CGPoint(x: min(self.revealOffset - revealSize.width, 0.0), y: 0.0), size: revealSize) + revealView.updateRevealOffset(offset: 0.0, sideInset: leftInset, transition: .immediate) + } + + self.controlsContainer.addSubview(revealView) + } + } + + private func setupAndAddRightRevealNode() { + if !self.revealOptions.right.isEmpty { + let revealView = OptionsView(optionSelected: { [weak self] option in + self?.revealOptionSelected?(option, false) + }, tapticAction: { [weak self] in + self?.hapticImpact() + }) + revealView.setOptions(self.revealOptions.right, isLeft: false) + self.rightRevealView = revealView + + if let (size, _, rightInset) = self.validLayout { + var revealSize = revealView.calculateSize(CGSize(width: CGFloat.greatestFiniteMagnitude, height: size.height)) + revealSize.width += rightInset + + revealView.frame = CGRect(origin: CGPoint(x: size.width + max(self.revealOffset, -revealSize.width), y: 0.0), size: revealSize) + revealView.updateRevealOffset(offset: 0.0, sideInset: -rightInset, transition: .immediate) + } + + self.controlsContainer.addSubview(revealView) + } + } + + public func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat) { + self.validLayout = (size, leftInset, rightInset) + + if let leftRevealView = self.leftRevealView { + var revealSize = leftRevealView.calculateSize(CGSize(width: CGFloat.greatestFiniteMagnitude, height: size.height)) + revealSize.width += leftInset + leftRevealView.frame = CGRect(origin: CGPoint(x: min(self.revealOffset - revealSize.width, 0.0), y: 0.0), size: revealSize) + } + + if let rightRevealView = self.rightRevealView { + var revealSize = rightRevealView.calculateSize(CGSize(width: CGFloat.greatestFiniteMagnitude, height: size.height)) + revealSize.width += rightInset + rightRevealView.frame = CGRect(origin: CGPoint(x: size.width + max(self.revealOffset, -revealSize.width), y: 0.0), size: revealSize) + } + } + + open func updateRevealOffsetInternal(offset: CGFloat, transition: Transition, completion: (() -> Void)? = nil) { + self.revealOffset = offset + guard let (size, leftInset, rightInset) = self.validLayout else { + return + } + + var leftRevealCompleted = true + var rightRevealCompleted = true + let intermediateCompletion = { + if leftRevealCompleted && rightRevealCompleted { + completion?() + } + } + + if let leftRevealView = self.leftRevealView { + leftRevealCompleted = false + + let revealSize = leftRevealView.bounds.size + + let revealFrame = CGRect(origin: CGPoint(x: min(self.revealOffset - revealSize.width, 0.0), y: 0.0), size: revealSize) + let revealNodeOffset = -self.revealOffset + leftRevealView.updateRevealOffset(offset: revealNodeOffset, sideInset: leftInset, transition: transition) + + if CGFloat(offset).isLessThanOrEqualTo(0.0) { + self.leftRevealView = nil + transition.setFrame(view: leftRevealView, frame: revealFrame, completion: { [weak leftRevealView] _ in + leftRevealView?.removeFromSuperview() + + leftRevealCompleted = true + intermediateCompletion() + }) + } else { + transition.setFrame(view: leftRevealView, frame: revealFrame, completion: { _ in + leftRevealCompleted = true + intermediateCompletion() + }) + } + } + if let rightRevealView = self.rightRevealView { + rightRevealCompleted = false + + let revealSize = rightRevealView.bounds.size + + let revealFrame = CGRect(origin: CGPoint(x: min(size.width, size.width + self.revealOffset), y: 0.0), size: revealSize) + let revealNodeOffset = -self.revealOffset + rightRevealView.updateRevealOffset(offset: revealNodeOffset, sideInset: -rightInset, transition: transition) + + if CGFloat(0.0).isLessThanOrEqualTo(offset) { + self.rightRevealView = nil + transition.setFrame(view: rightRevealView, frame: revealFrame, completion: { [weak rightRevealView] _ in + rightRevealView?.removeFromSuperview() + + rightRevealCompleted = true + intermediateCompletion() + }) + } else { + transition.setFrame(view: rightRevealView, frame: revealFrame, completion: { _ in + rightRevealCompleted = true + intermediateCompletion() + }) + } + } + let allowAnyDirection = !self.revealOptions.left.isEmpty || !offset.isZero + if allowAnyDirection != self.allowAnyDirection { + self.allowAnyDirection = allowAnyDirection + self.recognizer?.allowAnyDirection = allowAnyDirection + self.disablesInteractiveTransitionGestureRecognizer = allowAnyDirection + } + + self.updateRevealOffset?(offset, transition) + } + + open func setRevealOptionsOpened(_ value: Bool, animated: Bool) { + if value != !self.revealOffset.isZero { + if !self.revealOffset.isZero { + self.recognizer?.becomeCancelled() + } + let transition: Transition + if animated { + transition = .spring(duration: 0.3) + } else { + transition = .immediate + } + if value { + if self.rightRevealView == nil { + self.setupAndAddRightRevealNode() + if let rightRevealView = self.rightRevealView, let validLayout = self.validLayout { + let revealSize = rightRevealView.calculateSize(CGSize(width: CGFloat.greatestFiniteMagnitude, height: validLayout.size.height)) + self.updateRevealOffsetInternal(offset: -revealSize.width, transition: transition) + } + } + } else if !self.revealOffset.isZero { + self.updateRevealOffsetInternal(offset: 0.0, transition: transition) + } + } + } + + open func animateRevealOptionsFill(completion: (() -> Void)? = nil) { + if let validLayout = self.validLayout { + self.layer.allowsGroupOpacity = true + self.updateRevealOffsetInternal(offset: -validLayout.0.width - 74.0, transition: .spring(duration: 0.3), completion: { + self.layer.allowsGroupOpacity = false + completion?() + }) + } + } + + open var preventsTouchesToOtherItems: Bool { + return self.isDisplayingRevealedOptions + } + + open func touchesToOtherItemsPrevented() { + if self.isDisplayingRevealedOptions { + self.setRevealOptionsOpened(false, animated: true) + } + } + + private func hapticImpact() { + if self.hapticFeedback == nil { + self.hapticFeedback = HapticFeedback() + } + self.hapticFeedback?.impact(.medium) + } +} diff --git a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupScreen.swift b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupScreen.swift index 60c6d9ec49..2b10c46039 100644 --- a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupScreen.swift @@ -1329,7 +1329,21 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component { selectionState: .none, hasNext: false, action: { peer, _, _ in - } + }, + inlineActions: PeerListItemComponent.InlineActionsState( + actions: [PeerListItemComponent.InlineAction( + id: AnyHashable(0), + title: environment.strings.Common_Delete, + color: .destructive, + action: { [weak self] in + guard let self else { + return + } + self.additionalPeerList.categories.remove(category) + self.state?.updated(transition: .spring(duration: 0.4)) + } + )] + ) )))) } for peer in self.additionalPeerList.peers { @@ -1347,7 +1361,21 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component { selectionState: .none, hasNext: false, action: { peer, _, _ in - } + }, + inlineActions: PeerListItemComponent.InlineActionsState( + actions: [PeerListItemComponent.InlineAction( + id: AnyHashable(0), + title: environment.strings.Common_Delete, + color: .destructive, + action: { [weak self] in + guard let self else { + return + } + self.additionalPeerList.peers.removeAll(where: { $0.peer.id == peer.peer.id }) + self.state?.updated(transition: .spring(duration: 0.4)) + } + )] + ) )))) } diff --git a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift index 5d8ce9ca47..02dc866138 100644 --- a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift @@ -827,7 +827,21 @@ final class ChatbotSetupScreenComponent: Component { selectionState: .none, hasNext: false, action: { peer, _, _ in - } + }, + inlineActions: PeerListItemComponent.InlineActionsState( + actions: [PeerListItemComponent.InlineAction( + id: AnyHashable(0), + title: environment.strings.Common_Delete, + color: .destructive, + action: { [weak self] in + guard let self else { + return + } + self.additionalPeerList.categories.remove(category) + self.state?.updated(transition: .spring(duration: 0.4)) + } + )] + ) )))) } for peer in self.additionalPeerList.peers { @@ -845,7 +859,21 @@ final class ChatbotSetupScreenComponent: Component { selectionState: .none, hasNext: false, action: { peer, _, _ in - } + }, + inlineActions: PeerListItemComponent.InlineActionsState( + actions: [PeerListItemComponent.InlineAction( + id: AnyHashable(0), + title: environment.strings.Common_Delete, + color: .destructive, + action: { [weak self] in + guard let self else { + return + } + self.additionalPeerList.peers.removeAll(where: { $0.peer.id == peer.peer.id }) + self.state?.updated(transition: .spring(duration: 0.4)) + } + )] + ) )))) } diff --git a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/BUILD b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/BUILD index e72f30df9d..2841b2a498 100644 --- a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/BUILD +++ b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/BUILD @@ -30,6 +30,7 @@ swift_library( "//submodules/TextFormat", "//submodules/PhotoResources", "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/TelegramUI/Components/ListItemSwipeOptionContainer", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift index d7be7ac19f..f23ef6eb2a 100644 --- a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift +++ b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift @@ -1,4 +1,5 @@ import Foundation +import Foundation import UIKit import Display import AsyncDisplayKit @@ -20,6 +21,7 @@ import EmojiTextAttachmentView import TextFormat import PhotoResources import ListSectionComponent +import ListItemSwipeOptionContainer private let avatarFont = avatarPlaceholderFont(size: 15.0) private let readIconImage: UIImage? = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/MenuReadIcon"), color: .white)?.withRenderingMode(.alwaysTemplate) @@ -77,6 +79,58 @@ public final class PeerListItemComponent: Component { } } + public final class InlineAction: Equatable { + public enum Color: Equatable { + case destructive + } + + public let id: AnyHashable + public let title: String + public let color: Color + public let action: () -> Void + + public init(id: AnyHashable, title: String, color: Color, action: @escaping () -> Void) { + self.id = id + self.title = title + self.color = color + self.action = action + } + + public static func ==(lhs: InlineAction, rhs: InlineAction) -> Bool { + if lhs === rhs { + return true + } + if lhs.id != rhs.id { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.color != rhs.color { + return false + } + return true + } + } + + public final class InlineActionsState: Equatable { + public let actions: [InlineAction] + + public init(actions: [InlineAction]) { + self.actions = actions + } + + public static func ==(lhs: InlineActionsState, rhs: InlineActionsState) -> Bool { + if lhs === rhs { + return true + } + if lhs.actions != rhs.actions { + return false + } + return true + } + } + public final class Reaction: Equatable { public let reaction: MessageReaction.Reaction public let file: TelegramMediaFile? @@ -131,6 +185,7 @@ public final class PeerListItemComponent: Component { let isEnabled: Bool let hasNext: Bool let action: (EnginePeer, EngineMessage.Id?, UIView?) -> Void + let inlineActions: InlineActionsState? let contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)? let openStories: ((EnginePeer, AvatarNode) -> Void)? @@ -156,6 +211,7 @@ public final class PeerListItemComponent: Component { isEnabled: Bool = true, hasNext: Bool, action: @escaping (EnginePeer, EngineMessage.Id?, UIView?) -> Void, + inlineActions: InlineActionsState? = nil, contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)? = nil, openStories: ((EnginePeer, AvatarNode) -> Void)? = nil ) { @@ -180,6 +236,7 @@ public final class PeerListItemComponent: Component { self.isEnabled = isEnabled self.hasNext = hasNext self.action = action + self.inlineActions = inlineActions self.contextAction = contextAction self.openStories = openStories } @@ -245,6 +302,9 @@ public final class PeerListItemComponent: Component { if lhs.hasNext != rhs.hasNext { return false } + if lhs.inlineActions != rhs.inlineActions { + return false + } return true } @@ -252,6 +312,8 @@ public final class PeerListItemComponent: Component { private let extractedContainerView: ContextExtractedContentContainingView private let containerButton: HighlightTrackingButton + private let swipeOptionContainer: ListItemSwipeOptionContainer + private let title = ComponentView() private let label = ComponentView() private let separatorLayer: SimpleLayer @@ -306,8 +368,11 @@ public final class PeerListItemComponent: Component { self.extractedContainerView = ContextExtractedContentContainingView() self.containerButton = HighlightTrackingButton() + self.containerButton.layer.anchorPoint = CGPoint() self.containerButton.isExclusiveTouch = true + self.swipeOptionContainer = ListItemSwipeOptionContainer(frame: CGRect()) + self.avatarNode = AvatarNode(font: avatarFont) self.avatarNode.isLayerBacked = false self.avatarNode.isUserInteractionEnabled = false @@ -319,7 +384,9 @@ public final class PeerListItemComponent: Component { self.addSubview(self.extractedContainerView) self.targetViewForActivationProgress = self.extractedContainerView.contentView - self.extractedContainerView.contentView.addSubview(self.containerButton) + self.extractedContainerView.contentView.addSubview(self.swipeOptionContainer) + + self.swipeOptionContainer.addSubview(self.containerButton) self.layer.addSublayer(self.separatorLayer) self.containerButton.layer.addSublayer(self.avatarNode.layer) @@ -368,6 +435,25 @@ public final class PeerListItemComponent: Component { customUpdateIsHighlighted(highlighted) } } + + self.swipeOptionContainer.updateRevealOffset = { [weak self] offset, transition in + guard let self else { + return + } + transition.setBounds(view: self.containerButton, bounds: CGRect(origin: CGPoint(x: -offset, y: 0.0), size: self.containerButton.bounds.size)) + } + self.swipeOptionContainer.revealOptionSelected = { [weak self] option, animated in + guard let self, let component = self.component else { + return + } + guard let inlineActions = component.inlineActions else { + return + } + self.swipeOptionContainer.setRevealOptionsOpened(false, animated: animated) + if let inlineAction = inlineActions.actions.first(where: { $0.id == option.key }) { + inlineAction.action() + } + } } required init?(coder: NSCoder) { @@ -1007,10 +1093,39 @@ public final class PeerListItemComponent: Component { self.extractedContainerView.contentRect = resultBounds let containerFrame = CGRect(origin: CGPoint(x: contextInset, y: verticalInset), size: CGSize(width: availableSize.width - contextInset * 2.0, height: height - verticalInset * 2.0)) - transition.setFrame(view: self.containerButton, frame: containerFrame) + + let swipeOptionContainerFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: height)) + transition.setFrame(view: self.swipeOptionContainer, frame: swipeOptionContainerFrame) + + transition.setPosition(view: self.containerButton, position: containerFrame.origin) + transition.setBounds(view: self.containerButton, bounds: CGRect(origin: self.containerButton.bounds.origin, size: containerFrame.size)) self.separatorInset = leftInset + self.swipeOptionContainer.updateLayout(size: swipeOptionContainerFrame.size, leftInset: 0.0, rightInset: 0.0) + + var rightOptions: [ListItemSwipeOptionContainer.Option] = [] + if let inlineActions = component.inlineActions { + rightOptions = inlineActions.actions.map { action in + let color: UIColor + let textColor: UIColor + switch action.color { + case .destructive: + color = component.theme.list.itemDisclosureActions.destructive.fillColor + textColor = component.theme.list.itemDisclosureActions.destructive.foregroundColor + } + + return ListItemSwipeOptionContainer.Option( + key: action.id, + title: action.title, + icon: .none, + color: color, + textColor: textColor + ) + } + } + self.swipeOptionContainer.setRevealOptions(([], rightOptions)) + return CGSize(width: availableSize.width, height: height) } } diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextQueries.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextQueries.swift index 438efeff40..d9ed4448c4 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextQueries.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextQueries.swift @@ -234,7 +234,9 @@ private func updatedContextQueryResultStateForQuery(context: AccountContext, pee } var shortcuts: Signal<[ShortcutMessageList.Item], NoError> = .single([]) - if peer is TelegramUser { + if let user = peer as? TelegramUser, user.botInfo == nil { + context.account.viewTracker.keepQuickRepliesApproximatelyUpdated() + shortcuts = context.engine.accountData.shortcutMessageList() |> map { shortcutMessageList -> [ShortcutMessageList.Item] in return shortcutMessageList.items.filter { item in