diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index ac41f7007a..ecf3079935 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -10465,5 +10465,13 @@ Sorry for the inconvenience."; "Channel.Info.Stats" = "Statistics and Boosts"; +"Conversation.FreeTranscriptionLimitTooltip_1" = "You have **%@** free voice transcription left this month."; +"Conversation.FreeTranscriptionLimitTooltip_any" = "You have **%@** free voice transcriptions left this month."; + "Notification.GiveawayResults_1" = "%@ winner of the giveaway was randomly selected by Telegram and received private message with giftcode."; "Notification.GiveawayResults_any" = "%@ winners of the giveaway were randomly selected by Telegram and received private messages with giftcodes."; + +"Chat.Giveaway.DeleteConfirmation.Title" = "Do you want to delete the Giveaway Announcement?"; +"Chat.Giveaway.DeleteConfirmation.Text" = "Deleting this message won't cancel the giveaway - the winners will still be selected on **%@**.\n\nOnce deleted, the Giveaway Announcement cannot be recovered."; + +"Chat.SimilarChannels" = "Similar Channels"; diff --git a/submodules/AccountContext/Sources/ChatController.swift b/submodules/AccountContext/Sources/ChatController.swift index fa95c60080..3f2e3894ae 100644 --- a/submodules/AccountContext/Sources/ChatController.swift +++ b/submodules/AccountContext/Sources/ChatController.swift @@ -50,8 +50,33 @@ public final class ChatMessageItemAssociatedData: Equatable { public let hasBots: Bool public let translateToLanguage: String? public let maxReadStoryId: Int32? + public let recommendedChannels: RecommendedChannels? - public init(automaticDownloadPeerType: MediaAutoDownloadPeerType, automaticDownloadPeerId: EnginePeer.Id?, automaticDownloadNetworkType: MediaAutoDownloadNetworkType, isRecentActions: Bool = false, subject: ChatControllerSubject? = nil, contactsPeerIds: Set = Set(), channelDiscussionGroup: ChannelDiscussionGroupStatus = .unknown, animatedEmojiStickers: [String: [StickerPackItem]] = [:], additionalAnimatedEmojiStickers: [String: [Int: StickerPackItem]] = [:], forcedResourceStatus: FileMediaResourceStatus? = nil, currentlyPlayingMessageId: EngineMessage.Index? = nil, isCopyProtectionEnabled: Bool = false, availableReactions: AvailableReactions?, defaultReaction: MessageReaction.Reaction?, isPremium: Bool, accountPeer: EnginePeer?, forceInlineReactions: Bool = false, alwaysDisplayTranscribeButton: DisplayTranscribeButton = DisplayTranscribeButton(canBeDisplayed: false, displayForNotConsumed: false), topicAuthorId: EnginePeer.Id? = nil, hasBots: Bool = false, translateToLanguage: String? = nil, maxReadStoryId: Int32? = nil) { + public init( + automaticDownloadPeerType: MediaAutoDownloadPeerType, + automaticDownloadPeerId: EnginePeer.Id?, + automaticDownloadNetworkType: MediaAutoDownloadNetworkType, + isRecentActions: Bool = false, + subject: ChatControllerSubject? = nil, + contactsPeerIds: Set = Set(), + channelDiscussionGroup: ChannelDiscussionGroupStatus = .unknown, + animatedEmojiStickers: [String: [StickerPackItem]] = [:], + additionalAnimatedEmojiStickers: [String: [Int: StickerPackItem]] = [:], + forcedResourceStatus: FileMediaResourceStatus? = nil, + currentlyPlayingMessageId: EngineMessage.Index? = nil, + isCopyProtectionEnabled: Bool = false, + availableReactions: AvailableReactions?, + defaultReaction: MessageReaction.Reaction?, + isPremium: Bool, + accountPeer: EnginePeer?, + forceInlineReactions: Bool = false, + alwaysDisplayTranscribeButton: DisplayTranscribeButton = DisplayTranscribeButton(canBeDisplayed: false, displayForNotConsumed: false), + topicAuthorId: EnginePeer.Id? = nil, + hasBots: Bool = false, + translateToLanguage: String? = nil, + maxReadStoryId: Int32? = nil, + recommendedChannels: RecommendedChannels? = nil + ) { self.automaticDownloadPeerType = automaticDownloadPeerType self.automaticDownloadPeerId = automaticDownloadPeerId self.automaticDownloadNetworkType = automaticDownloadNetworkType @@ -74,6 +99,7 @@ public final class ChatMessageItemAssociatedData: Equatable { self.hasBots = hasBots self.translateToLanguage = translateToLanguage self.maxReadStoryId = maxReadStoryId + self.recommendedChannels = recommendedChannels } public static func == (lhs: ChatMessageItemAssociatedData, rhs: ChatMessageItemAssociatedData) -> Bool { @@ -140,6 +166,9 @@ public final class ChatMessageItemAssociatedData: Equatable { if lhs.maxReadStoryId != rhs.maxReadStoryId { return false } + if lhs.recommendedChannels != rhs.recommendedChannels { + return false + } return true } } diff --git a/submodules/MediaPickerUI/Sources/MediaPickerSelectedListNode.swift b/submodules/MediaPickerUI/Sources/MediaPickerSelectedListNode.swift index 2a36c5036a..cdcafeffc9 100644 --- a/submodules/MediaPickerUI/Sources/MediaPickerSelectedListNode.swift +++ b/submodules/MediaPickerUI/Sources/MediaPickerSelectedListNode.swift @@ -472,7 +472,6 @@ private class MessageBackgroundNode: ASDisplayNode { private var absoluteRect: (CGRect, CGSize)? func update(size: CGSize, theme: PresentationTheme, wallpaper: TelegramWallpaper, graphics: PrincipalThemeEssentialGraphics, wallpaperBackgroundNode: WallpaperBackgroundNode, transition: ContainedViewLayoutTransition) { - self.backgroundNode.setType(type: .outgoing(.Extracted), highlighted: false, graphics: graphics, maskMode: false, hasWallpaper: wallpaper.hasWallpaper, transition: transition, backgroundNode: wallpaperBackgroundNode) self.backgroundWallpaperNode.setType(type: .outgoing(.Extracted), theme: ChatPresentationThemeData(theme: theme, wallpaper: wallpaper), essentialGraphics: graphics, maskMode: true, backgroundNode: wallpaperBackgroundNode) self.shadowNode.setType(type: .outgoing(.Extracted), hasWallpaper: wallpaper.hasWallpaper, graphics: graphics) diff --git a/submodules/TelegramApi/Sources/Api32.swift b/submodules/TelegramApi/Sources/Api32.swift index b1a7a8b75f..7850be1230 100644 --- a/submodules/TelegramApi/Sources/Api32.swift +++ b/submodules/TelegramApi/Sources/Api32.swift @@ -2473,11 +2473,11 @@ public extension Api.functions.channels { } } public extension Api.functions.channels { - static func getChannelRecommendations(channelId: Api.InputChannel) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + static func getChannelRecommendations(channel: Api.InputChannel) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() - buffer.appendInt32(-873707987) - channelId.serialize(buffer, true) - return (FunctionDescription(name: "channels.getChannelRecommendations", parameters: [("channelId", String(describing: channelId))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.messages.Chats? in + buffer.appendInt32(-2085155433) + channel.serialize(buffer, true) + return (FunctionDescription(name: "channels.getChannelRecommendations", parameters: [("channel", String(describing: channel))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.messages.Chats? in let reader = BufferReader(buffer) var result: Api.messages.Chats? if let signature = reader.readInt32() { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelRecommendation.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelRecommendation.swift index 621585ebb7..31c8eb4116 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelRecommendation.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelRecommendation.swift @@ -49,7 +49,7 @@ func _internal_requestRecommendedChannels(account: Account, peerId: EnginePeer.I guard let inputChannel = channel.flatMap(apiInputChannel) else { return .complete() } - return account.network.request(Api.functions.channels.getChannelRecommendations(channelId: inputChannel)) + return account.network.request(Api.functions.channels.getChannelRecommendations(channel: inputChannel)) |> retryRequest |> mapToSignal { result -> Signal in return account.postbox.transaction { transaction -> [EnginePeer] in @@ -90,8 +90,8 @@ func _internal_requestRecommendedChannels(account: Account, peerId: EnginePeer.I } } -public struct RecommendedChannels { - public struct Channel { +public struct RecommendedChannels: Equatable { + public struct Channel: Equatable { public let peer: EnginePeer public let subscribers: Int32 } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD index 5b8b444a8d..c945e54830 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD @@ -79,6 +79,7 @@ swift_library( "//submodules/TelegramUI/Components/Chat/ChatMessageWallpaperBubbleContentNode", "//submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode", "//submodules/TelegramUI/Components/Chat/ChatMessageGiveawayBubbleContentNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageJoinedChannelBubbleContentNode", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 7f7738301d..b6b204e7d3 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -69,6 +69,7 @@ import ChatMessageUnsupportedBubbleContentNode import ChatMessageWallpaperBubbleContentNode import ChatMessageGiftBubbleContentNode import ChatMessageGiveawayBubbleContentNode +import ChatMessageJoinedChannelBubbleContentNode private struct BubbleItemAttributes { var isAttachment: Bool @@ -183,6 +184,8 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ result.append((message, ChatMessageWallpaperBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) } else if case .giftCode = action.action { result.append((message, ChatMessageGiftBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) + } else if case .joinedChannel = action.action { + result.append((message, ChatMessageJoinedChannelBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) } else { result.append((message, ChatMessageActionBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) } @@ -1555,6 +1558,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI var hasInstantVideo = false for contentNodeItemValue in contentNodeMessagesAndClasses { let contentNodeItem = contentNodeItemValue as (message: Message, type: AnyClass, attributes: ChatMessageEntryAttributes, bubbleAttributes: BubbleItemAttributes) + if contentNodeItem.type == ChatMessageJoinedChannelBubbleContentNode.self { + maximumContentWidth = baseWidth + break + } if contentNodeItem.type == ChatMessageGiveawayBubbleContentNode.self { maximumContentWidth = min(305.0, maximumContentWidth) break @@ -3939,17 +3946,27 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.mainContextSourceNode.layoutUpdated?(strongSelf.mainContextSourceNode.bounds.size, animation) } + var hasMenuGesture = true if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject { if case .link = info { } else { strongSelf.tapRecognizer?.isEnabled = false } strongSelf.replyRecognizer?.isEnabled = false - strongSelf.mainContainerNode.isGestureEnabled = false - for contentContainer in strongSelf.contentContainers { - contentContainer.containerNode.isGestureEnabled = false + hasMenuGesture = false + } + for media in item.message.media { + if let action = media as? TelegramMediaAction { + if case .joinedChannel = action.action { + hasMenuGesture = false + break + } } } + strongSelf.mainContainerNode.isGestureEnabled = hasMenuGesture + for contentContainer in strongSelf.contentContainers { + contentContainer.containerNode.isGestureEnabled = hasMenuGesture + } strongSelf.updateSearchTextHighlightState() diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageJoinedChannelBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageJoinedChannelBubbleContentNode/BUILD new file mode 100644 index 0000000000..cb366f3473 --- /dev/null +++ b/submodules/TelegramUI/Components/Chat/ChatMessageJoinedChannelBubbleContentNode/BUILD @@ -0,0 +1,39 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ChatMessageJoinedChannelBubbleContentNode", + module_name = "ChatMessageJoinedChannelBubbleContentNode", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramCore", + "//submodules/AccountContext", + "//submodules/ComponentFlow", + "//submodules/TelegramPresentationData", + "//submodules/TelegramUIPreferences", + "//submodules/TextFormat", + "//submodules/LocalizedPeerData", + "//submodules/UrlEscaping", + "//submodules/TelegramStringFormatting", + "//submodules/WallpaperBackgroundNode", + "//submodules/TelegramUI/Components/ChatControllerInteraction", + "//submodules/ShimmerEffect", + "//submodules/Markdown", + "//submodules/AvatarNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", + "//submodules/TelegramUI/Components/Utils/RoundedRectWithTailPath", + "//submodules/Components/MultilineTextComponent", + "//submodules/ChatMessageBackground", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageJoinedChannelBubbleContentNode/Sources/ChatMessageJoinedChannelBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageJoinedChannelBubbleContentNode/Sources/ChatMessageJoinedChannelBubbleContentNode.swift new file mode 100644 index 0000000000..a5d0205e9f --- /dev/null +++ b/submodules/TelegramUI/Components/Chat/ChatMessageJoinedChannelBubbleContentNode/Sources/ChatMessageJoinedChannelBubbleContentNode.swift @@ -0,0 +1,858 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import ComponentFlow +import SwiftSignalKit +import TelegramCore +import AccountContext +import TelegramPresentationData +import TelegramUIPreferences +import TextFormat +import LocalizedPeerData +import UrlEscaping +import TelegramStringFormatting +import WallpaperBackgroundNode +import ReactionSelectionNode +import ChatControllerInteraction +import ShimmerEffect +import Markdown +import ChatMessageBubbleContentNode +import ChatMessageItemCommon +import RoundedRectWithTailPath +import AvatarNode +import MultilineTextComponent +import ChatMessageBackground + +private func attributedServiceMessageString(theme: ChatPresentationThemeData, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, dateTimeFormat: PresentationDateTimeFormat, message: EngineMessage, accountPeerId: EnginePeer.Id) -> NSAttributedString? { + return universalServiceMessageString(presentationData: (theme.theme, theme.wallpaper), strings: strings, nameDisplayOrder: nameDisplayOrder, dateTimeFormat: dateTimeFormat, message: message, accountPeerId: accountPeerId, forChatList: false, forForumOverview: false) +} + +private func generateCloseButtonImage(color: UIColor) -> UIImage? { + return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + context.setAlpha(color.alpha) + context.setBlendMode(.copy) + + context.setLineWidth(2.0) + context.setLineCap(.round) + context.setStrokeColor(color.withAlphaComponent(1.0).cgColor) + + context.move(to: CGPoint(x: 10.0, y: 10.0)) + context.addLine(to: CGPoint(x: 20.0, y: 20.0)) + context.strokePath() + + context.move(to: CGPoint(x: 20.0, y: 10.0)) + context.addLine(to: CGPoint(x: 10.0, y: 20.0)) + context.strokePath() + }) +} + +public class ChatMessageJoinedChannelBubbleContentNode: ChatMessageBubbleContentNode { + private let labelNode: TextNode + private var backgroundNode: WallpaperBubbleBackgroundNode? + private let backgroundMaskNode: ASImageNode + private var linkHighlightingNode: LinkHighlightingNode? + + private let panelNode: ASDisplayNode + private let panelBackgroundNode: MessageBackgroundNode + private let titleNode: TextNode + private let closeButtonNode: HighlightTrackingButtonNode + private let closeIconNode: ASImageNode + private let panelListView = ComponentView() + + private var cachedMaskBackgroundImage: (CGPoint, UIImage, [CGRect])? + private var absoluteRect: (CGRect, CGSize)? + + private var currentMaskSize: CGSize? + private var panelMaskLayer: CAShapeLayer? + + private var isExpanded: Bool? + + required public init() { + self.labelNode = TextNode() + self.labelNode.isUserInteractionEnabled = false + self.labelNode.displaysAsynchronously = false + + self.backgroundMaskNode = ASImageNode() + + self.panelNode = ASDisplayNode() + self.panelBackgroundNode = MessageBackgroundNode() + + self.titleNode = TextNode() + self.titleNode.isUserInteractionEnabled = false + self.titleNode.displaysAsynchronously = false + + self.closeButtonNode = HighlightTrackingButtonNode() + + self.closeIconNode = ASImageNode() + self.closeIconNode.displaysAsynchronously = false + self.closeIconNode.isUserInteractionEnabled = false + + super.init() + + self.addSubnode(self.labelNode) + + self.panelNode.anchorPoint = CGPoint(x: 0.5, y: -0.1) + + self.addSubnode(self.panelNode) + self.panelNode.addSubnode(self.panelBackgroundNode) + self.panelNode.addSubnode(self.titleNode) + + self.panelNode.addSubnode(self.closeIconNode) + self.panelNode.addSubnode(self.closeButtonNode) + + self.closeButtonNode.highligthedChanged = { [weak self] highlighted in + guard let self else { + return + } + if highlighted { + self.closeIconNode.layer.removeAnimation(forKey: "opacity") + self.closeIconNode.alpha = 0.4 + } else { + self.closeIconNode.alpha = 1.0 + self.closeIconNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + } + } + self.closeButtonNode.addTarget(self, action: #selector(self.closeButtonPressed), forControlEvents: .touchUpInside) + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func didLoad() { + super.didLoad() + + self.panelMaskLayer = CAShapeLayer() + } + + @objc private func pressed() { + guard let item = self.item, let recommendedChannels = item.associatedData.recommendedChannels else { + return + } + let _ = item.context.engine.peers.toggleRecommendedChannelsHidden(peerId: item.message.id.peerId, hidden: !recommendedChannels.isHidden).startStandalone() + } + + @objc private func closeButtonPressed() { + guard let item = self.item else { + return + } + let _ = item.context.engine.peers.toggleRecommendedChannelsHidden(peerId: item.message.id.peerId, hidden: true).startStandalone() + } + + override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, unboundSize: CGSize?, maxWidth: CGFloat, layout: (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { + let makeLabelLayout = TextNode.asyncLayout(self.labelNode) + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + + let cachedMaskBackgroundImage = self.cachedMaskBackgroundImage + + return { item, layoutConstants, _, _, constrainedSize, _ in + let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: true, headerSpacing: 0.0, hidesBackground: .always, forceFullCorners: false, forceAlignment: .center) + + let unboundWidth: CGFloat = constrainedSize.width - 10.0 * 2.0 + return (contentProperties, nil, unboundWidth, { constrainedSize, position in + let attributedString = attributedServiceMessageString(theme: item.presentationData.theme, strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, dateTimeFormat: item.presentationData.dateTimeFormat, message: EngineMessage(item.message), accountPeerId: item.context.account.peerId) + + let (labelLayout, labelApply) = makeLabelLayout(TextNodeLayoutArguments(attributedString: attributedString, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: constrainedSize.width - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) + + let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.presentationData.strings.Chat_SimilarChannels, font: Font.semibold(15.0), textColor: item.presentationData.theme.theme.chat.message.incoming.primaryTextColor, paragraphAlignment: .center), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: constrainedSize.width - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) + + var labelRects = labelLayout.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) < 40.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((labelLayout.size.width - labelRects[i].width) / 2.0) + } + + let backgroundMaskImage: (CGPoint, UIImage)? + var backgroundMaskUpdated = false + if let (currentOffset, currentImage, currentRects) = cachedMaskBackgroundImage, currentRects == labelRects { + backgroundMaskImage = (currentOffset, currentImage) + } else { + backgroundMaskImage = LinkHighlightingNode.generateImage(color: .black, inset: 0.0, innerRadius: 10.0, outerRadius: 10.0, rects: labelRects, useModernPathCalculation: false) + backgroundMaskUpdated = true + } + + let isExpanded: Bool + if let recommendedChannels = item.associatedData.recommendedChannels, !recommendedChannels.isHidden { + isExpanded = true + } else { + isExpanded = false + } + + let spacing: CGFloat = 17.0 + let margin: CGFloat = 4.0 + var contentSize = CGSize(width: constrainedSize.width, height: labelLayout.size.height) + if isExpanded { + contentSize.height += spacing + 140.0 + margin + } else { + contentSize.height += margin + } + + return (contentSize.width, { boundingWidth in + return (contentSize, { [weak self] animation, synchronousLoads, info in + if let strongSelf = self { + let themeUpdated = strongSelf.item?.presentationData.theme !== item.presentationData.theme + strongSelf.item = item + strongSelf.isExpanded = isExpanded + + info?.setInvertOffsetDirection() + + let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: labelLayout.size.height + spacing - 14.0), size: CGSize(width: constrainedSize.width, height: 140.0)) + + strongSelf.panelNode.position = CGPoint(x: panelFrame.midX, y: panelFrame.minY) + strongSelf.panelNode.bounds = CGRect(origin: .zero, size: panelFrame.size) + + let panelInnerSize = CGSize(width: panelFrame.width + 8.0, height: panelFrame.height + 10.0) + if let backgroundNode = item.controllerInteraction.presentationContext.backgroundNode { + let graphics = PresentationResourcesChat.principalGraphics(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper, bubbleCorners: item.presentationData.chatBubbleCorners) + strongSelf.panelBackgroundNode.update(size: panelInnerSize, theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper, graphics: graphics, wallpaperBackgroundNode: backgroundNode, transition: .immediate) + } + strongSelf.panelBackgroundNode.frame = CGRect(origin: CGPoint(x: -7.0, y: -8.0), size: panelInnerSize) + + if strongSelf.panelBackgroundNode.layer.mask == nil { + strongSelf.panelBackgroundNode.layer.mask = strongSelf.panelMaskLayer + } + strongSelf.panelMaskLayer?.frame = CGRect(origin: .zero, size: panelInnerSize) + if strongSelf.panelMaskLayer?.path == nil { + let path = generateRoundedRectWithTailPath(rectSize: CGSize(width: panelFrame.width, height: panelFrame.height), cornerRadius: 16.0, tailSize: CGSize(width: 16.0, height: 6.0), tailRadius: 2.0, tailPosition: 0.5, transformTail: false) + path.apply(CGAffineTransform(translationX: 7.0, y: 2.0)) + strongSelf.panelMaskLayer?.path = path.cgPath + } + + if themeUpdated { + strongSelf.closeIconNode.image = generateCloseButtonImage(color: item.presentationData.theme.theme.chat.message.incoming.secondaryTextColor) + } + + let _ = labelApply() + let _ = titleApply() + + let labelFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((contentSize.width - labelLayout.size.width) / 2.0), y: 2.0), size: labelLayout.size) + strongSelf.labelNode.frame = labelFrame + + let titleFrame = CGRect(origin: CGPoint(x: 16.0, y: 11.0), size: titleLayout.size) + strongSelf.titleNode.frame = titleFrame + + if let icon = strongSelf.closeIconNode.image { + let closeFrame = CGRect(origin: CGPoint(x: panelFrame.width - 5.0 - icon.size.width, y: 5.0), size: icon.size) + strongSelf.closeIconNode.frame = closeFrame + strongSelf.closeButtonNode.frame = closeFrame.insetBy(dx: -4.0, dy: -4.0) + } + + if isExpanded { + animation.animator.updateAlpha(layer: strongSelf.panelNode.layer, alpha: 1.0, completion: nil) + animation.animator.updateScale(layer: strongSelf.panelNode.layer, scale: 1.0, completion: nil) + } else { + animation.animator.updateAlpha(layer: strongSelf.panelNode.layer, alpha: 0.0, completion: nil) + animation.animator.updateScale(layer: strongSelf.panelNode.layer, scale: 0.1, completion: nil) + } + + let baseBackgroundFrame = labelFrame.offsetBy(dx: 0.0, dy: -11.0) + if let (offset, image) = backgroundMaskImage { + if strongSelf.backgroundNode == nil { + if let backgroundNode = item.controllerInteraction.presentationContext.backgroundNode?.makeBubbleBackground(for: .free) { + strongSelf.backgroundNode = backgroundNode + strongSelf.insertSubnode(backgroundNode, at: 0) + + backgroundNode.view.addGestureRecognizer(UITapGestureRecognizer(target: strongSelf, action: #selector(strongSelf.pressed))) + } + } + + if backgroundMaskUpdated, let backgroundNode = strongSelf.backgroundNode { + if labelRects.count == 1 { + backgroundNode.clipsToBounds = true + backgroundNode.cornerRadius = labelRects[0].height / 2.0 + backgroundNode.view.mask = nil + } else { + backgroundNode.clipsToBounds = false + backgroundNode.cornerRadius = 0.0 + backgroundNode.view.mask = strongSelf.backgroundMaskNode.view + } + } + + if let backgroundNode = strongSelf.backgroundNode { + backgroundNode.frame = CGRect(origin: CGPoint(x: baseBackgroundFrame.minX + offset.x, y: baseBackgroundFrame.minY + offset.y), size: image.size) + } + strongSelf.backgroundMaskNode.image = image + strongSelf.backgroundMaskNode.frame = CGRect(origin: CGPoint(), size: image.size) + + strongSelf.cachedMaskBackgroundImage = (offset, image, labelRects) + } + if let (rect, size) = strongSelf.absoluteRect { + strongSelf.updateAbsoluteRect(rect, within: size) + } + + strongSelf.updateList() + } + }) + }) + }) + } + } + + private func updateList() { + guard let item = self.item, let recommendedChannels = item.associatedData.recommendedChannels else { + return + } + let listSize = self.panelListView.update( + transition: .immediate, + component: AnyComponent( + ChannelListPanelComponent( + context: item.context, + theme: item.presentationData.theme.theme, + peers: recommendedChannels, + action: { peer in + item.controllerInteraction.openPeer(peer, .chat(textInputState: nil, subject: nil, peekData: nil), nil, .default) + } + ) + ), + environment: {}, + containerSize: CGSize(width: self.panelNode.frame.width, height: 100.0) + ) + if let view = self.panelListView.view { + if view.superview == nil { + self.panelNode.view.addSubview(view) + } + view.frame = CGRect(origin: CGPoint(x: 0.0, y: 42.0), size: listSize) + } + } + + override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { + self.absoluteRect = (rect, containerSize) + + if let backgroundNode = self.backgroundNode { + var backgroundFrame = backgroundNode.frame + backgroundFrame.origin.x += rect.minX + backgroundFrame.origin.y += rect.minY + backgroundNode.update(rect: backgroundFrame, within: containerSize, transition: .immediate) + } + + var panelBackgroundFrame = panelBackgroundNode.frame + panelBackgroundFrame.origin.x += self.panelNode.frame.minX + rect.minX + panelBackgroundFrame.origin.y += self.panelNode.frame.minY + rect.minY + self.panelBackgroundNode.updateAbsoluteRect(panelBackgroundFrame, within: containerSize) + } + + override public func applyAbsoluteOffset(value: CGPoint, animationCurve: ContainedViewLayoutTransitionCurve, duration: Double) { + if let backgroundNode = self.backgroundNode { + backgroundNode.offset(value: value, animationCurve: animationCurve, duration: duration) + } + } + + override public func applyAbsoluteOffsetSpring(value: CGFloat, duration: Double, damping: CGFloat) { + if let backgroundNode = self.backgroundNode { + backgroundNode.offsetSpring(value: value, duration: duration, damping: damping) + } + } + + override public func updateTouchesAtPoint(_ point: CGPoint?) { + if let item = self.item { + var rects: [(CGRect, CGRect)]? + let textNodeFrame = self.labelNode.frame + if let point = point { + if let (index, attributes) = self.labelNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY - 10.0)) { + let possibleNames: [String] = [ + TelegramTextAttributes.URL, + TelegramTextAttributes.PeerMention, + TelegramTextAttributes.PeerTextMention, + TelegramTextAttributes.BotCommand, + TelegramTextAttributes.Hashtag + ] + for name in possibleNames { + if let _ = attributes[NSAttributedString.Key(rawValue: name)] { + rects = self.labelNode.lineAndAttributeRects(name: name, at: index) + break + } + } + } + } + + if let rects = rects { + var mappedRects: [CGRect] = [] + for i in 0 ..< rects.count { + let lineRect = rects[i].0 + var itemRect = rects[i].1 + itemRect.origin.x = floor((textNodeFrame.size.width - lineRect.width) / 2.0) + itemRect.origin.x + mappedRects.append(itemRect) + } + + let linkHighlightingNode: LinkHighlightingNode + if let current = self.linkHighlightingNode { + linkHighlightingNode = current + } else { + let serviceColor = serviceMessageColorComponents(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper) + linkHighlightingNode = LinkHighlightingNode(color: serviceColor.linkHighlight) + linkHighlightingNode.inset = 2.5 + self.linkHighlightingNode = linkHighlightingNode + self.insertSubnode(linkHighlightingNode, belowSubnode: self.labelNode) + } + linkHighlightingNode.frame = self.labelNode.frame.offsetBy(dx: 0.0, dy: 1.5) + linkHighlightingNode.updateRects(mappedRects) + } else if let linkHighlightingNode = self.linkHighlightingNode { + self.linkHighlightingNode = nil + linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in + linkHighlightingNode?.removeFromSupernode() + }) + } + } + } + + override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { + let textNodeFrame = self.labelNode.frame + if let (index, attributes) = self.labelNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY - 10.0)), gesture == .tap { + if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { + var concealed = true + if let (attributeText, fullText) = self.labelNode.attributeSubstring(name: TelegramTextAttributes.URL, index: index) { + concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText) + } + return ChatMessageBubbleContentTapAction(content: .url(ChatMessageBubbleContentTapAction.Url(url: url, concealed: concealed))) + } else if let peerMention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention { + return ChatMessageBubbleContentTapAction(content: .peerMention(peerId: peerMention.peerId, mention: peerMention.mention, openProfile: false)) + } else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String { + return ChatMessageBubbleContentTapAction(content: .textMention(peerName)) + } else if let botCommand = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String { + return ChatMessageBubbleContentTapAction(content: .botCommand(botCommand)) + } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag { + return ChatMessageBubbleContentTapAction(content: .hashtag(hashtag.peerName, hashtag.hashtag)) + } + } + + if let backgroundNode = self.backgroundNode, backgroundNode.frame.contains(point) { + return ChatMessageBubbleContentTapAction(content: .ignore) + } + + if self.panelNode.frame.contains(point) { + let panelPoint = self.view.convert(point, to: self.panelNode.view) + if self.closeButtonNode.frame.contains(panelPoint) { + return ChatMessageBubbleContentTapAction(content: .ignore) + } + } + + return ChatMessageBubbleContentTapAction(content: .none) + } +} + +private class MessageBackgroundNode: ASDisplayNode { + private let backgroundWallpaperNode: ChatMessageBubbleBackdrop + private let backgroundNode: ChatMessageBackground + + override init() { + self.backgroundWallpaperNode = ChatMessageBubbleBackdrop() + self.backgroundNode = ChatMessageBackground() + self.backgroundNode.backdropNode = self.backgroundWallpaperNode + + super.init() + + self.addSubnode(self.backgroundNode) + self.addSubnode(self.backgroundWallpaperNode) + } + + private var absoluteRect: (CGRect, CGSize)? + + func update(size: CGSize, theme: PresentationTheme, wallpaper: TelegramWallpaper, graphics: PrincipalThemeEssentialGraphics, wallpaperBackgroundNode: WallpaperBackgroundNode, transition: ContainedViewLayoutTransition) { + self.backgroundNode.setType(type: .incoming(.Extracted), highlighted: false, graphics: graphics, maskMode: false, hasWallpaper: wallpaper.hasWallpaper, transition: transition, backgroundNode: wallpaperBackgroundNode) + self.backgroundWallpaperNode.setType(type: .incoming(.Extracted), theme: ChatPresentationThemeData(theme: theme, wallpaper: wallpaper), essentialGraphics: graphics, maskMode: false, backgroundNode: wallpaperBackgroundNode) + + let backgroundFrame = CGRect(origin: CGPoint(), size: size) + self.backgroundNode.updateLayout(size: backgroundFrame.size, transition: transition) + self.backgroundWallpaperNode.updateFrame(backgroundFrame, transition: transition) + + if let (rect, size) = self.absoluteRect { + self.updateAbsoluteRect(rect, within: size) + } + } + + func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { + self.absoluteRect = (rect, containerSize) + + var backgroundWallpaperFrame = self.backgroundWallpaperNode.frame + backgroundWallpaperFrame.origin.x += rect.minX + backgroundWallpaperFrame.origin.y += rect.minY + self.backgroundWallpaperNode.update(rect: backgroundWallpaperFrame, within: containerSize) + } +} + +private let itemSize = CGSize(width: 94.0, height: 90.0) + +private final class ChannelItemComponent: Component { + let context: AccountContext + let theme: PresentationTheme + let peer: EnginePeer + let subtitle: String + let action: (EnginePeer) -> Void + + init( + context: AccountContext, + theme: PresentationTheme, + peer: EnginePeer, + subtitle: String, + action: @escaping (EnginePeer) -> Void + ) { + self.context = context + self.theme = theme + self.peer = peer + self.subtitle = subtitle + self.action = action + } + + static func ==(lhs: ChannelItemComponent, rhs: ChannelItemComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.peer != rhs.peer { + return false + } + if lhs.subtitle != rhs.subtitle { + return false + } + return true + } + + final class View: UIView { + private let containerButton: HighlightTrackingButton + + private let title = ComponentView() + private let subtitle = ComponentView() + private let avatarNode: AvatarNode + + private var component: ChannelItemComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0)) + self.avatarNode.isUserInteractionEnabled = false + + self.containerButton = HighlightTrackingButton() + + super.init(frame: frame) + + self.addSubview(self.containerButton) + self.addSubnode(self.avatarNode) + + self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + guard let component = self.component else { + return + } + component.action(component.peer) + } + + func update(component: ChannelItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.peer.compactDisplayTitle, font: Font.regular(11.0), textColor: component.theme.chat.message.incoming.primaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: itemSize.width - 20.0, height: 100.0) + ) + + let subtitleSize = self.subtitle.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.subtitle, font: Font.regular(10.0), textColor: component.theme.chat.message.incoming.secondaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: itemSize.width - 12.0, height: 100.0) + ) + + let avatarSize = CGSize(width: 60.0, height: 60.0) + let avatarFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((itemSize.width - avatarSize.width) / 2.0), y: 0.0), size: avatarSize) + let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((itemSize.width - titleSize.width) / 2.0), y: avatarFrame.maxY + 4.0), size: titleSize) + let subtitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((itemSize.width - subtitleSize.width) / 2.0), y: titleFrame.maxY + 1.0), size: subtitleSize) + + self.avatarNode.frame = avatarFrame + self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: component.peer) + + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + } + if let subtitleView = self.subtitle.view { + if subtitleView.superview == nil { + subtitleView.isUserInteractionEnabled = false + self.containerButton.addSubview(subtitleView) + } + subtitleView.frame = subtitleFrame + } + + self.containerButton.frame = CGRect(origin: .zero, size: itemSize) + + return itemSize + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +final class ChannelListPanelComponent: Component { + typealias EnvironmentType = Empty + + let context: AccountContext + let theme: PresentationTheme + let peers: RecommendedChannels + let action: (EnginePeer) -> Void + + init( + context: AccountContext, + theme: PresentationTheme, + peers: RecommendedChannels, + action: @escaping (EnginePeer) -> Void + ) { + self.context = context + self.theme = theme + self.peers = peers + self.action = action + } + + static func ==(lhs: ChannelListPanelComponent, rhs: ChannelListPanelComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.peers != rhs.peers { + return false + } + return true + } + + private struct ItemLayout: Equatable { + let containerInsets: UIEdgeInsets + let containerHeight: CGFloat + let itemWidth: CGFloat + let itemCount: Int + + let contentWidth: CGFloat + + init( + containerInsets: UIEdgeInsets, + containerHeight: CGFloat, + itemWidth: CGFloat, + itemCount: Int + ) { + self.containerInsets = containerInsets + self.containerHeight = containerHeight + self.itemWidth = itemWidth + self.itemCount = itemCount + + self.contentWidth = containerInsets.left + containerInsets.right + CGFloat(itemCount) * itemWidth + } + + func visibleItems(for rect: CGRect) -> Range? { + let offsetRect = rect.offsetBy(dx: -self.containerInsets.left, dy: -self.containerInsets.top) + var minVisibleRow = Int(floor((offsetRect.minX) / (self.itemWidth))) + minVisibleRow = max(0, minVisibleRow) + let maxVisibleRow = Int(ceil((offsetRect.maxX) / (self.itemWidth))) + + let minVisibleIndex = minVisibleRow + let maxVisibleIndex = maxVisibleRow + + if maxVisibleIndex >= minVisibleIndex { + return minVisibleIndex ..< (maxVisibleIndex + 1) + } else { + return nil + } + } + + func itemFrame(for index: Int) -> CGRect { + return CGRect(origin: CGPoint(x: self.containerInsets.top + CGFloat(index) * self.itemWidth, y: 0.0), size: CGSize(width: self.itemWidth, height: self.containerHeight)) + } + } + + private final class ScrollViewImpl: UIScrollView { + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + class View: UIView, UIScrollViewDelegate { + private let scrollView: ScrollViewImpl + + private let measureItem = ComponentView() + private var visibleItems: [EnginePeer.Id: ComponentView] = [:] + + private var ignoreScrolling: Bool = false + + private var component: ChannelListPanelComponent? + private var itemLayout: ItemLayout? + + override init(frame: CGRect) { + self.scrollView = ScrollViewImpl() + + super.init(frame: frame) + + self.scrollView.delaysContentTouches = true + self.scrollView.canCancelContentTouches = true + self.scrollView.clipsToBounds = false + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.scrollView.contentInsetAdjustmentBehavior = .never + } + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.alwaysBounceHorizontal = true + self.scrollView.scrollsToTop = false + self.scrollView.delegate = self + self.scrollView.clipsToBounds = true + self.addSubview(self.scrollView) + + self.disablesInteractiveTransitionGestureRecognizer = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + private func updateScrolling(transition: Transition) { + guard let component = self.component, let itemLayout = self.itemLayout else { + return + } + + let visibleBounds = self.scrollView.bounds.insetBy(dx: -100.0, dy: 0.0) + + var validIds = Set() + if let visibleItems = itemLayout.visibleItems(for: visibleBounds) { + for index in visibleItems.lowerBound ..< visibleItems.upperBound { + if index >= component.peers.channels.count { + continue + } + let item = component.peers.channels[index] + let id = item.peer.id + validIds.insert(id) + + var itemTransition = transition + let itemView: ComponentView + if let current = self.visibleItems[id] { + itemView = current + } else { + itemTransition = .immediate + itemView = ComponentView() + self.visibleItems[id] = itemView + } + + let subtitle = countString(Int64(item.subscribers)) + let _ = itemView.update( + transition: itemTransition, + component: AnyComponent(ChannelItemComponent( + context: component.context, + theme: component.theme, + peer: item.peer, + subtitle: subtitle, + action: component.action + )), + environment: {}, + containerSize: CGSize(width: itemLayout.itemWidth, height: itemLayout.containerHeight) + ) + let itemFrame = itemLayout.itemFrame(for: index) + if let itemComponentView = itemView.view { + if itemComponentView.superview == nil { + self.scrollView.addSubview(itemComponentView) + } + itemTransition.setFrame(view: itemComponentView, frame: itemFrame) + } + } + } + + var removeIds: [EnginePeer.Id] = [] + for (id, itemView) in self.visibleItems { + if !validIds.contains(id) { + removeIds.append(id) + if let itemComponentView = itemView.view { + transition.setAlpha(view: itemComponentView, alpha: 0.0, completion: { [weak itemComponentView] _ in + itemComponentView?.removeFromSuperview() + }) + } + } + } + for id in removeIds { + self.visibleItems.removeValue(forKey: id) + } + } + + func update(component: ChannelListPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + + let itemLayout = ItemLayout( + containerInsets: .zero, + containerHeight: availableSize.height, + itemWidth: itemSize.width, + itemCount: component.peers.channels.count + ) + self.itemLayout = itemLayout + + self.ignoreScrolling = true + let contentOffset = self.scrollView.bounds.minY + transition.setPosition(view: self.scrollView, position: CGRect(origin: CGPoint(), size: availableSize).center) + var scrollBounds = self.scrollView.bounds + scrollBounds.size = availableSize + transition.setBounds(view: self.scrollView, bounds: scrollBounds) + let contentSize = CGSize(width: itemLayout.contentWidth, height: availableSize.height) + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + if !transition.animation.isImmediate && self.scrollView.bounds.minY != contentOffset { + let deltaOffset = self.scrollView.bounds.minY - contentOffset + transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: -deltaOffset), to: CGPoint(), additive: true) + } + self.ignoreScrolling = false + self.updateScrolling(transition: transition) + + return availableSize + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift index 664f73eab7..5e722ef69f 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift @@ -117,7 +117,7 @@ struct ChatRecentActionsEntry: Comparable, Identifiable { let action = TelegramMediaActionType.titleUpdated(title: new) let message = Message(stableId: self.entry.stableId, stableVersion: 0, id: MessageId(peerId: peer.id, namespace: Namespaces.Message.Cloud, id: Int32(bitPattern: self.entry.stableId)), globallyUniqueId: self.entry.event.id, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: self.entry.event.date, flags: [.Incoming], tags: [], globalTags: [], localTags: [], forwardInfo: nil, author: author, text: "", attributes: [], media: [TelegramMediaAction(action: action)], peers: peers, associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) - return ChatMessageItemImpl(presentationData: self.presentationData, context: context, chatLocation: .peer(id: peer.id), associatedData: ChatMessageItemAssociatedData(automaticDownloadPeerType: .channel, automaticDownloadPeerId: nil, automaticDownloadNetworkType: .cellular, isRecentActions: true, availableReactions: nil, defaultReaction: nil, isPremium: false, accountPeer: nil), controllerInteraction: controllerInteraction, content: .message(message: message, read: true, selection: .none, attributes: ChatMessageEntryAttributes(), location: nil)) + return ChatMessageItemImpl(presentationData: self.presentationData, context: context, chatLocation: .peer(id: peer.id), associatedData: ChatMessageItemAssociatedData(automaticDownloadPeerType: .channel, automaticDownloadPeerId: nil, automaticDownloadNetworkType: .cellular, isRecentActions: true, availableReactions: nil, defaultReaction: nil, isPremium: false, accountPeer: nil), controllerInteraction: controllerInteraction, content: .message(message: message, read: true, selection: .none, attributes: ChatMessageEntryAttributes(), location: nil)) case let .changeAbout(prev, new): var peers = SimpleDictionary() var author: Peer? diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 6814c0a76f..29f025f4ab 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -17805,9 +17805,32 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } contextItems.append(.action(ContextMenuActionItem(text: globalTitle, textColor: .destructive, icon: { _ in nil }, action: { [weak self] _, f in if let strongSelf = self { - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) - let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() - f(.dismissWithoutContent) + var giveaway: TelegramMediaGiveaway? + for messageId in messageIds { + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) { + if let media = message.media.first(where: { $0 is TelegramMediaGiveaway }) as? TelegramMediaGiveaway { + giveaway = media + break + } + } + } + let commit = { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) + let _ = strongSelf.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() + } + if let giveaway { + Queue.mainQueue().after(0.2) { + let dateString = stringForDate(timestamp: giveaway.untilDate, timeZone: .current, strings: strongSelf.presentationData.strings) + strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: strongSelf.presentationData.strings.Chat_Giveaway_DeleteConfirmation_Title, text: strongSelf.presentationData.strings.Chat_Giveaway_DeleteConfirmation_Text(dateString).string, actions: [TextAlertAction(type: .destructiveAction, title: strongSelf.presentationData.strings.Common_Delete, action: { + commit() + }), TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_Cancel, action: { + })], parseMarkdown: true), in: .window(.root)) + } + f(.default) + } else { + commit() + f(.dismissWithoutContent) + } } }))) items.append(ActionSheetButtonItem(title: globalTitle, color: .destructive, action: { [weak self, weak actionSheet] in diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index 1d1bc02292..386d8db286 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -320,7 +320,26 @@ private final class ChatHistoryTransactionOpaqueState { } } -private func extractAssociatedData(chatLocation: ChatLocation, view: MessageHistoryView, automaticDownloadNetworkType: MediaAutoDownloadNetworkType, animatedEmojiStickers: [String: [StickerPackItem]], additionalAnimatedEmojiStickers: [String: [Int: StickerPackItem]], subject: ChatControllerSubject?, currentlyPlayingMessageId: MessageIndex?, isCopyProtectionEnabled: Bool, availableReactions: AvailableReactions?, defaultReaction: MessageReaction.Reaction?, isPremium: Bool, alwaysDisplayTranscribeButton: ChatMessageItemAssociatedData.DisplayTranscribeButton, accountPeer: EnginePeer?, topicAuthorId: EnginePeer.Id?, hasBots: Bool, translateToLanguage: String?, maxReadStoryId: Int32?) -> ChatMessageItemAssociatedData { +private func extractAssociatedData( + chatLocation: ChatLocation, + view: MessageHistoryView, + automaticDownloadNetworkType: MediaAutoDownloadNetworkType, + animatedEmojiStickers: [String: [StickerPackItem]], + additionalAnimatedEmojiStickers: [String: [Int: StickerPackItem]], + subject: ChatControllerSubject?, + currentlyPlayingMessageId: MessageIndex?, + isCopyProtectionEnabled: Bool, + availableReactions: AvailableReactions?, + defaultReaction: MessageReaction.Reaction?, + isPremium: Bool, + alwaysDisplayTranscribeButton: ChatMessageItemAssociatedData.DisplayTranscribeButton, + accountPeer: EnginePeer?, + topicAuthorId: EnginePeer.Id?, + hasBots: Bool, + translateToLanguage: String?, + maxReadStoryId: Int32?, + recommendedChannels: RecommendedChannels? +) -> ChatMessageItemAssociatedData { var automaticDownloadPeerId: EnginePeer.Id? var automaticMediaDownloadPeerType: MediaAutoDownloadPeerType = .channel var contactsPeerIds: Set = Set() @@ -374,7 +393,7 @@ private func extractAssociatedData(chatLocation: ChatLocation, view: MessageHist automaticDownloadPeerId = message.messageId.peerId } - return ChatMessageItemAssociatedData(automaticDownloadPeerType: automaticMediaDownloadPeerType, automaticDownloadPeerId: automaticDownloadPeerId, automaticDownloadNetworkType: automaticDownloadNetworkType, isRecentActions: false, subject: subject, contactsPeerIds: contactsPeerIds, channelDiscussionGroup: channelDiscussionGroup, animatedEmojiStickers: animatedEmojiStickers, additionalAnimatedEmojiStickers: additionalAnimatedEmojiStickers, currentlyPlayingMessageId: currentlyPlayingMessageId, isCopyProtectionEnabled: isCopyProtectionEnabled, availableReactions: availableReactions, defaultReaction: defaultReaction, isPremium: isPremium, accountPeer: accountPeer, alwaysDisplayTranscribeButton: alwaysDisplayTranscribeButton, topicAuthorId: topicAuthorId, hasBots: hasBots, translateToLanguage: translateToLanguage, maxReadStoryId: maxReadStoryId) + return ChatMessageItemAssociatedData(automaticDownloadPeerType: automaticMediaDownloadPeerType, automaticDownloadPeerId: automaticDownloadPeerId, automaticDownloadNetworkType: automaticDownloadNetworkType, isRecentActions: false, subject: subject, contactsPeerIds: contactsPeerIds, channelDiscussionGroup: channelDiscussionGroup, animatedEmojiStickers: animatedEmojiStickers, additionalAnimatedEmojiStickers: additionalAnimatedEmojiStickers, currentlyPlayingMessageId: currentlyPlayingMessageId, isCopyProtectionEnabled: isCopyProtectionEnabled, availableReactions: availableReactions, defaultReaction: defaultReaction, isPremium: isPremium, accountPeer: accountPeer, alwaysDisplayTranscribeButton: alwaysDisplayTranscribeButton, topicAuthorId: topicAuthorId, hasBots: hasBots, translateToLanguage: translateToLanguage, maxReadStoryId: maxReadStoryId, recommendedChannels: recommendedChannels) } private extension ChatHistoryLocationInput { @@ -1290,7 +1309,8 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { self.pendingRemovedMessagesPromise.get(), self.currentlyPlayingMessageIdPromise.get(), self.scrollToMessageIdPromise.get(), - self.chatHasBotsPromise.get() + self.chatHasBotsPromise.get(), + self.allAdMessagesPromise.get() ) let maxReadStoryId: Signal @@ -1311,6 +1331,13 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { maxReadStoryId = .single(nil) } + let recommendedChannels: Signal + if let peerId = self.chatLocation.peerId, peerId.namespace == Namespaces.Peer.CloudChannel { + recommendedChannels = self.context.engine.peers.recommendedChannels(peerId: peerId) + } else { + recommendedChannels = .single(nil) + } + let messageViewQueue = Queue.mainQueue() let historyViewTransitionDisposable = combineLatest(queue: messageViewQueue, historyViewUpdate, @@ -1328,11 +1355,11 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { audioTranscriptionSuggestion, promises, topicAuthorId, - self.allAdMessagesPromise.get(), translationState, - maxReadStoryId - ).startStrict(next: { [weak self] update, chatPresentationData, selectedMessages, updatingMedia, networkType, animatedEmojiStickers, additionalAnimatedEmojiStickers, customChannelDiscussionReadState, customThreadOutgoingReadState, availableReactions, defaultReaction, accountPeer, suggestAudioTranscription, promises, topicAuthorId, allAdMessages, translationState, maxReadStoryId in - let (historyAppearsCleared, pendingUnpinnedAllMessages, pendingRemovedMessages, currentlyPlayingMessageIdAndType, scrollToMessageId, chatHasBots) = promises + maxReadStoryId, + recommendedChannels + ).startStrict(next: { [weak self] update, chatPresentationData, selectedMessages, updatingMedia, networkType, animatedEmojiStickers, additionalAnimatedEmojiStickers, customChannelDiscussionReadState, customThreadOutgoingReadState, availableReactions, defaultReaction, accountPeer, suggestAudioTranscription, promises, topicAuthorId, translationState, maxReadStoryId, recommendedChannels in + let (historyAppearsCleared, pendingUnpinnedAllMessages, pendingRemovedMessages, currentlyPlayingMessageIdAndType, scrollToMessageId, chatHasBots, allAdMessages) = promises func applyHole() { Queue.mainQueue().async { @@ -1455,7 +1482,7 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { reverseGroups = reverseGroupsValue } - var isCopyProtectionEnabled: Bool = data.initialData?.peer?.isCopyProtectionEnabled ?? false + var isCopyProtectionEnabled: Bool = data.initialData?.peer?.isCopyProtectionEnabled ?? false for entry in view.additionalData { if case let .peer(_, maybePeer) = entry, let peer = maybePeer { isCopyProtectionEnabled = peer.isCopyProtectionEnabled @@ -1487,7 +1514,7 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { translateToLanguage = languageCode } - let associatedData = extractAssociatedData(chatLocation: chatLocation, view: view, automaticDownloadNetworkType: networkType, animatedEmojiStickers: animatedEmojiStickers, additionalAnimatedEmojiStickers: additionalAnimatedEmojiStickers, subject: subject, currentlyPlayingMessageId: currentlyPlayingMessageIdAndType?.0, isCopyProtectionEnabled: isCopyProtectionEnabled, availableReactions: availableReactions, defaultReaction: defaultReaction, isPremium: isPremium, alwaysDisplayTranscribeButton: alwaysDisplayTranscribeButton, accountPeer: accountPeer, topicAuthorId: topicAuthorId, hasBots: chatHasBots, translateToLanguage: translateToLanguage, maxReadStoryId: maxReadStoryId) + let associatedData = extractAssociatedData(chatLocation: chatLocation, view: view, automaticDownloadNetworkType: networkType, animatedEmojiStickers: animatedEmojiStickers, additionalAnimatedEmojiStickers: additionalAnimatedEmojiStickers, subject: subject, currentlyPlayingMessageId: currentlyPlayingMessageIdAndType?.0, isCopyProtectionEnabled: isCopyProtectionEnabled, availableReactions: availableReactions, defaultReaction: defaultReaction, isPremium: isPremium, alwaysDisplayTranscribeButton: alwaysDisplayTranscribeButton, accountPeer: accountPeer, topicAuthorId: topicAuthorId, hasBots: chatHasBots, translateToLanguage: translateToLanguage, maxReadStoryId: maxReadStoryId, recommendedChannels: recommendedChannels) let filteredEntries = chatHistoryEntriesForView( location: chatLocation, diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index df161c0d6c..01e67bf40c 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -565,7 +565,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState var loadStickerSaveStatus: MediaId? var loadCopyMediaResource: MediaResource? var isAction = false - var isGiveawayLaunch = false + var isGiveawayServiceMessage = false var diceEmoji: String? if messages.count == 1 { for media in messages[0].media { @@ -580,8 +580,13 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } else if media is TelegramMediaAction || media is TelegramMediaExpiredContent { isAction = true - if let action = media as? TelegramMediaAction, case .giveawayLaunched = action.action { - isGiveawayLaunch = true + if let action = media as? TelegramMediaAction { + switch action.action { + case .giveawayLaunched, .giveawayResults: + isGiveawayServiceMessage = true + default: + break + } } } else if let image = media as? TelegramMediaImage { if !messages[0].containsSecretMedia { @@ -643,7 +648,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState canPin = false } - if isGiveawayLaunch { + if isGiveawayServiceMessage { canReply = false }