Business features

This commit is contained in:
Isaac 2024-03-19 22:59:37 +04:00
parent 4b1b272081
commit b25f8ed37f
16 changed files with 920 additions and 398 deletions

View File

@ -11362,7 +11362,7 @@ Sorry for the inconvenience.";
"Business.QuickReplies" = "Quick Replies";
"Business.GreetingMessages" = "Greeting Messages";
"Business.AwayMessages" = "Away Messages";
"Business.Chatbots" = "Chatbots";
"Business.ChatbotsItem" = "Chatbots";
"Business.Intro" = "Intro";
"Business.LocationInfo" = "Display the location of your business on your account.";
@ -11409,6 +11409,7 @@ Sorry for the inconvenience.";
"ChatList.ItemMenuEdit" = "Edit";
"ChatList.ItemMenuDelete" = "Delete";
"ChatList.PeerTypeNonContact" = "non-contact";
"ChatList.PeerTypeNonContactUser" = "non-contact";
"ChatListFilter.TagLabelNoTag" = "NO TAG";
"ChatListFilter.TagLabelPremiumExpired" = "PREMIUM EXPIRED";
@ -11575,7 +11576,7 @@ Sorry for the inconvenience.";
"BusinessLocationSetup.AlertUnsavedChanges.Text" = "You have unsaved changes.";
"BusinessLocationSetup.AlertUnsavedChanges.ResetAction" = "Revert";
"ChatbotSetup.Title" = "Chatbots";
"ChatbotSetup.TitleItem" = "Chatbots";
"ChatbotSetup.Text" = "Add a bot to your account to help you automatically process and respond to the messages you receive. [Learn More >]()";
"ChatbotSetup.TextLink" = "https://telegram.org";

View File

@ -4165,14 +4165,14 @@ private func statusStringForPeerType(accountPeerId: EnginePeer.Id, strings: Pres
if isContact {
return (strings.ChatList_PeerTypeContact, false, false, nil)
} else {
return (strings.ChatList_PeerTypeNonContact, false, false, nil)
return (strings.ChatList_PeerTypeNonContactUser, false, false, nil)
}
}
} else if case .secretChat = peer {
if isContact {
return (strings.ChatList_PeerTypeContact, false, false, nil)
} else {
return (strings.ChatList_PeerTypeNonContact, false, false, nil)
return (strings.ChatList_PeerTypeNonContactUser, false, false, nil)
}
} else if case .legacyGroup = peer {
return (strings.ChatList_PeerTypeGroup, false, false, nil)
@ -4183,7 +4183,7 @@ private func statusStringForPeerType(accountPeerId: EnginePeer.Id, strings: Pres
return (strings.ChatList_PeerTypeChannel, false, false, nil)
}
}
return (strings.ChatList_PeerTypeNonContact, false, false, nil)
return (strings.ChatList_PeerTypeNonContactUser, false, false, nil)
}
public class ChatHistoryListSelectionRecognizer: UIPanGestureRecognizer {

View File

@ -633,7 +633,7 @@ public enum PremiumPerk: CaseIterable {
case .businessAwayMessage:
return strings.Business_AwayMessages
case .businessChatBots:
return strings.Business_Chatbots
return strings.Business_ChatbotsItem
case .businessIntro:
return strings.Business_Intro
}

View File

@ -971,7 +971,7 @@ public class PremiumLimitsListScreen: ViewController {
videoFile: videos["business_bots"],
decoration: .business
)),
title: strings.Business_Chatbots,
title: strings.Business_ChatbotsItem,
text: strings.Business_ChatbotsInfo,
textColor: textColor
)

View File

