Files
Swiftgram/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift
2025-12-26 20:29:30 +08:00

1177 lines
62 KiB
Swift

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
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)
}
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<Empty>
public let titleVerifiedIconView: ComponentHostView<Empty>
public let titleStatusIconView: ComponentHostView<Empty>
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
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 })
if peer.isFake {
titleCredibilityIcon = .fake
} else if peer.isScam {
titleCredibilityIcon = .scam
} else if let emojiStatus = peer.emojiStatus {
titleStatusIcon = .emojiStatus(emojiStatus)
} else if peer.isPremium && !premiumConfiguration.isPremiumDisabled {
titleCredibilityIcon = .premium
}
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 {
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(_:))))
}
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))
}
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))
}
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))
}
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: 20.0, height: 20.0)
)
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: 20.0, height: 20.0)
)
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, color: UIColor(white: self.theme.overallDarkAppearance ? 0.0 : 1.0, alpha: 0.6)), 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)
}
}