import Foundation import UIKit import AsyncDisplayKit import Display import Postbox import TelegramCore import SwiftSignalKit import LegacyComponents import TelegramPresentationData import TelegramUIPreferences import ActivityIndicator import TelegramStringFormatting import PeerPresenceStatusManager import ChatTitleActivityNode import LocalizedPeerData import PhoneNumberFormat import AnimatedCountLabelNode import AccountContext import ComponentFlow import EmojiStatusComponent import AnimationCache import MultiAnimationRenderer import ComponentDisplayAdapters import GlassBackgroundComponent import AnimatedTextComponent #if canImport(SGSimpleSettings) import SGSimpleSettings #endif #if canImport(SGSupporters) import SGSupporters private func sgTitleBadgeIcon(_ badge: (name: String, color: UIColor, displayMode: String, imageURL: String?)) -> ChatTitleCredibilityIcon { if badge.displayMode == "image", let url = badge.imageURL, let img = SGSupporters.cachedBadgeImage(for: url) { return .serverImageBadge(image: img) } return .serverBadge(name: badge.name, colorRgb: badge.color.rgb) } #endif private let titleFont = Font.with(size: 17.0, design: .regular, weight: .semibold, traits: [.monospacedNumbers]) private let subtitleFont = Font.regular(13.0) public enum ChatTitleContent: Equatable { public struct PeerData: Equatable { public var peerId: PeerId public var peer: Peer? public var isContact: Bool public var isSavedMessages: Bool public var notificationSettings: TelegramPeerNotificationSettings? public var peerPresences: [PeerId: PeerPresence] public var cachedData: CachedPeerData? public init(peerId: PeerId, peer: Peer?, isContact: Bool, isSavedMessages: Bool, notificationSettings: TelegramPeerNotificationSettings?, peerPresences: [PeerId: PeerPresence], cachedData: CachedPeerData?) { self.peerId = peerId self.peer = peer self.isContact = isContact self.isSavedMessages = isSavedMessages self.notificationSettings = notificationSettings self.peerPresences = peerPresences self.cachedData = cachedData } public init(peerView: PeerView) { self.init(peerId: peerView.peerId, peer: peerViewMainPeer(peerView), isContact: peerView.peerIsContact, isSavedMessages: false, notificationSettings: peerView.notificationSettings as? TelegramPeerNotificationSettings, peerPresences: peerView.peerPresences, cachedData: peerView.cachedData) } public static func ==(lhs: PeerData, rhs: PeerData) -> Bool { if let lhsPeer = lhs.peer, let rhsPeer = rhs.peer { if !lhsPeer.isEqual(rhsPeer) { return false } } else if (lhs.peer == nil) != (rhs.peer == nil) { return false } if lhs.isContact != rhs.isContact { return false } if lhs.isSavedMessages != rhs.isSavedMessages { return false } if lhs.notificationSettings != rhs.notificationSettings { return false } if lhs.peerPresences.count != rhs.peerPresences.count { return false } else { for (key, value) in lhs.peerPresences { if let rhsValue = rhs.peerPresences[key] { if !value.isEqual(to: rhsValue) { return false } } else { return false } } } if lhs.cachedData !== rhs.cachedData { return false } return true } } public enum ReplyThreadType { case comments case replies } public struct TitleTextItem: Equatable { public enum Content: Equatable { case text(String) case number(Int, minDigits: Int) } public var id: AnyHashable public var isUnbreakable: Bool public var content: Content public init(id: AnyHashable, isUnbreakable: Bool = true, content: Content) { self.id = id self.isUnbreakable = isUnbreakable self.content = content } } case peer(peerView: PeerData, customTitle: String?, customSubtitle: String?, onlineMemberCount: (total: Int32?, recent: Int32?), isScheduledMessages: Bool, isMuted: Bool?, customMessageCount: Int?, isEnabled: Bool) case replyThread(type: ReplyThreadType, count: Int) case custom(title: [TitleTextItem], subtitle: String?, isEnabled: Bool) public static func ==(lhs: ChatTitleContent, rhs: ChatTitleContent) -> Bool { switch lhs { case let .peer(peerView, customTitle, customSubtitle, onlineMemberCount, isScheduledMessages, isMuted, customMessageCount, isEnabled): if case let .peer(rhsPeerView, rhsCustomTitle, rhsCustomSubtitle, rhsOnlineMemberCount, rhsIsScheduledMessages, rhsIsMuted, rhsCustomMessageCount, rhsIsEnabled) = rhs { if peerView != rhsPeerView { return false } if customTitle != rhsCustomTitle { return false } if customSubtitle != rhsCustomSubtitle { return false } if onlineMemberCount.0 != rhsOnlineMemberCount.0 || onlineMemberCount.1 != rhsOnlineMemberCount.1 { return false } if isScheduledMessages != rhsIsScheduledMessages { return false } if isMuted != rhsIsMuted { return false } if customMessageCount != rhsCustomMessageCount { return false } if isEnabled != rhsIsEnabled { return false } return true } else { return false } case let .replyThread(type, count): if case .replyThread(type, count) = rhs { return true } else { return false } case let .custom(title, status, active): if case .custom(title, status, active) = rhs { return true } else { return false } } } } enum ChatTitleIcon { case none case lock case mute } enum ChatTitleCredibilityIcon: Equatable { case none case fake case scam case verified case premium case emojiStatus(PeerEmojiStatus) // MARK: - GLEGram case verifiedGLEGram case developer case serverBadge(name: String, colorRgb: UInt32) case serverImageBadge(image: UIImage) // MARK: - End GLEGram } public final class ChatTitleView: UIView, NavigationBarTitleView { public enum AnimateFromSnapshotDirection { case up case down case left case right } private let context: AccountContext private var theme: PresentationTheme private var strings: PresentationStrings private var dateTimeFormat: PresentationDateTimeFormat private var nameDisplayOrder: PresentationPersonNameOrder private let animationCache: AnimationCache private let animationRenderer: MultiAnimationRenderer private let contentContainer: ASDisplayNode private let backgroundView: GlassBackgroundView public let titleContainerView: PortalSourceView public let titleTextNode: ImmediateAnimatedCountLabelNode public let titleLeftIconNode: ASImageNode public let titleRightIconNode: ASImageNode public let titleCredibilityIconView: ComponentHostView public let titleVerifiedIconView: ComponentHostView public let titleStatusIconView: ComponentHostView public let activityNode: ChatTitleActivityNode private let button: HighlightTrackingButtonNode public var disableAnimations: Bool = false var manualLayout: Bool = false private var validLayout: CGSize? public var requestUpdate: ((ContainedViewLayoutTransition) -> Void)? private var titleLeftIcon: ChatTitleIcon = .none private var titleRightIcon: ChatTitleIcon = .none private var titleCredibilityIcon: ChatTitleCredibilityIcon = .none private var titleVerifiedIcon: ChatTitleCredibilityIcon = .none private var titleStatusIcon: ChatTitleCredibilityIcon = .none // MARK: - GLEGram private var badgeImageObserver: NSObjectProtocol? // MARK: - End GLEGram private var presenceManager: PeerPresenceStatusManager? private var pointerInteraction: PointerInteraction? public var inputActivities: ChatTitleComponent.Activities? { didSet { let _ = self.updateStatus() } } private func updateNetworkStatusNode(networkState: AccountNetworkState, layout: ContainerViewLayout?) { if self.manualLayout { self.setNeedsLayout() } } public var networkState: AccountNetworkState = .online(proxy: nil) { didSet { if self.networkState != oldValue { updateNetworkStatusNode(networkState: self.networkState, layout: self.layout) let _ = self.updateStatus() } } } public var layout: ContainerViewLayout? { didSet { if self.layout != oldValue { updateNetworkStatusNode(networkState: self.networkState, layout: self.layout) } } } public var pressed: (() -> Void)? public var longPressed: (() -> Void)? public var titleContent: ChatTitleContent? { didSet { if let titleContent = self.titleContent { let titleTheme = self.theme var segments: [AnimatedCountLabelNode.Segment] = [] var titleLeftIcon: ChatTitleIcon = .none var titleRightIcon: ChatTitleIcon = .none var titleCredibilityIcon: ChatTitleCredibilityIcon = .none var titleVerifiedIcon: ChatTitleCredibilityIcon = .none var titleStatusIcon: ChatTitleCredibilityIcon = .none var isEnabled = true switch titleContent { case let .peer(peerView, customTitle, _, _, isScheduledMessages, isMuted, _, isEnabledValue): if peerView.peerId.isReplies { let typeText: String = self.strings.DialogList_Replies segments = [.text(0, NSAttributedString(string: typeText, font: titleFont, textColor: titleTheme.rootController.navigationBar.primaryTextColor))] isEnabled = false } else if isScheduledMessages { if peerView.peerId == self.context.account.peerId { segments = [.text(0, NSAttributedString(string: self.strings.ScheduledMessages_RemindersTitle, font: titleFont, textColor: titleTheme.rootController.navigationBar.primaryTextColor))] } else { segments = [.text(0, NSAttributedString(string: self.strings.ScheduledMessages_Title, font: titleFont, textColor: titleTheme.rootController.navigationBar.primaryTextColor))] } isEnabled = false } else { if let peer = peerView.peer { if let customTitle { segments = [.text(0, NSAttributedString(string: customTitle, font: titleFont, textColor: titleTheme.rootController.navigationBar.primaryTextColor))] } else if peerView.peerId == self.context.account.peerId { if peerView.isSavedMessages { segments = [.text(0, NSAttributedString(string: self.strings.Conversation_MyNotes, font: titleFont, textColor: titleTheme.rootController.navigationBar.primaryTextColor))] } else { segments = [.text(0, NSAttributedString(string: self.strings.Conversation_SavedMessages, font: titleFont, textColor: titleTheme.rootController.navigationBar.primaryTextColor))] } } else if peerView.peerId.isAnonymousSavedMessages { segments = [.text(0, NSAttributedString(string: self.strings.ChatList_AuthorHidden, font: titleFont, textColor: titleTheme.rootController.navigationBar.primaryTextColor))] } else { if !peerView.isContact, let user = peer as? TelegramUser, !user.flags.contains(.isSupport), user.botInfo == nil, let phone = user.phone, !phone.isEmpty { segments = [.text(0, NSAttributedString(string: formatPhoneNumber(context: self.context, number: phone), font: titleFont, textColor: titleTheme.rootController.navigationBar.primaryTextColor))] } else { segments = [.text(0, NSAttributedString(string: EnginePeer(peer).displayTitle(strings: self.strings, displayOrder: self.nameDisplayOrder), font: titleFont, textColor: titleTheme.rootController.navigationBar.primaryTextColor))] } } if peer.id != self.context.account.peerId { let premiumConfiguration = PremiumConfiguration.with(appConfiguration: self.context.currentAppConfiguration.with { $0 }) // MARK: - GLEGram - verified badge + badges #if canImport(SGSimpleSettings) let glegramChannelIds: Set = [-1003574063854, -1003791606969, -1003618396753] let isGLEGramChannel = (peer as? TelegramChannel) != nil && (peer.addressName?.lowercased() == "glegramios" || glegramChannelIds.contains(peer.id.toInt64())) #else let isGLEGramChannel = false #endif let isVerifiedPeer = isGLEGramChannel || peer.isVerified || (peer as? TelegramChannel)?.flags.contains(.isVerified) ?? false if isVerifiedPeer { titleCredibilityIcon = isGLEGramChannel ? .verifiedGLEGram : .verified } if !isVerifiedPeer { if peer.isFake { titleCredibilityIcon = .fake } else if peer.isScam { titleCredibilityIcon = .scam } else if let emojiStatus = peer.emojiStatus { titleCredibilityIcon = .emojiStatus(emojiStatus) } else { #if canImport(SGSimpleSettings) let effectiveIsPremium = SGSimpleSettings.shared.isPremium(peerId: peer.id.id._internalGetInt64Value(), accountPeerId: self.context.account.peerId.id._internalGetInt64Value(), isPremium: peer.isPremium) #else let effectiveIsPremium = peer.isPremium #endif if effectiveIsPremium && !premiumConfiguration.isPremiumDisabled { titleCredibilityIcon = .premium } } } #if canImport(SGSupporters) if let user = peer as? TelegramUser, user.id.namespace == Namespaces.Peer.CloudUser { let serverBadges = SGSupporters.badges(forUserId: user.id.id._internalGetInt64Value()) if let first = serverBadges.first { let icon = sgTitleBadgeIcon(first) if titleCredibilityIcon == .none { titleCredibilityIcon = icon } else if titleStatusIcon == .none { titleStatusIcon = icon } } if serverBadges.count > 1, titleStatusIcon == .none { titleStatusIcon = sgTitleBadgeIcon(serverBadges[1]) } } #endif // MARK: - End GLEGram if peer.isVerified { titleCredibilityIcon = .verified } if let verificationIconFileId = peer.verificationIconFileId { titleVerifiedIcon = .emojiStatus(PeerEmojiStatus(content: .emoji(fileId: verificationIconFileId), expirationDate: nil)) } } } if peerView.peerId.namespace == Namespaces.Peer.SecretChat { titleLeftIcon = .lock } if let isMuted { if isMuted { titleRightIcon = .mute } } else { if let notificationSettings = peerView.notificationSettings { if case let .muted(until) = notificationSettings.muteState, until >= Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) { if titleCredibilityIcon != .verified && titleCredibilityIcon != .verifiedGLEGram { // MARK: - GLEGram titleRightIcon = .mute } } } } if peerView.peerId.isVerificationCodes { isEnabled = false } else { isEnabled = isEnabledValue } } case let .replyThread(type, count): let textFont = titleFont let textColor = titleTheme.rootController.navigationBar.primaryTextColor if count > 0 { var commentsPart: String switch type { case .comments: commentsPart = self.strings.Conversation_TitleComments(Int32(count)) case .replies: commentsPart = self.strings.Conversation_TitleReplies(Int32(count)) } if commentsPart.contains("[") && commentsPart.contains("]") { if let startIndex = commentsPart.firstIndex(of: "["), let endIndex = commentsPart.firstIndex(of: "]") { commentsPart.removeSubrange(startIndex ... endIndex) } } else { commentsPart = commentsPart.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789-,.")) } let rawTextAndRanges: PresentationStrings.FormattedString switch type { case .comments: rawTextAndRanges = self.strings.Conversation_TitleCommentsFormat("\(count)", commentsPart) case .replies: rawTextAndRanges = self.strings.Conversation_TitleRepliesFormat("\(count)", commentsPart) } let rawText = rawTextAndRanges.string var textIndex = 0 var latestIndex = 0 for indexAndRange in rawTextAndRanges.ranges { let index = indexAndRange.index let range = indexAndRange.range var lowerSegmentIndex = range.lowerBound if index != 0 { lowerSegmentIndex = min(lowerSegmentIndex, latestIndex) } else { if latestIndex < range.lowerBound { let part = String(rawText[rawText.index(rawText.startIndex, offsetBy: latestIndex) ..< rawText.index(rawText.startIndex, offsetBy: range.lowerBound)]) segments.append(.text(textIndex, NSAttributedString(string: part, font: textFont, textColor: textColor))) textIndex += 1 } } latestIndex = range.upperBound let part = String(rawText[rawText.index(rawText.startIndex, offsetBy: lowerSegmentIndex) ..< rawText.index(rawText.startIndex, offsetBy: min(rawText.count, range.upperBound))]) if index == 0 { segments.append(.number(count, NSAttributedString(string: part, font: textFont, textColor: textColor))) } else { segments.append(.text(textIndex, NSAttributedString(string: part, font: textFont, textColor: textColor))) textIndex += 1 } } if latestIndex < rawText.count { let part = String(rawText[rawText.index(rawText.startIndex, offsetBy: latestIndex)...]) segments.append(.text(textIndex, NSAttributedString(string: part, font: textFont, textColor: textColor))) textIndex += 1 } } else { switch type { case .comments: segments = [.text(0, NSAttributedString(string: strings.Conversation_TitleCommentsEmpty, font: textFont, textColor: textColor))] case .replies: segments = [.text(0, NSAttributedString(string: strings.Conversation_TitleRepliesEmpty, font: textFont, textColor: textColor))] } } isEnabled = false case let .custom(textItems, _, enabled): var nextId = -1 segments = textItems.map { item -> AnimatedCountLabelNode.Segment in nextId += 1 switch item.content { case let .number(value, _): return .number(nextId, NSAttributedString(string: "\(value)", font: titleFont, textColor: titleTheme.rootController.navigationBar.primaryTextColor)) case let .text(text): return .text(nextId, NSAttributedString(string: text, font: titleFont, textColor: titleTheme.rootController.navigationBar.primaryTextColor)) } } isEnabled = enabled } var updated = false if self.titleTextNode.segments != segments { self.titleTextNode.segments = segments updated = true } if titleLeftIcon != self.titleLeftIcon { self.titleLeftIcon = titleLeftIcon switch titleLeftIcon { case .lock: self.titleLeftIconNode.image = PresentationResourcesChat.chatTitleLockIcon(titleTheme) default: self.titleLeftIconNode.image = nil } updated = true } if titleCredibilityIcon != self.titleCredibilityIcon { self.titleCredibilityIcon = titleCredibilityIcon updated = true } if titleVerifiedIcon != self.titleVerifiedIcon { self.titleVerifiedIcon = titleVerifiedIcon updated = true } if titleStatusIcon != self.titleStatusIcon { self.titleStatusIcon = titleStatusIcon updated = true } if titleRightIcon != self.titleRightIcon { self.titleRightIcon = titleRightIcon switch titleRightIcon { case .mute: self.titleRightIconNode.image = PresentationResourcesChat.chatTitleMuteIcon(titleTheme) default: self.titleRightIconNode.image = nil } updated = true } self.isUserInteractionEnabled = isEnabled self.button.isUserInteractionEnabled = isEnabled var enableAnimation = false switch titleContent { case let .peer(_, customTitle, _, _, _, _, _, _): if case let .peer(_, previousCustomTitle, _, _, _, _, _, _) = oldValue { if customTitle != previousCustomTitle { enableAnimation = false } } else { enableAnimation = false } default: break } if !self.updateStatus(enableAnimation: enableAnimation) { if updated { if !self.manualLayout, let size = self.validLayout { let _ = self.updateLayout(availableSize: size, transition: (self.disableAnimations || !enableAnimation) ? .immediate : .animated(duration: 0.2, curve: .easeInOut)) } } } } } } private func updateStatus(enableAnimation: Bool = true) -> Bool { var inputActivitiesAllowed = true if let titleContent = self.titleContent { switch titleContent { case let .peer(peerView, _, _, _, isScheduledMessages, _, _, _): if let peer = peerView.peer { if peer.id == self.context.account.peerId || isScheduledMessages || peer.id.isRepliesOrVerificationCodes { inputActivitiesAllowed = false } } case .replyThread: inputActivitiesAllowed = true default: inputActivitiesAllowed = false } } let titleTheme = self.theme var state = ChatTitleActivityNodeState.none switch self.networkState { case .waitingForNetwork, .connecting, .updating: var infoText: String switch self.networkState { case .waitingForNetwork: infoText = self.strings.ChatState_WaitingForNetwork case .connecting: infoText = self.strings.ChatState_Connecting case .updating: infoText = self.strings.ChatState_Updating case .online: infoText = "" } state = .info(NSAttributedString(string: infoText, font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor), .generic) case .online: if let inputActivities = self.inputActivities, !inputActivities.items.isEmpty, inputActivitiesAllowed { var stringValue = "" var mergedActivity = inputActivities.items[0].activity for item in inputActivities.items { if item.activity != mergedActivity { mergedActivity = .typingText break } } if inputActivities.peerId.namespace == Namespaces.Peer.CloudUser || inputActivities.peerId.namespace == Namespaces.Peer.SecretChat { switch mergedActivity { case .typingText: stringValue = strings.Conversation_typing case .uploadingFile: stringValue = strings.Activity_UploadingDocument case .recordingVoice: stringValue = strings.Activity_RecordingAudio case .uploadingPhoto: stringValue = strings.Activity_UploadingPhoto case .uploadingVideo: stringValue = strings.Activity_UploadingVideo case .playingGame: stringValue = strings.Activity_PlayingGame case .recordingInstantVideo: stringValue = strings.Activity_RecordingVideoMessage case .uploadingInstantVideo: stringValue = strings.Activity_UploadingVideoMessage case .choosingSticker: stringValue = strings.Activity_ChoosingSticker case let .seeingEmojiInteraction(emoticon): stringValue = strings.Activity_EnjoyingAnimations(emoticon).string case .speakingInGroupCall, .interactingWithEmoji: stringValue = "" } } else { if inputActivities.items.count > 1 { let peerTitle = inputActivities.items[0].peer.compactDisplayTitle if inputActivities.items.count == 2 { let secondPeerTitle = inputActivities.items[1].peer.compactDisplayTitle stringValue = self.strings.Chat_MultipleTypingPair(peerTitle, secondPeerTitle).string } else { stringValue = self.strings.Chat_MultipleTypingMore(peerTitle, String(inputActivities.items.count - 1)).string } } else if let item = inputActivities.items.first { stringValue = item.peer.compactDisplayTitle } } let color = titleTheme.rootController.navigationBar.accentTextColor let string = NSAttributedString(string: stringValue, font: subtitleFont, textColor: color) switch mergedActivity { case .typingText: state = .typingText(string, color) case .recordingVoice: state = .recordingVoice(string, color) case .recordingInstantVideo: state = .recordingVideo(string, color) case .uploadingFile, .uploadingInstantVideo, .uploadingPhoto, .uploadingVideo: state = .uploading(string, color) case .playingGame: state = .playingGame(string, color) case .speakingInGroupCall, .interactingWithEmoji: state = .typingText(string, color) case .choosingSticker: state = .choosingSticker(string, color) case .seeingEmojiInteraction: state = .choosingSticker(string, color) } } else { if let titleContent = self.titleContent { switch titleContent { case let .peer(peerView, customTitle, customSubtitle, onlineMemberCount, isScheduledMessages, _, customMessageCount, _): if let customSubtitle { let string = NSAttributedString(string: customSubtitle, font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } else if let customMessageCount = customMessageCount, customMessageCount != 0 { let string = NSAttributedString(string: self.strings.Conversation_Messages(Int32(customMessageCount)), font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } else if let peer = peerView.peer { let servicePeer = isServicePeer(peer) if peer.id == self.context.account.peerId || isScheduledMessages || peer.id.isRepliesOrVerificationCodes { let string = NSAttributedString(string: "", font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } else if let user = peer as? TelegramUser { if user.isDeleted { state = .none } else if servicePeer { let string = NSAttributedString(string: "", font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } else if user.flags.contains(.isSupport) { let statusText = self.strings.Bot_GenericSupportStatus let string = NSAttributedString(string: statusText, font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } else if let _ = user.botInfo { let statusText: String if let subscriberCount = user.subscriberCount { statusText = self.strings.Conversation_StatusBotSubscribers(subscriberCount) } else { statusText = self.strings.Bot_GenericBotStatus } let string = NSAttributedString(string: statusText, font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } else if let peer = peerView.peer { let timestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 let userPresence: TelegramUserPresence if let presence = peerView.peerPresences[peer.id] as? TelegramUserPresence { userPresence = presence self.presenceManager?.reset(presence: EnginePeer.Presence(presence)) } else { userPresence = TelegramUserPresence(status: .none, lastActivity: 0) } let (string, activity) = stringAndActivityForUserPresence(strings: self.strings, dateTimeFormat: self.dateTimeFormat, presence: EnginePeer.Presence(userPresence), relativeTo: Int32(timestamp)) let attributedString = NSAttributedString(string: string, font: subtitleFont, textColor: activity ? titleTheme.rootController.navigationBar.accentTextColor : titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(attributedString, activity ? .online : .lastSeenTime) } else { let string = NSAttributedString(string: "", font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } } else if let group = peer as? TelegramGroup { var onlineCount = 0 if let cachedGroupData = peerView.cachedData as? CachedGroupData, let participants = cachedGroupData.participants { let timestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 for participant in participants.participants { if let presence = peerView.peerPresences[participant.peerId] as? TelegramUserPresence { let relativeStatus = relativeUserPresenceStatus(EnginePeer.Presence(presence), relativeTo: Int32(timestamp)) switch relativeStatus { case .online: onlineCount += 1 default: break } } } } if onlineCount > 1 { let string = NSMutableAttributedString() string.append(NSAttributedString(string: "\(strings.Conversation_StatusMembers(Int32(group.participantCount))), ", font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor)) string.append(NSAttributedString(string: strings.Conversation_StatusOnline(Int32(onlineCount)), font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor)) state = .info(string, .generic) } else { let string = NSAttributedString(string: strings.Conversation_StatusMembers(Int32(group.participantCount)), font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } } else if let channel = peer as? TelegramChannel { if channel.isForumOrMonoForum, customTitle != nil { let string = NSAttributedString(string: EnginePeer(peer).displayTitle(strings: self.strings, displayOrder: self.nameDisplayOrder), font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } else if let cachedChannelData = peerView.cachedData as? CachedChannelData, let memberCount = onlineMemberCount.total ?? cachedChannelData.participantsSummary.memberCount { if memberCount == 0 { let string: NSAttributedString if case .group = channel.info { string = NSAttributedString(string: strings.Group_Status, font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) } else { string = NSAttributedString(string: strings.Channel_Status, font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) } state = .info(string, .generic) } else { if case .group = channel.info, let onlineMemberCount = onlineMemberCount.recent, onlineMemberCount > 1 { let string = NSMutableAttributedString() string.append(NSAttributedString(string: "\(strings.Conversation_StatusMembers(Int32(memberCount))), ", font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor)) string.append(NSAttributedString(string: strings.Conversation_StatusOnline(Int32(onlineMemberCount)), font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor)) state = .info(string, .generic) } else { let membersString: String if case .group = channel.info { membersString = strings.Conversation_StatusMembers(memberCount) } else { membersString = strings.Conversation_StatusSubscribers(memberCount) } let string = NSAttributedString(string: membersString, font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } } } else { switch channel.info { case .group: let string = NSAttributedString(string: strings.Group_Status, font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) case .broadcast: let string = NSAttributedString(string: strings.Channel_Status, font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) } } } } case let .custom(_, subtitle?, _): let string = NSAttributedString(string: subtitle, font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(string, .generic) default: break } var accessibilityText = "" for segment in self.titleTextNode.segments { switch segment { case let .number(_, string): accessibilityText.append(string.string) case let .text(_, string): accessibilityText.append(string.string) } } self.accessibilityLabel = accessibilityText self.accessibilityValue = state.string } else { self.accessibilityLabel = nil } } } if self.activityNode.transitionToState(state, animation: enableAnimation ? .slide : .none) { if !self.manualLayout, let size = self.validLayout { let _ = self.updateLayout(availableSize: size, transition: enableAnimation ? .animated(duration: 0.3, curve: .spring) : .immediate) } return true } else { return false } } public init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer) { self.context = context self.theme = theme self.strings = strings self.dateTimeFormat = dateTimeFormat self.nameDisplayOrder = nameDisplayOrder self.animationCache = animationCache self.animationRenderer = animationRenderer self.contentContainer = ASDisplayNode() self.backgroundView = GlassBackgroundView() self.titleContainerView = PortalSourceView() self.titleTextNode = ImmediateAnimatedCountLabelNode() self.titleLeftIconNode = ASImageNode() self.titleLeftIconNode.isLayerBacked = true self.titleLeftIconNode.displayWithoutProcessing = true self.titleLeftIconNode.displaysAsynchronously = false self.titleRightIconNode = ASImageNode() self.titleRightIconNode.isLayerBacked = true self.titleRightIconNode.displayWithoutProcessing = true self.titleRightIconNode.displaysAsynchronously = false self.titleCredibilityIconView = ComponentHostView() self.titleCredibilityIconView.isUserInteractionEnabled = false self.titleVerifiedIconView = ComponentHostView() self.titleVerifiedIconView.isUserInteractionEnabled = false self.titleStatusIconView = ComponentHostView() self.titleStatusIconView.isUserInteractionEnabled = false self.activityNode = ChatTitleActivityNode() self.button = HighlightTrackingButtonNode() super.init(frame: CGRect()) self.isAccessibilityElement = true self.accessibilityTraits = .header self.addSubnode(self.contentContainer) self.contentContainer.view.addSubview(self.backgroundView) self.titleContainerView.addSubnode(self.titleTextNode) self.contentContainer.view.addSubview(self.titleContainerView) self.contentContainer.addSubnode(self.activityNode) self.addSubnode(self.button) self.presenceManager = PeerPresenceStatusManager(update: { [weak self] in let _ = self?.updateStatus() }) self.button.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: [.touchUpInside]) self.button.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { strongSelf.titleTextNode.layer.removeAnimation(forKey: "opacity") strongSelf.activityNode.layer.removeAnimation(forKey: "opacity") strongSelf.titleCredibilityIconView.layer.removeAnimation(forKey: "opacity") strongSelf.titleVerifiedIconView.layer.removeAnimation(forKey: "opacity") strongSelf.titleStatusIconView.layer.removeAnimation(forKey: "opacity") strongSelf.titleTextNode.alpha = 0.4 strongSelf.activityNode.alpha = 0.4 strongSelf.titleCredibilityIconView.alpha = 0.4 strongSelf.titleVerifiedIconView.alpha = 0.4 } else { strongSelf.titleTextNode.alpha = 1.0 strongSelf.activityNode.alpha = 1.0 strongSelf.titleCredibilityIconView.alpha = 1.0 strongSelf.titleVerifiedIconView.alpha = 1.0 strongSelf.titleStatusIconView.alpha = 1.0 strongSelf.titleTextNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) strongSelf.activityNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } } } self.button.view.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(self.longPressGesture(_:)))) // MARK: - GLEGram #if canImport(SGSimpleSettings) self.badgeImageObserver = NotificationCenter.default.addObserver(forName: .sgBadgeImageDidCache, object: nil, queue: .main) { [weak self] _ in guard let self, let content = self.titleContent else { return } self.titleContent = content } #endif // MARK: - End GLEGram } // MARK: - GLEGram deinit { if let observer = self.badgeImageObserver { NotificationCenter.default.removeObserver(observer) } } // MARK: - End GLEGram required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override public func layoutSubviews() { super.layoutSubviews() if !self.manualLayout, let size = self.validLayout { let _ = self.updateLayout(availableSize: size, transition: .immediate) } } public func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { if self.theme !== theme || self.strings !== strings { self.theme = theme self.strings = strings let titleContent = self.titleContent self.titleCredibilityIcon = .none self.titleVerifiedIcon = .none self.titleContent = titleContent let _ = self.updateStatus() if !self.manualLayout, let size = self.validLayout { let _ = self.updateLayout(availableSize: size, transition: .immediate) } } } public func updateLayout(availableSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { let size = availableSize self.validLayout = size self.button.frame = CGRect(origin: CGPoint(), size: size) self.contentContainer.frame = CGRect(origin: CGPoint(), size: size) var leftIconWidth: CGFloat = 0.0 var rightIconWidth: CGFloat = 0.0 var credibilityIconWidth: CGFloat = 0.0 var verifiedIconWidth: CGFloat = 0.0 var statusIconWidth: CGFloat = 0.0 if let image = self.titleLeftIconNode.image { if self.titleLeftIconNode.supernode == nil { self.titleTextNode.addSubnode(self.titleLeftIconNode) } leftIconWidth = image.size.width + 6.0 } else if self.titleLeftIconNode.supernode != nil { self.titleLeftIconNode.removeFromSupernode() } let titleCredibilityContent: EmojiStatusComponent.Content switch self.titleCredibilityIcon { case .none: titleCredibilityContent = .none case .premium: titleCredibilityContent = .premium(color: self.theme.list.itemAccentColor) case .verified: titleCredibilityContent = .verified(fillColor: self.theme.list.itemCheckColors.fillColor, foregroundColor: self.theme.list.itemCheckColors.foregroundColor, sizeType: .large) case .fake: titleCredibilityContent = .text(color: self.theme.chat.message.incoming.scamColor, string: self.strings.Message_FakeAccount.uppercased()) case .scam: titleCredibilityContent = .text(color: self.theme.chat.message.incoming.scamColor, string: self.strings.Message_ScamAccount.uppercased()) case let .emojiStatus(emojiStatus): titleCredibilityContent = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 32.0, height: 32.0), placeholderColor: self.theme.list.mediaPlaceholderColor, themeColor: self.theme.list.itemAccentColor, loopMode: .count(2)) // MARK: - GLEGram case .verifiedGLEGram: titleCredibilityContent = .image(image: UIImage(bundleImageName: "GLEGramVerifiedBadge"), tintColor: nil) case .developer: let developerColor = UIColor(rgb: 0x00A8FF) titleCredibilityContent = .text(color: developerColor, string: "DEV") case let .serverBadge(name, colorRgb): titleCredibilityContent = .text(color: UIColor(rgb: colorRgb), string: name) case let .serverImageBadge(image): titleCredibilityContent = .image(image: image, tintColor: nil) // MARK: - End GLEGram } let titleVerifiedContent: EmojiStatusComponent.Content switch self.titleVerifiedIcon { case .none: titleVerifiedContent = .none case .premium: titleVerifiedContent = .premium(color: self.theme.list.itemAccentColor) case .verified: titleVerifiedContent = .verified(fillColor: self.theme.list.itemCheckColors.fillColor, foregroundColor: self.theme.list.itemCheckColors.foregroundColor, sizeType: .large) case .fake: titleVerifiedContent = .text(color: self.theme.chat.message.incoming.scamColor, string: self.strings.Message_FakeAccount.uppercased()) case .scam: titleVerifiedContent = .text(color: self.theme.chat.message.incoming.scamColor, string: self.strings.Message_ScamAccount.uppercased()) case let .emojiStatus(emojiStatus): titleVerifiedContent = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 32.0, height: 32.0), placeholderColor: self.theme.list.mediaPlaceholderColor, themeColor: self.theme.list.itemAccentColor, loopMode: .count(2)) // MARK: - GLEGram case .verifiedGLEGram: titleVerifiedContent = .image(image: UIImage(bundleImageName: "GLEGramVerifiedBadge"), tintColor: nil) case .developer: let developerColor = UIColor(rgb: 0x00A8FF) titleVerifiedContent = .text(color: developerColor, string: "DEV") case let .serverBadge(name, colorRgb): titleVerifiedContent = .text(color: UIColor(rgb: colorRgb), string: name) case let .serverImageBadge(image): titleVerifiedContent = .image(image: image, tintColor: nil) // MARK: - End GLEGram } let titleStatusContent: EmojiStatusComponent.Content var titleStatusParticleColor: UIColor? switch self.titleStatusIcon { case let .emojiStatus(emojiStatus): titleStatusContent = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 32.0, height: 32.0), placeholderColor: self.theme.list.mediaPlaceholderColor, themeColor: self.theme.list.itemAccentColor, loopMode: .count(2)) if let color = emojiStatus.color { titleStatusParticleColor = UIColor(rgb: UInt32(bitPattern: color)) } // MARK: - GLEGram case .developer: let developerColor = UIColor(rgb: 0x00A8FF) titleStatusContent = .text(color: developerColor, string: "DEV") case let .serverBadge(name, colorRgb): titleStatusContent = .text(color: UIColor(rgb: colorRgb), string: name) case let .serverImageBadge(image): titleStatusContent = .image(image: image, tintColor: nil) // MARK: - End GLEGram default: titleStatusContent = .none } let titleCredibilitySize = self.titleCredibilityIconView.update( transition: .immediate, component: AnyComponent(EmojiStatusComponent( context: self.context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, content: titleCredibilityContent, isVisibleForAnimations: true, action: nil )), environment: {}, containerSize: CGSize(width: self.titleCredibilityIcon == .verifiedGLEGram ? 26.0 : 20.0, height: self.titleCredibilityIcon == .verifiedGLEGram ? 26.0 : 20.0) // MARK: - GLEGram ) let titleVerifiedSize = self.titleVerifiedIconView.update( transition: .immediate, component: AnyComponent(EmojiStatusComponent( context: self.context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, content: titleVerifiedContent, isVisibleForAnimations: true, action: nil )), environment: {}, containerSize: CGSize(width: self.titleVerifiedIcon == .verifiedGLEGram ? 26.0 : 20.0, height: self.titleVerifiedIcon == .verifiedGLEGram ? 26.0 : 20.0) // MARK: - GLEGram ) let titleStatusSize = self.titleStatusIconView.update( transition: .immediate, component: AnyComponent(EmojiStatusComponent( context: self.context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, content: titleStatusContent, particleColor: titleStatusParticleColor, isVisibleForAnimations: true, action: nil )), environment: {}, containerSize: CGSize(width: 20.0, height: 20.0) ) if self.titleCredibilityIcon != .none { self.titleTextNode.view.addSubview(self.titleCredibilityIconView) credibilityIconWidth = titleCredibilitySize.width + 3.0 } else { if self.titleCredibilityIconView.superview != nil { self.titleCredibilityIconView.removeFromSuperview() } } if self.titleVerifiedIcon != .none { self.titleTextNode.view.addSubview(self.titleVerifiedIconView) verifiedIconWidth = titleVerifiedSize.width + 3.0 } else { if self.titleVerifiedIconView.superview != nil { self.titleVerifiedIconView.removeFromSuperview() } } if self.titleStatusIcon != .none { self.titleTextNode.view.addSubview(self.titleStatusIconView) statusIconWidth = titleStatusSize.width + 3.0 } else { if self.titleStatusIconView.superview != nil { self.titleStatusIconView.removeFromSuperview() } } if let image = self.titleRightIconNode.image { if self.titleRightIconNode.supernode == nil { self.titleTextNode.addSubnode(self.titleRightIconNode) } rightIconWidth = max(24.0, image.size.width) + 3.0 } else if self.titleRightIconNode.supernode != nil { self.titleRightIconNode.removeFromSupernode() } var titleTransition = transition if self.titleContainerView.bounds.width.isZero { titleTransition = .immediate } let statusSpacing: CGFloat = 3.0 let titleSideInset: CGFloat = 12.0 + 8.0 var titleFrame: CGRect var titleInsets: UIEdgeInsets = .zero if case .emojiStatus = self.titleVerifiedIcon, verifiedIconWidth > 0.0 { titleInsets.left = verifiedIconWidth } var titleSize = self.titleTextNode.updateLayout(size: CGSize(width: size.width - leftIconWidth - credibilityIconWidth - verifiedIconWidth - statusIconWidth - rightIconWidth - titleSideInset * 2.0, height: size.height), insets: titleInsets, animated: titleTransition.isAnimated) titleSize.width += credibilityIconWidth titleSize.width += verifiedIconWidth if statusIconWidth > 0.0 { titleSize.width += statusIconWidth if credibilityIconWidth > 0.0 { titleSize.width += statusSpacing } } let activitySize = self.activityNode.updateLayout(CGSize(width: size.width - titleSideInset * 2.0, height: size.height), alignment: .center) let titleInfoSpacing: CGFloat = 0.0 var activityFrame = CGRect() if activitySize.height.isZero { titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor((size.height - titleSize.height) / 2.0)), size: titleSize) if titleFrame.size.width < size.width { titleFrame.origin.x = floor((size.width - titleFrame.width) / 2.0) } titleTransition.updateFrameAdditive(view: self.titleContainerView, frame: titleFrame) titleTransition.updateFrameAdditive(node: self.titleTextNode, frame: CGRect(origin: CGPoint(), size: titleFrame.size)) } else { let combinedHeight = titleSize.height + activitySize.height + titleInfoSpacing let contentWidth = max(titleSize.width + rightIconWidth, activitySize.width) var contentX = floor((size.width - contentWidth) / 2.0) contentX = max(contentX, 20.0) titleFrame = CGRect(origin: CGPoint(x: contentX + floor((contentWidth - titleSize.width) / 2.0), y: floor((size.height - combinedHeight) / 2.0)), size: titleSize) titleFrame.origin.x = max(titleFrame.origin.x, leftIconWidth) titleTransition.updateFrameAdditive(view: self.titleContainerView, frame: titleFrame) titleTransition.updateFrameAdditive(node: self.titleTextNode, frame: CGRect(origin: CGPoint(), size: titleFrame.size)) activityFrame = CGRect(origin: CGPoint(x: titleFrame.minX + floor((titleFrame.width - activitySize.width) / 2.0), y: floor((size.height - combinedHeight) / 2.0) + titleSize.height + titleInfoSpacing), size: activitySize) titleTransition.updateFrameAdditiveToCenter(node: self.activityNode, frame: activityFrame.offsetBy(dx: activitySize.width * 0.5, dy: 0.0)) } if let image = self.titleLeftIconNode.image { titleTransition.updateFrame(node: self.titleLeftIconNode, frame: CGRect(origin: CGPoint(x: -image.size.width - 3.0 - UIScreenPixel, y: 4.0), size: image.size)) } var nextIconX: CGFloat = titleFrame.width titleTransition.updateFrame(view: self.titleVerifiedIconView, frame: CGRect(origin: CGPoint(x: 0.0, y: floor((titleFrame.height - titleVerifiedSize.height) / 2.0)), size: titleVerifiedSize)) self.titleCredibilityIconView.frame = CGRect(origin: CGPoint(x: nextIconX - titleCredibilitySize.width, y: floor((titleFrame.height - titleCredibilitySize.height) / 2.0)), size: titleCredibilitySize) nextIconX -= titleCredibilitySize.width if credibilityIconWidth > 0.0 { nextIconX -= statusSpacing } self.titleStatusIconView.frame = CGRect(origin: CGPoint(x: nextIconX - titleStatusSize.width, y: floor((titleFrame.height - titleStatusSize.height) / 2.0)), size: titleStatusSize) nextIconX -= titleStatusSize.width if let image = self.titleRightIconNode.image { self.titleRightIconNode.frame = CGRect(origin: CGPoint(x: titleFrame.width + 3.0 + UIScreenPixel, y: 6.0), size: image.size) } self.pointerInteraction = PointerInteraction(view: self, style: .rectangle(CGSize(width: titleFrame.width + 16.0, height: 40.0))) var backgroundFrame = CGRect(origin: CGPoint(x: titleFrame.minX, y: 6.0), size: CGSize(width: titleFrame.width, height: 44.0)) if !activityFrame.isEmpty { backgroundFrame.origin.x = min(backgroundFrame.minX, activityFrame.minX) backgroundFrame.size.width = max(backgroundFrame.maxX, activityFrame.maxX) - backgroundFrame.minX } backgroundFrame = backgroundFrame.insetBy(dx: -12.0, dy: 0.0) let componentTransition = ComponentTransition(transition) componentTransition.setFrame(view: self.backgroundView, frame: backgroundFrame) self.backgroundView.update(size: backgroundFrame.size, cornerRadius: backgroundFrame.height * 0.5, isDark: self.theme.overallDarkAppearance, tintColor: .init(kind: .panel), isInteractive: false, transition: componentTransition) return availableSize } @objc private func buttonPressed() { self.pressed?() } @objc private func longPressGesture(_ gesture: UILongPressGestureRecognizer) { switch gesture.state { case .began: self.longPressed?() default: break } } public func animateLayoutTransition() { UIView.transition(with: self, duration: 0.25, options: [.transitionCrossDissolve], animations: { }, completion: nil) } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.isUserInteractionEnabled { return nil } if self.button.frame.contains(point) { return self.button.view } return super.hitTest(point, with: event) } public final class SnapshotState { fileprivate let snapshotView: UIView fileprivate init(snapshotView: UIView) { self.snapshotView = snapshotView } } public func prepareSnapshotState() -> SnapshotState? { guard let snapshotView = self.snapshotView(afterScreenUpdates: false) else { return nil } return SnapshotState( snapshotView: snapshotView ) } public func animateFromSnapshot(_ snapshotState: SnapshotState, direction: AnimateFromSnapshotDirection = .up) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) var offset = CGPoint() switch direction { case .up: offset.y = -20.0 case .down: offset.y = 20.0 case .left: offset.x = -20.0 case .right: offset.x = 20.0 } self.layer.animatePosition(from: offset, to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: true, additive: true) snapshotState.snapshotView.frame = self.frame self.superview?.insertSubview(snapshotState.snapshotView, belowSubview: self) let snapshotView = snapshotState.snapshotView snapshotState.snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.14, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) snapshotView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: -offset.x, y: -offset.y), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) } }