@ -600,7 +600,7 @@ func messageTextEntitiesFromApiEntities(_ entities: [Api.MessageEntity]) -> [Mes
extension StoreMessage {
convenience init?(apiMessage: Api.Message, accountPeerId: PeerId, peerIsForum: Bool, namespace: MessageId.Namespace = Namespaces.Message.Cloud) {
switch apiMessage {
case let .message(flags, _, id, fromId, boosts, chatPeerId, savedPeerId, fwdFrom, viaBotId, viaBusinessBotId, replyTo, date, message, media, replyMarkup, entities, views, forwards, replies, editDate, postAuthor, groupingId, reactions, restrictionReason, ttlPeriod, quickReplyShortcutId):
case let .message(flags, flags2, id, fromId, boosts, chatPeerId, savedPeerId, fwdFrom, viaBotId, viaBusinessBotId, replyTo, date, message, media, replyMarkup, entities, views, forwards, replies, editDate, postAuthor, groupingId, reactions, restrictionReason, ttlPeriod, quickReplyShortcutId):
let resolvedFromId = fromId?.peerId ?? chatPeerId.peerId
var namespace = namespace
@ -906,7 +906,7 @@ extension StoreMessage {
storeFlags.insert(.IsForumTopic)
}
if (flags & (1 << 4)) != 0 || (flags & (1 << 13)) != 0 {
if (flags & (1 << 4)) != 0 || (flags & (1 << 13)) != 0 || (flags2 & (1 << 1)) != 0 {
var notificationFlags: NotificationInfoMessageAttributeFlags = []
if (flags & (1 << 4)) != 0 {
notificationFlags.insert(.personal)
@ -916,6 +916,9 @@ extension StoreMessage {
if (flags & (1 << 13)) != 0 {
notificationFlags.insert(.muted)
}
if (flags2 & (1 << 1)) != 0 {
notificationFlags.insert(.automaticMessage)
}
attributes.append(NotificationInfoMessageAttribute(flags: notificationFlags))
}

View File

@ -3452,7 +3452,17 @@ func replayFinalState(
if message.flags.contains(.Incoming) {
addedOperationIncomingMessageIds.append(id)
if let authorId = message.authorId {
recordPeerActivityTimestamp(peerId: authorId, timestamp: message.timestamp, into: &peerActivityTimestamps)
var isAutomatic = false
for attribute in message.attributes {
if let attribute = attribute as? NotificationInfoMessageAttribute {
if attribute.flags.contains(.automaticMessage) {
isAutomatic = true
}
}
}
if !isAutomatic {
recordPeerActivityTimestamp(peerId: authorId, timestamp: message.timestamp, into: &peerActivityTimestamps)
}
}
}
if message.flags.contains(.WasScheduled) {

View File

@ -12,9 +12,9 @@ public struct NotificationInfoMessageAttributeFlags: OptionSet {
self.rawValue = 0
}
public static let muted = NotificationInfoMessageAttributeFlags(rawValue: 1)
public static let personal = NotificationInfoMessageAttributeFlags(rawValue: 2)
public static let muted = NotificationInfoMessageAttributeFlags(rawValue: 1 << 0)
public static let personal = NotificationInfoMessageAttributeFlags(rawValue: 1 << 1)
public static let automaticMessage = NotificationInfoMessageAttributeFlags(rawValue: 1 << 2)
}
public class NotificationInfoMessageAttribute: MessageAttribute {

View File

@ -1613,34 +1613,6 @@ public extension TelegramEngine.EngineData.Item {
}
}
public struct BusinessIntro: TelegramEngineDataItem, TelegramEngineMapKeyDataItem, PostboxViewDataItem {
public typealias Result = CachedTelegramBusinessIntro?
fileprivate var id: EnginePeer.Id
public var mapKey: EnginePeer.Id {
return self.id
}
public init(id: EnginePeer.Id) {
self.id = id
}
var key: PostboxViewKey {
return .cachedPeerData(peerId: self.id)
}
func extract(view: PostboxView) -> Result {
guard let view = view as? CachedPeerDataView else {
preconditionFailure()
}
if let cachedData = view.cachedPeerData as? CachedUserData {
return cachedData.businessIntro
} else {
return nil
}
}
}
public struct ChatManagingBot: TelegramEngineDataItem, TelegramEngineMapKeyDataItem, PostboxViewDataItem {
public typealias Result = PeerStatusSettings.ManagingBot?

View File

@ -371,6 +371,9 @@ func _internal_toggleChatManagingBotIsPaused(account: Account, chatId: EnginePee
if let managingBot = peerStatusSettings.managingBot {
isPaused = !managingBot.isPaused
peerStatusSettings.managingBot?.isPaused = isPaused
if !isPaused {
peerStatusSettings.managingBot?.canReply = true
}
}
return current.withUpdatedPeerStatusSettings(peerStatusSettings)
@ -412,6 +415,35 @@ func _internal_removeChatManagingBot(account: Account, chatId: EnginePeer.Id) ->
return current
}
})
transaction.updatePeerCachedData(peerIds: Set([account.peerId]), update: { _, current in
guard let current = current as? CachedUserData else {
return current
}
if let connectedBot = current.connectedBot {
var additionalPeers = connectedBot.recipients.additionalPeers
var excludePeers = connectedBot.recipients.excludePeers
if connectedBot.recipients.exclude {
additionalPeers.insert(chatId)
} else {
additionalPeers.remove(chatId)
excludePeers.insert(chatId)
}
return current.withUpdatedConnectedBot(TelegramAccountConnectedBot(
id: connectedBot.id,
recipients: TelegramBusinessRecipients(
categories: connectedBot.recipients.categories,
additionalPeers: additionalPeers,
excludePeers: excludePeers,
exclude: connectedBot.recipients.exclude
),
canReply: connectedBot.canReply
))
} else {
return current
}
})
}
|> mapToSignal { _ -> Signal<Never, NoError> in
return account.postbox.transaction { transaction -> Api.InputPeer? in

View File

@ -811,7 +811,7 @@ extension TelegramBusinessRecipients {
self.init(
categories: categories,
additionalPeers: Set((users ?? []).map( { PeerId(namespace: Namespaces.Peer.CloudUser, id: ._internalFromInt64Value($0)) })),
excludePeers: Set((excludeUsers ?? []).map(PeerId.init)),
excludePeers: Set((excludeUsers ?? []).map( { PeerId(namespace: Namespaces.Peer.CloudUser, id: ._internalFromInt64Value($0)) })),
exclude: (flags & (1 << 5)) != 0
)
}

View File

@ -1175,6 +1175,250 @@ private enum ChatEmptyNodeContentType: Equatable {
case premiumRequired
}
private final class EmptyAttachedDescriptionNode: HighlightTrackingButtonNode {
private struct Params: Equatable {
var theme: PresentationTheme
var strings: PresentationStrings
var chatWallpaper: TelegramWallpaper
var peer: EnginePeer
var constrainedSize: CGSize
init(theme: PresentationTheme, strings: PresentationStrings, chatWallpaper: TelegramWallpaper, peer: EnginePeer, constrainedSize: CGSize) {
self.theme = theme
self.strings = strings
self.chatWallpaper = chatWallpaper
self.peer = peer
self.constrainedSize = constrainedSize
}
static func ==(lhs: Params, rhs: Params) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.chatWallpaper != rhs.chatWallpaper {
return false
}
if lhs.constrainedSize != rhs.constrainedSize {
return false
}
return true
}
}
private struct Layout {
var params: Params
var size: CGSize
init(params: Params, size: CGSize) {
self.params = params
self.size = size
}
}
private let textNode: ImmediateTextNode
private var backgroundContent: WallpaperBubbleBackgroundNode?
private let textMaskNode: LinkHighlightingNode
private let badgeTextNode: ImmediateTextNode
private let badgeBackgroundView: UIImageView
private var currentLayout: Layout?
var action: (() -> Void)?
override init(pointerStyle: PointerStyle? = nil) {
self.textNode = ImmediateTextNode()
self.textNode.textAlignment = .center
self.textNode.maximumNumberOfLines = 0
self.textMaskNode = LinkHighlightingNode(color: .white)
self.textMaskNode.innerRadius = 5.0
self.textMaskNode.outerRadius = 10.0
self.textMaskNode.inset = 0.0
self.badgeTextNode = ImmediateTextNode()
self.badgeBackgroundView = UIImageView()
super.init(pointerStyle: pointerStyle)
self.addSubnode(self.textNode)
self.view.addSubview(self.badgeBackgroundView)
self.addSubnode(self.badgeTextNode)
self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside)
self.highligthedChanged = { [weak self] highlighted in
if let self, self.bounds.width > 0.0 {
let animateScale = true
let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width
let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width
if highlighted {
self.layer.removeAnimation(forKey: "transform.scale")
if animateScale {
let transition = Transition(animation: .curve(duration: 0.2, curve: .easeInOut))
transition.setScale(layer: self.layer, scale: topScale)
}
} else {
if animateScale {
let transition = Transition(animation: .none)
transition.setScale(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)
})
}
}
}
}
}
@objc private func pressed() {
self.action?()
}
func update(
theme: PresentationTheme,
strings: PresentationStrings,
chatWallpaper: TelegramWallpaper,
peer: EnginePeer,
wallpaperBackgroundNode: WallpaperBackgroundNode?,
constrainedSize: CGSize
) -> CGSize {
let params = Params(
theme: theme,
strings: strings,
chatWallpaper: chatWallpaper,
peer: peer,
constrainedSize: constrainedSize
)
if let currentLayout = self.currentLayout, currentLayout.params == params {
return currentLayout.size
} else {
let size = self.updateInternal(params: params, wallpaperBackgroundNode: wallpaperBackgroundNode)
self.currentLayout = Layout(params: params, size: size)
return size
}
}
private func updateInternal(params: Params, wallpaperBackgroundNode: WallpaperBackgroundNode?) -> CGSize {
let serviceColor = serviceMessageColorComponents(theme: params.theme, wallpaper: params.chatWallpaper)
//TODO:localize
let textString = NSMutableAttributedString()
textString.append(NSAttributedString(string: "\(params.peer.compactDisplayTitle) added the message above for all empty chats", font: Font.regular(13.0), textColor: serviceColor.primaryText))
textString.append(NSAttributedString(string: " .how?", font: Font.regular(11.0), textColor: .clear))
self.textNode.attributedText = textString
let maxTextSize = CGSize(width: min(300.0, params.constrainedSize.width - 8.0 * 2.0), height: params.constrainedSize.height - 8.0 * 2.0)
var bestSize: (availableWidth: CGFloat, info: TextNodeLayout)
let info = self.textNode.updateLayoutFullInfo(maxTextSize)
bestSize = (maxTextSize.width, info)
if info.numberOfLines > 1 {
let measureIncrement = 8.0
var measureWidth = info.size.width
measureWidth -= measureIncrement
while measureWidth > 0.0 {
let otherInfo = self.textNode.updateLayoutFullInfo(CGSize(width: measureWidth, height: maxTextSize.height))
if otherInfo.numberOfLines > bestSize.info.numberOfLines {
break
}
if (otherInfo.size.width - otherInfo.trailingLineWidth) < (bestSize.info.size.width - bestSize.info.trailingLineWidth) {
bestSize = (measureWidth, otherInfo)
}
measureWidth -= measureIncrement
}
let bestInfo = self.textNode.updateLayoutFullInfo(CGSize(width: bestSize.availableWidth, height: maxTextSize.height))
bestSize = (maxTextSize.width, bestInfo)
}
let textLayout = bestSize.info
var labelRects = textLayout.linesRects()
if labelRects.count > 1 {
let sortedIndices = (0 ..< labelRects.count).sorted(by: { labelRects[$0].width > labelRects[$1].width })
for i in 0 ..< sortedIndices.count {
let index = sortedIndices[i]
for j in -1 ... 1 {
if j != 0 && index + j >= 0 && index + j < sortedIndices.count {
if abs(labelRects[index + j].width - labelRects[index].width) < 16.0 {
labelRects[index + j].size.width = max(labelRects[index + j].width, labelRects[index].width)
labelRects[index].size.width = labelRects[index + j].size.width
}
}
}
}
}
for i in 0 ..< labelRects.count {
labelRects[i] = labelRects[i].insetBy(dx: -6.0, dy: floor((labelRects[i].height - 20.0) / 2.0))
labelRects[i].size.height = 20.0
labelRects[i].origin.x = floor((textLayout.size.width - labelRects[i].width) / 2.0)
}
self.textMaskNode.updateRects(labelRects)
let size = CGSize(width: textLayout.size.width + 4.0 * 2.0, height: textLayout.size.height + 4.0 * 2.0)
let textFrame = CGRect(origin: CGPoint(x: 4.0, y: 4.0), size: textLayout.size)
self.textNode.frame = textFrame
//TODO:localize
self.badgeTextNode.attributedText = NSAttributedString(string: "how?", font: Font.regular(11.0), textColor: serviceColor.primaryText)
let badgeTextSize = self.badgeTextNode.updateLayout(CGSize(width: 200.0, height: 100.0))
if let lastLineFrame = labelRects.last {
let badgeTextFrame = CGRect(origin: CGPoint(x: lastLineFrame.maxX - badgeTextSize.width - 2.0, y: textFrame.maxY - badgeTextSize.height), size: badgeTextSize)
self.badgeTextNode.frame = badgeTextFrame
let badgeBackgroundFrame = badgeTextFrame.insetBy(dx: -4.0, dy: -1.0)
if badgeBackgroundFrame.height != self.badgeBackgroundView.image?.size.height {
self.badgeBackgroundView.image = generateStretchableFilledCircleImage(diameter: badgeBackgroundFrame.height, color: serviceColor.primaryText.withMultipliedAlpha(0.1))
}
self.badgeBackgroundView.frame = badgeBackgroundFrame
}
self.textMaskNode.frame = CGRect(origin: CGPoint(x: textFrame.minX - self.textMaskNode.inset + 4.0, y: textFrame.minY - self.textMaskNode.inset - 11.0), size: CGSize())
if let wallpaperBackgroundNode {
if self.backgroundContent == nil, let backgroundContent = wallpaperBackgroundNode.makeBubbleBackground(for: .free) {
self.backgroundContent = backgroundContent
backgroundContent.view.mask = self.textMaskNode.view
self.insertSubnode(backgroundContent, at: 0)
}
if let backgroundContent = self.backgroundContent {
backgroundContent.frame = CGRect(origin: CGPoint(x: -4.0, y: 0.0), size: CGSize(width: size.width + 4.0 * 2.0, height: size.height))
}
} else if let backgroundContent = self.backgroundContent {
self.backgroundContent = nil
backgroundContent.removeFromSupernode()
}
return size
}
func updateAbsolutePosition(rect: CGRect, containerSize: CGSize, transition: ContainedViewLayoutTransition) {
guard let backgroundContent = self.backgroundContent else {
return
}
var backgroundFrame = backgroundContent.frame
backgroundFrame.origin.x += rect.minX
backgroundFrame.origin.y += rect.minY
backgroundContent.update(rect: backgroundFrame, within: containerSize, transition: transition)
}
}
public final class ChatEmptyNode: ASDisplayNode {
public enum Subject {
public enum EmptyType: Equatable {
@ -1203,6 +1447,7 @@ public final class ChatEmptyNode: ASDisplayNode {
private var currentStrings: PresentationStrings?
private var content: (ChatEmptyNodeContentType, ASDisplayNode & ChatEmptyNodeContent)?
private var attachedDescriptionNode: EmptyAttachedDescriptionNode?
public init(context: AccountContext, interaction: ChatPanelInterfaceInteraction?) {
self.context = context
@ -1247,6 +1492,12 @@ public final class ChatEmptyNode: ASDisplayNode {
backgroundContent.cornerRadius = initialFrame.size.width / 2.0
transition.updateCornerRadius(layer: backgroundContent.layer, cornerRadius: targetCornerRadius)
}
if let attachedDescriptionNode = self.attachedDescriptionNode {
attachedDescriptionNode.layer.animatePosition(from: initialFrame.center, to: attachedDescriptionNode.position, duration: duration, timingFunction: kCAMediaTimingFunctionSpring)
attachedDescriptionNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
attachedDescriptionNode.layer.animateScale(from: 0.001, to: 1.0, duration: duration, timingFunction: kCAMediaTimingFunctionSpring)
}
}
public func updateLayout(interfaceState: ChatPresentationInterfaceState, subject: Subject, loadingNode: ChatLoadingNode?, backgroundNode: WallpaperBackgroundNode?, size: CGSize, insets: UIEdgeInsets, transition: ContainedViewLayoutTransition) {
@ -1265,6 +1516,7 @@ public final class ChatEmptyNode: ASDisplayNode {
}
let contentType: ChatEmptyNodeContentType
var displayAttachedDescription = false
switch subject {
case .detailsPlaceholder:
contentType = .regular
@ -1298,6 +1550,9 @@ public final class ChatEmptyNode: ASDisplayNode {
contentType = .regular
} else {
contentType = .greeting
if interfaceState.businessIntro != nil {
displayAttachedDescription = true
}
}
}
} else {
@ -1368,6 +1623,46 @@ public final class ChatEmptyNode: ASDisplayNode {
transition.updateFrame(node: self.backgroundNode, frame: contentFrame)
self.backgroundNode.update(size: self.backgroundNode.bounds.size, cornerRadius: min(20.0, self.backgroundNode.bounds.height / 2.0), transition: transition)
if displayAttachedDescription, let peer = interfaceState.renderedPeer?.chatMainPeer {
let attachedDescriptionNode: EmptyAttachedDescriptionNode
if let current = self.attachedDescriptionNode {
attachedDescriptionNode = current
} else {
attachedDescriptionNode = EmptyAttachedDescriptionNode()
self.attachedDescriptionNode = attachedDescriptionNode
self.addSubnode(attachedDescriptionNode)
attachedDescriptionNode.action = { [weak self] in
guard let self else {
return
}
let controller = self.context.sharedContext.makePremiumIntroController(context: self.context, source: .settings, forceDark: false, dismissed: nil)
self.interaction?.chatController()?.push(controller)
}
}
let attachedDescriptionSize = attachedDescriptionNode.update(
theme: interfaceState.theme,
strings: interfaceState.strings,
chatWallpaper: interfaceState.chatWallpaper,
peer: EnginePeer(peer),
wallpaperBackgroundNode: backgroundNode,
constrainedSize: CGSize(width: size.width - insets.left - insets.right, height: 200.0)
)
let attachedDescriptionFrame = CGRect(origin: CGPoint(x: floor((size.width - attachedDescriptionSize.width) * 0.5), y: contentFrame.maxY + 4.0), size: attachedDescriptionSize)
transition.updateFrame(node: attachedDescriptionNode, frame: attachedDescriptionFrame)
if let (rect, containerSize) = self.absolutePosition {
var backgroundFrame = attachedDescriptionNode.frame
backgroundFrame.origin.x += rect.minX
backgroundFrame.origin.y += rect.minY
attachedDescriptionNode.updateAbsolutePosition(rect: backgroundFrame, containerSize: containerSize, transition: .immediate)
}
} else if let attachedDescriptionNode = self.attachedDescriptionNode {
self.attachedDescriptionNode = nil
attachedDescriptionNode.removeFromSupernode()
}
if backgroundNode?.hasExtraBubbleBackground() == true {
if self.backgroundContent == nil, let backgroundContent = backgroundNode?.makeBubbleBackground(for: .free) {
backgroundContent.clipsToBounds = true
@ -1409,5 +1704,12 @@ public final class ChatEmptyNode: ASDisplayNode {
backgroundFrame.origin.y += rect.minY
backgroundContent.update(rect: backgroundFrame, within: containerSize, transition: transition)
}
if let attachedDescriptionNode = self.attachedDescriptionNode {
var backgroundFrame = attachedDescriptionNode.frame
backgroundFrame.origin.x += rect.minX
backgroundFrame.origin.y += rect.minY
attachedDescriptionNode.updateAbsolutePosition(rect: backgroundFrame, containerSize: containerSize, transition: transition)
}
}
}

View File

@ -91,10 +91,11 @@ private final class SendInviteLinkScreenComponent: Component {
private var premiumSeparatorRight: SimpleLayer?
private var premiumSeparatorText: ComponentView<Empty>?
private let title = ComponentView<Empty>()
private let leftButton = ComponentView<Empty>()
private let descriptionText = ComponentView<Empty>()
private let actionButton = ComponentView<Empty>()
private var title: ComponentView<Empty>?
private var descriptionText: ComponentView<Empty>?
private var actionButton: ComponentView<Empty>?
private let itemContainerView: UIView
private var items: [AnyHashable: ComponentView<Empty>] = [:]
@ -242,7 +243,7 @@ private final class SendInviteLinkScreenComponent: Component {
self.scrollContentClippingView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
self.backgroundLayer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
self.navigationBarContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
if let actionButtonView = self.actionButton.view {
if let actionButtonView = self.actionButton?.view {
actionButtonView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
}
}
@ -256,7 +257,7 @@ private final class SendInviteLinkScreenComponent: Component {
})
self.backgroundLayer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true)
self.navigationBarContainer.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true)
if let actionButtonView = self.actionButton.view {
if let actionButtonView = self.actionButton?.view {
actionButtonView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true)
}
}
@ -280,6 +281,7 @@ private final class SendInviteLinkScreenComponent: Component {
self.environment = environment
let hasPremiumRestrictedUsers = "".isEmpty
let hasInviteLink = "".isEmpty
if themeUpdated {
self.dimView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
@ -373,35 +375,6 @@ private final class SendInviteLinkScreenComponent: Component {
self.premiumButton = premiumButton
}
let premiumSeparatorText: ComponentView<Empty>
if let current = self.premiumSeparatorText {
premiumSeparatorText = current
} else {
premiumSeparatorText = ComponentView()
self.premiumSeparatorText = premiumSeparatorText
}
let premiumSeparatorLeft: SimpleLayer
if let current = self.premiumSeparatorLeft {
premiumSeparatorLeft = current
} else {
premiumSeparatorLeft = SimpleLayer()
self.premiumSeparatorLeft = premiumSeparatorLeft
self.scrollContentView.layer.addSublayer(premiumSeparatorLeft)
}
let premiumSeparatorRight: SimpleLayer
if let current = self.premiumSeparatorRight {
premiumSeparatorRight = current
} else {
premiumSeparatorRight = SimpleLayer()
self.premiumSeparatorRight = premiumSeparatorRight
self.scrollContentView.layer.addSublayer(premiumSeparatorRight)
}
premiumSeparatorLeft.backgroundColor = environment.theme.list.itemPlainSeparatorColor.cgColor
premiumSeparatorRight.backgroundColor = environment.theme.list.itemPlainSeparatorColor.cgColor
//TODO:localize
let premiumTitleSize = premiumTitle.update(
transition: .immediate,
@ -531,31 +504,78 @@ private final class SendInviteLinkScreenComponent: Component {
transition.setFrame(view: premiumButtonView, frame: premiumButtonFrame)
}
contentHeight += premiumButtonSize.height
contentHeight += 19.0
let premiumSeparatorTextSize = premiumSeparatorText.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: "or", font: Font.regular(15.0), textColor: environment.theme.list.itemSecondaryTextColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - leftButtonFrame.maxX * 2.0, height: 100.0)
)
let premiumSeparatorTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - premiumSeparatorTextSize.width) * 0.5), y: contentHeight), size: premiumSeparatorTextSize)
if let premiumSeparatorTextView = premiumSeparatorText.view {
if premiumSeparatorTextView.superview == nil {
self.scrollContentView.addSubview(premiumSeparatorTextView)
if hasInviteLink {
let premiumSeparatorText: ComponentView<Empty>
if let current = self.premiumSeparatorText {
premiumSeparatorText = current
} else {
premiumSeparatorText = ComponentView()
self.premiumSeparatorText = premiumSeparatorText
}
transition.setFrame(view: premiumSeparatorTextView, frame: premiumSeparatorTextFrame)
let premiumSeparatorLeft: SimpleLayer
if let current = self.premiumSeparatorLeft {
premiumSeparatorLeft = current
} else {
premiumSeparatorLeft = SimpleLayer()
self.premiumSeparatorLeft = premiumSeparatorLeft
self.scrollContentView.layer.addSublayer(premiumSeparatorLeft)
}
let premiumSeparatorRight: SimpleLayer
if let current = self.premiumSeparatorRight {
premiumSeparatorRight = current
} else {
premiumSeparatorRight = SimpleLayer()
self.premiumSeparatorRight = premiumSeparatorRight
self.scrollContentView.layer.addSublayer(premiumSeparatorRight)
}
premiumSeparatorLeft.backgroundColor = environment.theme.list.itemPlainSeparatorColor.cgColor
premiumSeparatorRight.backgroundColor = environment.theme.list.itemPlainSeparatorColor.cgColor
contentHeight += 19.0
let premiumSeparatorTextSize = premiumSeparatorText.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: "or", font: Font.regular(15.0), textColor: environment.theme.list.itemSecondaryTextColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - leftButtonFrame.maxX * 2.0, height: 100.0)
)
let premiumSeparatorTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - premiumSeparatorTextSize.width) * 0.5), y: contentHeight), size: premiumSeparatorTextSize)
if let premiumSeparatorTextView = premiumSeparatorText.view {
if premiumSeparatorTextView.superview == nil {
self.scrollContentView.addSubview(premiumSeparatorTextView)
}
transition.setFrame(view: premiumSeparatorTextView, frame: premiumSeparatorTextFrame)
}
let separatorWidth: CGFloat = 72.0
let separatorSpacing: CGFloat = 10.0
transition.setFrame(layer: premiumSeparatorLeft, frame: CGRect(origin: CGPoint(x: premiumSeparatorTextFrame.minX - separatorSpacing - separatorWidth, y: premiumSeparatorTextFrame.midY + 1.0), size: CGSize(width: separatorWidth, height: UIScreenPixel)))
transition.setFrame(layer: premiumSeparatorRight, frame: CGRect(origin: CGPoint(x: premiumSeparatorTextFrame.maxX + separatorSpacing, y: premiumSeparatorTextFrame.midY + 1.0), size: CGSize(width: separatorWidth, height: UIScreenPixel)))
contentHeight += 31.0
} else {
if let premiumSeparatorLeft = self.premiumSeparatorLeft {
self.premiumSeparatorLeft = nil
premiumSeparatorLeft.removeFromSuperlayer()
}
if let premiumSeparatorRight = self.premiumSeparatorRight {
self.premiumSeparatorRight = nil
premiumSeparatorRight.removeFromSuperlayer()
}
if let premiumSeparatorText = self.premiumSeparatorText {
self.premiumSeparatorText = nil
premiumSeparatorText.view?.removeFromSuperview()
}
contentHeight += 14.0
}
let separatorWidth: CGFloat = 72.0
let separatorSpacing: CGFloat = 10.0
transition.setFrame(layer: premiumSeparatorLeft, frame: CGRect(origin: CGPoint(x: premiumSeparatorTextFrame.minX - separatorSpacing - separatorWidth, y: premiumSeparatorTextFrame.midY + 1.0), size: CGSize(width: separatorWidth, height: UIScreenPixel)))
transition.setFrame(layer: premiumSeparatorRight, frame: CGRect(origin: CGPoint(x: premiumSeparatorTextFrame.maxX + separatorSpacing, y: premiumSeparatorTextFrame.midY + 1.0), size: CGSize(width: separatorWidth, height: UIScreenPixel)))
contentHeight += 31.0
} else {
if let premiumTitle = self.premiumTitle {
self.premiumTitle = nil
@ -569,241 +589,278 @@ private final class SendInviteLinkScreenComponent: Component {
self.premiumButton = nil
premiumButton.view?.removeFromSuperview()
}
if let premiumSeparatorLeft = self.premiumSeparatorLeft {
self.premiumSeparatorLeft = nil
premiumSeparatorLeft.removeFromSuperlayer()
}
if let premiumSeparatorRight = self.premiumSeparatorRight {
self.premiumSeparatorRight = nil
premiumSeparatorRight.removeFromSuperlayer()
}
if let premiumSeparatorText = self.premiumSeparatorText {
self.premiumSeparatorText = nil
premiumSeparatorText.view?.removeFromSuperview()
}
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.link != nil ? environment.strings.SendInviteLink_InviteTitle : environment.strings.SendInviteLink_LinkUnavailableTitle, font: Font.semibold(24.0), textColor: environment.theme.list.itemPrimaryTextColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - leftButtonFrame.maxX * 2.0, height: 100.0)
)
let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: contentHeight), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
self.scrollContentView.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: titleFrame)
}
contentHeight += titleSize.height
contentHeight += 8.0
let text: String
if hasPremiumRestrictedUsers {
if component.link != nil {
//TODO:localize
text = "You can try to send an invite link instead."
} else {
if component.peers.count == 1 {
text = environment.strings.SendInviteLink_TextUnavailableSingleUser(component.peers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast)).string
} else {
text = environment.strings.SendInviteLink_TextUnavailableMultipleUsers(Int32(component.peers.count))
}
}
} else {
if component.link != nil {
if component.peers.count == 1 {
text = environment.strings.SendInviteLink_TextAvailableSingleUser(component.peers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast)).string
} else {
text = environment.strings.SendInviteLink_TextAvailableMultipleUsers(Int32(component.peers.count))
}
} else {
if component.peers.count == 1 {
text = environment.strings.SendInviteLink_TextUnavailableSingleUser(component.peers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast)).string
} else {
text = environment.strings.SendInviteLink_TextUnavailableMultipleUsers(Int32(component.peers.count))
}
}
}
let body = MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemPrimaryTextColor)
let bold = MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.itemPrimaryTextColor)
let descriptionTextSize = self.descriptionText.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .markdown(text: text, attributes: MarkdownAttributes(
body: body,
bold: bold,
link: body,
linkAttribute: { _ in nil }
)),
horizontalAlignment: .center,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 16.0 * 2.0, height: 1000.0)
)
let descriptionTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - descriptionTextSize.width) * 0.5), y: contentHeight), size: descriptionTextSize)
if let descriptionTextView = self.descriptionText.view {
if descriptionTextView.superview == nil {
self.scrollContentView.addSubview(descriptionTextView)
}
transition.setFrame(view: descriptionTextView, frame: descriptionTextFrame)
}
contentHeight += descriptionTextFrame.height
contentHeight += 22.0
var singleItemHeight: CGFloat = 0.0
var itemsHeight: CGFloat = 0.0
var validIds: [AnyHashable] = []
for i in 0 ..< component.peers.count {
let peer = component.peers[i]
for _ in 0 ..< 1 {
//let id: AnyHashable = AnyHashable("\(peer.id)_\(j)")
let id = AnyHashable(peer.id)
validIds.append(id)
let item: ComponentView<Empty>
var itemTransition = transition
if let current = self.items[id] {
item = current
} else {
itemTransition = .immediate
item = ComponentView()
self.items[id] = item
}
let itemSize = item.update(
transition: itemTransition,
component: AnyComponent(PeerListItemComponent(
context: component.context,
theme: environment.theme,
strings: environment.strings,
sideInset: 0.0,
title: peer.displayTitle(strings: environment.strings, displayOrder: .firstLast),
peer: peer,
presence: component.peerPresences[peer.id],
selectionState: component.link == nil ? .none : .editing(isSelected: self.selectedItems.contains(peer.id)),
hasNext: i != component.peers.count - 1,
action: { [weak self] peer in
guard let self else {
return
}
if self.selectedItems.contains(peer.id) {
self.selectedItems.remove(peer.id)
} else {
self.selectedItems.insert(peer.id)
}
self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .easeInOut)))
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: itemsHeight), size: itemSize)
if let itemView = item.view {
if itemView.superview == nil {
self.itemContainerView.addSubview(itemView)
}
itemTransition.setFrame(view: itemView, frame: itemFrame)
}
itemsHeight += itemSize.height
singleItemHeight = itemSize.height
}
}
var removeIds: [AnyHashable] = []
for (id, item) in self.items {
if !validIds.contains(id) {
removeIds.append(id)
item.view?.removeFromSuperview()
}
}
for id in removeIds {
self.items.removeValue(forKey: id)
}
transition.setFrame(view: self.itemContainerView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: itemsHeight)))
var initialContentHeight = contentHeight
initialContentHeight += min(itemsHeight, floor(singleItemHeight * 2.5))
contentHeight += itemsHeight
contentHeight += 24.0
initialContentHeight += 24.0
let actionButtonTitle: String
if component.link != nil {
actionButtonTitle = self.selectedItems.isEmpty ? environment.strings.SendInviteLink_ActionSkip : environment.strings.SendInviteLink_ActionInvite
} else {
actionButtonTitle = environment.strings.SendInviteLink_ActionClose
}
let actionButtonSize = self.actionButton.update(
transition: transition,
component: AnyComponent(SolidRoundedButtonComponent(
title: actionButtonTitle,
badge: (self.selectedItems.isEmpty || component.link == nil) ? nil : "\(self.selectedItems.count)",
theme: SolidRoundedButtonComponent.Theme(theme: environment.theme),
font: .bold,
fontSize: 17.0,
height: 50.0,
cornerRadius: 11.0,
gloss: false,
animationName: nil,
iconPosition: .right,
iconSpacing: 4.0,
action: { [weak self] in
guard let self, let component = self.component, let controller = self.environment?.controller() else {
return
}
if self.selectedItems.isEmpty {
controller.dismiss()
} else if let link = component.link {
let selectedPeers = component.peers.filter { self.selectedItems.contains($0.id) }
let _ = enqueueMessagesToMultiplePeers(account: component.context.account, peerIds: Array(self.selectedItems), threadIds: [:], messages: [.message(text: link, attributes: [], inlineStickers: [:], mediaReference: nil, threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]).start()
let text: String
if selectedPeers.count == 1 {
text = environment.strings.Conversation_ShareLinkTooltip_Chat_One(selectedPeers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast).replacingOccurrences(of: "*", with: "")).string
} else if selectedPeers.count == 2 {
text = environment.strings.Conversation_ShareLinkTooltip_TwoChats_One(selectedPeers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast).replacingOccurrences(of: "*", with: ""), selectedPeers[1].displayTitle(strings: environment.strings, displayOrder: .firstLast).replacingOccurrences(of: "*", with: "")).string
} else {
text = environment.strings.Conversation_ShareLinkTooltip_ManyChats_One(selectedPeers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast).replacingOccurrences(of: "*", with: ""), "\(selectedPeers.count - 1)").string
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
controller.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: false, text: text), elevatedLayout: false, action: { _ in return false }), in: .window(.root))
controller.dismiss()
} else {
controller.dismiss()
}
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0)
)
let bottomPanelHeight = 15.0 + environment.safeInsets.bottom + actionButtonSize.height
let actionButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: availableSize.height - bottomPanelHeight), size: actionButtonSize)
if let actionButtonView = self.actionButton.view {
if actionButtonView.superview == nil {
self.addSubview(actionButtonView)
}
transition.setFrame(view: actionButtonView, frame: actionButtonFrame)
}
contentHeight += bottomPanelHeight
initialContentHeight += bottomPanelHeight
let containerInset: CGFloat = environment.statusBarHeight + 10.0
var initialContentHeight = contentHeight
let clippingY: CGFloat
if hasInviteLink {
let title: ComponentView<Empty>
if let current = self.title {
title = current
} else {
title = ComponentView()
self.title = title
}
let descriptionText: ComponentView<Empty>
if let current = self.descriptionText {
descriptionText = current
} else {
descriptionText = ComponentView()
self.descriptionText = descriptionText
}
let actionButton: ComponentView<Empty>
if let current = self.actionButton {
actionButton = current
} else {
actionButton = ComponentView()
self.actionButton = actionButton
}
let titleSize = title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.link != nil ? environment.strings.SendInviteLink_InviteTitle : environment.strings.SendInviteLink_LinkUnavailableTitle, font: Font.semibold(24.0), textColor: environment.theme.list.itemPrimaryTextColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - leftButtonFrame.maxX * 2.0, height: 100.0)
)
let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: contentHeight), size: titleSize)
if let titleView = title.view {
if titleView.superview == nil {
self.scrollContentView.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: titleFrame)
}
contentHeight += titleSize.height
contentHeight += 8.0
let text: String
if hasPremiumRestrictedUsers {
if component.link != nil {
//TODO:localize
text = "You can try to send an invite link instead."
} else {
if component.peers.count == 1 {
text = environment.strings.SendInviteLink_TextUnavailableSingleUser(component.peers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast)).string
} else {
text = environment.strings.SendInviteLink_TextUnavailableMultipleUsers(Int32(component.peers.count))
}
}
} else {
if component.link != nil {
if component.peers.count == 1 {
text = environment.strings.SendInviteLink_TextAvailableSingleUser(component.peers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast)).string
} else {
text = environment.strings.SendInviteLink_TextAvailableMultipleUsers(Int32(component.peers.count))
}
} else {
if component.peers.count == 1 {
text = environment.strings.SendInviteLink_TextUnavailableSingleUser(component.peers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast)).string
} else {
text = environment.strings.SendInviteLink_TextUnavailableMultipleUsers(Int32(component.peers.count))
}
}
}
let body = MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemPrimaryTextColor)
let bold = MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.itemPrimaryTextColor)
let descriptionTextSize = descriptionText.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .markdown(text: text, attributes: MarkdownAttributes(
body: body,
bold: bold,
link: body,
linkAttribute: { _ in nil }
)),
horizontalAlignment: .center,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 16.0 * 2.0, height: 1000.0)
)
let descriptionTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - descriptionTextSize.width) * 0.5), y: contentHeight), size: descriptionTextSize)
if let descriptionTextView = descriptionText.view {
if descriptionTextView.superview == nil {
self.scrollContentView.addSubview(descriptionTextView)
}
transition.setFrame(view: descriptionTextView, frame: descriptionTextFrame)
}
contentHeight += descriptionTextFrame.height
contentHeight += 22.0
var singleItemHeight: CGFloat = 0.0
var itemsHeight: CGFloat = 0.0
var validIds: [AnyHashable] = []
for i in 0 ..< component.peers.count {
let peer = component.peers[i]
for _ in 0 ..< 1 {
//let id: AnyHashable = AnyHashable("\(peer.id)_\(j)")
let id = AnyHashable(peer.id)
validIds.append(id)
let item: ComponentView<Empty>
var itemTransition = transition
if let current = self.items[id] {
item = current
} else {
itemTransition = .immediate
item = ComponentView()
self.items[id] = item
}
let itemSize = item.update(
transition: itemTransition,
component: AnyComponent(PeerListItemComponent(
context: component.context,
theme: environment.theme,
strings: environment.strings,
sideInset: 0.0,
title: peer.displayTitle(strings: environment.strings, displayOrder: .firstLast),
peer: peer,
presence: component.peerPresences[peer.id],
selectionState: component.link == nil ? .none : .editing(isSelected: self.selectedItems.contains(peer.id)),
hasNext: i != component.peers.count - 1,
action: { [weak self] peer in
guard let self else {
return
}
if self.selectedItems.contains(peer.id) {
self.selectedItems.remove(peer.id)
} else {
self.selectedItems.insert(peer.id)
}
self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .easeInOut)))
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: itemsHeight), size: itemSize)
if let itemView = item.view {
if itemView.superview == nil {
self.itemContainerView.addSubview(itemView)
}
itemTransition.setFrame(view: itemView, frame: itemFrame)
}
itemsHeight += itemSize.height
singleItemHeight = itemSize.height
}
}
var removeIds: [AnyHashable] = []
for (id, item) in self.items {
if !validIds.contains(id) {
removeIds.append(id)
item.view?.removeFromSuperview()
}
}
for id in removeIds {
self.items.removeValue(forKey: id)
}
transition.setFrame(view: self.itemContainerView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: itemsHeight)))
initialContentHeight += singleItemHeight + 16.0
initialContentHeight += min(itemsHeight, floor(singleItemHeight * 2.5))
contentHeight += itemsHeight
contentHeight += 24.0
initialContentHeight += 24.0
let actionButtonTitle: String
if component.link != nil {
actionButtonTitle = self.selectedItems.isEmpty ? environment.strings.SendInviteLink_ActionSkip : environment.strings.SendInviteLink_ActionInvite
} else {
actionButtonTitle = environment.strings.SendInviteLink_ActionClose
}
let actionButtonSize = actionButton.update(
transition: transition,
component: AnyComponent(SolidRoundedButtonComponent(
title: actionButtonTitle,
badge: (self.selectedItems.isEmpty || component.link == nil) ? nil : "\(self.selectedItems.count)",
theme: SolidRoundedButtonComponent.Theme(theme: environment.theme),
font: .bold,
fontSize: 17.0,
height: 50.0,
cornerRadius: 11.0,
gloss: false,
animationName: nil,
iconPosition: .right,
iconSpacing: 4.0,
action: { [weak self] in
guard let self, let component = self.component, let controller = self.environment?.controller() else {
return
}
if self.selectedItems.isEmpty {
controller.dismiss()
} else if let link = component.link {
let selectedPeers = component.peers.filter { self.selectedItems.contains($0.id) }
let _ = enqueueMessagesToMultiplePeers(account: component.context.account, peerIds: Array(self.selectedItems), threadIds: [:], messages: [.message(text: link, attributes: [], inlineStickers: [:], mediaReference: nil, threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]).start()
let text: String
if selectedPeers.count == 1 {
text = environment.strings.Conversation_ShareLinkTooltip_Chat_One(selectedPeers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast).replacingOccurrences(of: "*", with: "")).string
} else if selectedPeers.count == 2 {
text = environment.strings.Conversation_ShareLinkTooltip_TwoChats_One(selectedPeers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast).replacingOccurrences(of: "*", with: ""), selectedPeers[1].displayTitle(strings: environment.strings, displayOrder: .firstLast).replacingOccurrences(of: "*", with: "")).string
} else {
text = environment.strings.Conversation_ShareLinkTooltip_ManyChats_One(selectedPeers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast).replacingOccurrences(of: "*", with: ""), "\(selectedPeers.count - 1)").string
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
controller.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: false, text: text), elevatedLayout: false, action: { _ in return false }), in: .window(.root))
controller.dismiss()
} else {
controller.dismiss()
}
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0)
)
let bottomPanelHeight = 15.0 + environment.safeInsets.bottom + actionButtonSize.height
let actionButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: availableSize.height - bottomPanelHeight), size: actionButtonSize)
if let actionButtonView = actionButton.view {
if actionButtonView.superview == nil {
self.addSubview(actionButtonView)
}
transition.setFrame(view: actionButtonView, frame: actionButtonFrame)
}
contentHeight += bottomPanelHeight
initialContentHeight += bottomPanelHeight
clippingY = actionButtonFrame.minY - 24.0
} else {
if let title = self.title {
self.title = nil
title.view?.removeFromSuperview()
}
if let descriptionText = self.descriptionText {
self.descriptionText = nil
descriptionText.view?.removeFromSuperview()
}
if let actionButton = self.actionButton {
self.actionButton = nil
actionButton.view?.removeFromSuperview()
}
initialContentHeight += environment.safeInsets.bottom
clippingY = availableSize.height
}
let topInset: CGFloat = max(0.0, availableSize.height - containerInset - initialContentHeight)
let scrollContentHeight = max(topInset + contentHeight, availableSize.height - containerInset)
@ -817,12 +874,12 @@ private final class SendInviteLinkScreenComponent: Component {
transition.setPosition(layer: self.backgroundLayer, position: CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0))
transition.setBounds(layer: self.backgroundLayer, bounds: CGRect(origin: CGPoint(), size: availableSize))
let scrollClippingFrame = CGRect(origin: CGPoint(x: sideInset, y: containerInset), size: CGSize(width: availableSize.width - sideInset * 2.0, height: actionButtonFrame.minY - 24.0 - (containerInset)))
let scrollClippingFrame = CGRect(origin: CGPoint(x: sideInset, y: containerInset), size: CGSize(width: availableSize.width - sideInset * 2.0, height: clippingY - containerInset))
transition.setPosition(view: self.scrollContentClippingView, position: scrollClippingFrame.center)
transition.setBounds(view: self.scrollContentClippingView, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size))
self.ignoreScrolling = true
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: availableSize.height - containerInset)))
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: availableSize.height)))
let contentSize = CGSize(width: availableSize.width, height: scrollContentHeight)
if contentSize != self.scrollView.contentSize {
self.scrollView.contentSize = contentSize

View File

@ -1356,7 +1356,7 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
sideInset: 0.0,
title: peer.peer.displayTitle(strings: environment.strings, displayOrder: .firstLast),
peer: peer.peer,
subtitle: peer.isContact ? environment.strings.ChatList_PeerTypeContact : environment.strings.ChatList_PeerTypeNonContact,
subtitle: peer.isContact ? environment.strings.ChatList_PeerTypeContact : environment.strings.ChatList_PeerTypeNonContactUser,
subtitleAccessory: .none,
presence: nil,
selectionState: .none,

View File

@ -0,0 +1,10 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import TelegramCore
import AccountContext
import ListSectionComponent

View File

@ -120,6 +120,7 @@ final class ChatbotSetupScreenComponent: Component {
private let nameSection = ComponentView<Empty>()
private let accessSection = ComponentView<Empty>()
private let excludedSection = ComponentView<Empty>()
private let excludedUsersSection = ComponentView<Empty>()
private let permissionsSection = ComponentView<Empty>()
private var isUpdating: Bool = false
@ -299,7 +300,7 @@ final class ChatbotSetupScreenComponent: Component {
}
}
private func openAdditionalPeerListSetup() {
private func openAdditionalPeerListSetup(isExclude: Bool) {
guard let component = self.component, let environment = self.environment else {
return
}
@ -311,44 +312,51 @@ final class ChatbotSetupScreenComponent: Component {
case nonContacts
}
let additionalCategories: [ChatListNodeAdditionalCategory] = [
ChatListNodeAdditionalCategory(
id: self.hasAccessToAllChatsByDefault ? AdditionalCategoryId.existingChats.rawValue : AdditionalCategoryId.newChats.rawValue,
icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: self.hasAccessToAllChatsByDefault ? "Chat List/Filters/Chats" : "Chat List/Filters/NewChats"), color: .white), cornerRadius: 12.0, color: .purple),
smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: self.hasAccessToAllChatsByDefault ? "Chat List/Filters/Chats" : "Chat List/Filters/NewChats"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .purple),
title: self.hasAccessToAllChatsByDefault ? environment.strings.BusinessMessageSetup_Recipients_CategoryExistingChats : environment.strings.BusinessMessageSetup_Recipients_CategoryNewChats
),
ChatListNodeAdditionalCategory(
id: AdditionalCategoryId.contacts.rawValue,
icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), cornerRadius: 12.0, color: .blue),
smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .blue),
title: environment.strings.BusinessMessageSetup_Recipients_CategoryContacts
),
ChatListNodeAdditionalCategory(
id: AdditionalCategoryId.nonContacts.rawValue,
icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), cornerRadius: 12.0, color: .yellow),
smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .yellow),
title: environment.strings.BusinessMessageSetup_Recipients_CategoryNonContacts
)
]
let additionalCategories: [ChatListNodeAdditionalCategory]
var selectedCategories = Set<Int>()
for category in self.additionalPeerList.categories {
switch category {
case .existingChats:
selectedCategories.insert(AdditionalCategoryId.existingChats.rawValue)
case .newChats:
selectedCategories.insert(AdditionalCategoryId.newChats.rawValue)
case .contacts:
selectedCategories.insert(AdditionalCategoryId.contacts.rawValue)
case .nonContacts:
selectedCategories.insert(AdditionalCategoryId.nonContacts.rawValue)
if isExclude {
additionalCategories = []
} else {
additionalCategories = [
ChatListNodeAdditionalCategory(
id: self.hasAccessToAllChatsByDefault ? AdditionalCategoryId.existingChats.rawValue : AdditionalCategoryId.newChats.rawValue,
icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: self.hasAccessToAllChatsByDefault ? "Chat List/Filters/Chats" : "Chat List/Filters/NewChats"), color: .white), cornerRadius: 12.0, color: .purple),
smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: self.hasAccessToAllChatsByDefault ? "Chat List/Filters/Chats" : "Chat List/Filters/NewChats"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .purple),
title: self.hasAccessToAllChatsByDefault ? environment.strings.BusinessMessageSetup_Recipients_CategoryExistingChats : environment.strings.BusinessMessageSetup_Recipients_CategoryNewChats
),
ChatListNodeAdditionalCategory(
id: AdditionalCategoryId.contacts.rawValue,
icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), cornerRadius: 12.0, color: .blue),
smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .blue),
title: environment.strings.BusinessMessageSetup_Recipients_CategoryContacts
),
ChatListNodeAdditionalCategory(
id: AdditionalCategoryId.nonContacts.rawValue,
icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), cornerRadius: 12.0, color: .yellow),
smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .yellow),
title: environment.strings.BusinessMessageSetup_Recipients_CategoryNonContacts
)
]
}
if !isExclude {
for category in self.additionalPeerList.categories {
switch category {
case .existingChats:
selectedCategories.insert(AdditionalCategoryId.existingChats.rawValue)
case .newChats:
selectedCategories.insert(AdditionalCategoryId.newChats.rawValue)
case .contacts:
selectedCategories.insert(AdditionalCategoryId.contacts.rawValue)
case .nonContacts:
selectedCategories.insert(AdditionalCategoryId.nonContacts.rawValue)
}
}
}
let controller = component.context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams(context: component.context, mode: .chatSelection(ContactMultiselectionControllerMode.ChatSelection(
title: self.hasAccessToAllChatsByDefault ? environment.strings.BusinessMessageSetup_Recipients_ExcludeSearchTitle : environment.strings.BusinessMessageSetup_Recipients_IncludeSearchTitle,
title: (self.hasAccessToAllChatsByDefault || isExclude) ? environment.strings.BusinessMessageSetup_Recipients_ExcludeSearchTitle : environment.strings.BusinessMessageSetup_Recipients_IncludeSearchTitle,
searchPlaceholder: environment.strings.ChatListFilter_AddChatsSearchPlaceholder,
selectedChats: Set(self.additionalPeerList.peers.map(\.peer.id)),
selectedChats: isExclude ? Set(self.additionalPeerList.excludePeers.map(\.peer.id)) : Set(self.additionalPeerList.peers.map(\.peer.id)),
additionalCategories: ContactMultiselectionControllerAdditionalCategories(categories: additionalCategories, selectedCategories: selectedCategories),
chatListFilters: nil,
onlyUsers: true
@ -386,36 +394,59 @@ final class ChatbotSetupScreenComponent: Component {
return
}
let mappedCategories = additionalCategoryIds.compactMap { item -> AdditionalPeerList.Category? in
switch item {
case AdditionalCategoryId.existingChats.rawValue:
return .existingChats
case AdditionalCategoryId.newChats.rawValue:
return .newChats
case AdditionalCategoryId.contacts.rawValue:
return .contacts
case AdditionalCategoryId.nonContacts.rawValue:
return .nonContacts
default:
return nil
if !isExclude {
let mappedCategories = additionalCategoryIds.compactMap { item -> AdditionalPeerList.Category? in
switch item {
case AdditionalCategoryId.existingChats.rawValue:
return .existingChats
case AdditionalCategoryId.newChats.rawValue:
return .newChats
case AdditionalCategoryId.contacts.rawValue:
return .contacts
case AdditionalCategoryId.nonContacts.rawValue:
return .nonContacts
default:
return nil
}
}
self.additionalPeerList.categories = Set(mappedCategories)
self.additionalPeerList.peers.removeAll()
for id in peerIds {
guard let maybePeer = peerMap[id], let peer = maybePeer else {
continue
}
self.additionalPeerList.peers.append(AdditionalPeerList.Peer(
peer: peer,
isContact: isContactMap[id] ?? false
))
}
self.additionalPeerList.peers.sort(by: { lhs, rhs in
return lhs.peer.debugDisplayTitle < rhs.peer.debugDisplayTitle
})
let includedIds = self.additionalPeerList.peers.map(\.peer.id)
self.additionalPeerList.excludePeers.removeAll(where: { includedIds.contains($0.peer.id) })
} else {
self.additionalPeerList.excludePeers.removeAll()
for id in peerIds {
guard let maybePeer = peerMap[id], let peer = maybePeer else {
continue
}
self.additionalPeerList.excludePeers.append(AdditionalPeerList.Peer(
peer: peer,
isContact: isContactMap[id] ?? false
))
}
self.additionalPeerList.excludePeers.sort(by: { lhs, rhs in
return lhs.peer.debugDisplayTitle < rhs.peer.debugDisplayTitle
})
let excludedIds = self.additionalPeerList.excludePeers.map(\.peer.id)
self.additionalPeerList.peers.removeAll(where: { excludedIds.contains($0.peer.id) })
}
self.additionalPeerList.categories = Set(mappedCategories)
self.additionalPeerList.peers.removeAll()
for id in peerIds {
guard let maybePeer = peerMap[id], let peer = maybePeer else {
continue
}
self.additionalPeerList.peers.append(AdditionalPeerList.Peer(
peer: peer,
isContact: isContactMap[id] ?? false
))
}
self.additionalPeerList.peers.sort(by: { lhs, rhs in
return lhs.peer.debugDisplayTitle < rhs.peer.debugDisplayTitle
})
self.state?.updated(transition: .immediate)
controller?.dismiss()
@ -494,7 +525,7 @@ final class ChatbotSetupScreenComponent: Component {
let navigationTitleSize = self.navigationTitle.update(
transition: transition,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: environment.strings.ChatbotSetup_Title, font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)),
text: .plain(NSAttributedString(string: environment.strings.ChatbotSetup_TitleItem, font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)),
horizontalAlignment: .center
)),
environment: {},
@ -795,7 +826,7 @@ final class ChatbotSetupScreenComponent: Component {
guard let self else {
return
}
self.openAdditionalPeerListSetup()
self.openAdditionalPeerListSetup(isExclude: false)
}
))))
for category in self.additionalPeerList.categories.sorted(by: { $0.rawValue < $1.rawValue }) {
@ -865,7 +896,7 @@ final class ChatbotSetupScreenComponent: Component {
sideInset: 0.0,
title: peer.peer.displayTitle(strings: environment.strings, displayOrder: .firstLast),
peer: peer.peer,
subtitle: peer.isContact ? environment.strings.ChatList_PeerTypeContact : environment.strings.ChatList_PeerTypeNonContact,
subtitle: peer.isContact ? environment.strings.ChatList_PeerTypeContact : environment.strings.ChatList_PeerTypeNonContactUser,
subtitleAccessory: .none,
presence: nil,
selectionState: .none,
@ -930,6 +961,103 @@ final class ChatbotSetupScreenComponent: Component {
contentHeight += excludedSectionSize.height
contentHeight += sectionSpacing
//TODO:localize
var excludedUsersContentHeight: CGFloat = 0.0
var excludedUsersSectionItems: [AnyComponentWithIdentity<Empty>] = []
excludedUsersSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent(
theme: environment.theme,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: environment.strings.BusinessMessageSetup_Recipients_AddExclude,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: environment.theme.list.itemAccentColor
)),
maximumNumberOfLines: 1
))),
], alignment: .left, spacing: 2.0)),
leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent(
name: "Chat List/AddIcon",
tintColor: environment.theme.list.itemAccentColor
))),
accessory: nil,
action: { [weak self] _ in
guard let self else {
return
}
self.openAdditionalPeerListSetup(isExclude: true)
}
))))
for peer in self.additionalPeerList.excludePeers {
excludedUsersSectionItems.append(AnyComponentWithIdentity(id: peer.peer.id, component: AnyComponent(PeerListItemComponent(
context: component.context,
theme: environment.theme,
strings: environment.strings,
style: .generic,
sideInset: 0.0,
title: peer.peer.displayTitle(strings: environment.strings, displayOrder: .firstLast),
peer: peer.peer,
subtitle: peer.isContact ? environment.strings.ChatList_PeerTypeContact : environment.strings.ChatList_PeerTypeNonContactUser,
subtitleAccessory: .none,
presence: nil,
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.excludePeers.removeAll(where: { $0.peer.id == peer.peer.id })
self.state?.updated(transition: .spring(duration: 0.4))
}
)]
)
))))
}
let excludedUsersSectionSize = self.excludedUsersSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: environment.theme,
header: nil,
footer: AnyComponent(MultilineTextComponent(
text: .markdown(
text: environment.strings.ChatbotSetup_Recipients_ExcludedSectionFooter,
attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor),
bold: MarkdownAttributeSet(font: Font.semibold(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor),
link: MarkdownAttributeSet(font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.itemAccentColor),
linkAttribute: { _ in
return nil
}
)
),
maximumNumberOfLines: 0
)),
items: excludedUsersSectionItems
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0)
)
let excludedUsersSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + excludedUsersContentHeight), size: excludedSectionSize)
if let excludedUsersSectionView = self.excludedUsersSection.view {
if excludedUsersSectionView.superview == nil {
self.scrollView.addSubview(excludedUsersSectionView)
}
transition.setFrame(view: excludedUsersSectionView, frame: excludedUsersSectionFrame)
transition.setAlpha(view: excludedUsersSectionView, alpha: !self.hasAccessToAllChatsByDefault ? 1.0 : 0.0)
}
excludedUsersContentHeight += excludedUsersSectionSize.height
excludedUsersContentHeight += sectionSpacing
if !self.hasAccessToAllChatsByDefault {
contentHeight += excludedUsersContentHeight
}
let permissionsSectionSize = self.permissionsSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
@ -1107,6 +1235,7 @@ public final class ChatbotSetupScreen: ViewControllerComponentContainer {
var additionalPeerIds = Set<EnginePeer.Id>()
additionalPeerIds.formUnion(connectedBot.recipients.additionalPeers)
additionalPeerIds.formUnion(connectedBot.recipients.excludePeers)
return context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: connectedBot.id),

View File

@ -170,10 +170,16 @@ private final class ChatManagingBotTitlePanelComponent: Component {
containerSize: CGSize(width: maxTextWidth, height: 100.0)
)
//TODO:localize
let textValue: String
if component.isPaused {
textValue = "bot paused"
} else {
textValue = component.managesChat ? "bot manages this chat" : "bot has access to this chat"
}
let textSize = self.text.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.managesChat ? "bot manages this chat" : "bot has access to this chat", font: Font.regular(15.0), textColor: component.theme.rootController.navigationBar.secondaryTextColor))
text: .plain(NSAttributedString(string: textValue, font: Font.regular(15.0), textColor: component.theme.rootController.navigationBar.secondaryTextColor))
)),
environment: {},
containerSize: CGSize(width: maxTextWidth, height: 100.0)
@ -400,7 +406,7 @@ final class ChatManagingBotTitlePanelNode: ChatTitleAccessoryPanelNode {
strings: interfaceState.strings,
insets: UIEdgeInsets(top: 0.0, left: leftInset, bottom: 0.0, right: rightInset),
peer: managingBot.bot,
managesChat: managingBot.canReply,
managesChat: managingBot.canReply || managingBot.isPaused,
isPaused: managingBot.isPaused,
toggleIsPaused: { [weak self] in
guard let self else {