Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
@@ -0,0 +1,989 @@
import Foundation
import SwiftSignalKit
import UIKit
import Postbox
import TelegramCore
import Display
import DeviceAccess
import TelegramPresentationData
import AccountContext
import LiveLocationManager
import TemporaryCachedPeerDataManager
import PhoneNumberFormat
import TelegramUIPreferences
import TelegramVoip
import TelegramCallsUI
import TelegramBaseController
import AsyncDisplayKit
import PresentationDataUtils
import FetchManagerImpl
import InAppPurchaseManager
import AnimationCache
import MultiAnimationRenderer
import AppBundle
import DirectMediaImageCache
private final class DeviceSpecificContactImportContext {
let disposable = MetaDisposable()
var reference: DeviceContactBasicDataWithReference?
init() {
}
deinit {
self.disposable.dispose()
}
}
private final class DeviceSpecificContactImportContexts {
private let queue: Queue
private var contexts: [PeerId: DeviceSpecificContactImportContext] = [:]
init(queue: Queue) {
self.queue = queue
}
deinit {
assert(self.queue.isCurrent())
}
func update(account: Account, deviceContactDataManager: DeviceContactDataManager, references: [PeerId: DeviceContactBasicDataWithReference]) {
var validIds = Set<PeerId>()
for (peerId, reference) in references {
validIds.insert(peerId)
let context: DeviceSpecificContactImportContext
if let current = self.contexts[peerId] {
context = current
} else {
context = DeviceSpecificContactImportContext()
self.contexts[peerId] = context
}
if context.reference != reference {
context.reference = reference
let signal = TelegramEngine(account: account).data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|> map { peer -> String? in
if case let .user(user) = peer {
return user.phone
} else {
return nil
}
}
|> distinctUntilChanged
|> mapToSignal { phone -> Signal<Never, NoError> in
guard let phone = phone else {
return .complete()
}
var found = false
let formattedPhone = formatPhoneNumber(phone)
for number in reference.basicData.phoneNumbers {
if formatPhoneNumber(number.value) == formattedPhone {
found = true
break
}
}
if !found {
return deviceContactDataManager.appendPhoneNumber(DeviceContactPhoneNumberData(label: "_$!<Mobile>!$_", value: formattedPhone), to: reference.stableId)
|> ignoreValues
} else {
return .complete()
}
}
context.disposable.set(signal.start())
}
}
var removeIds: [PeerId] = []
for peerId in self.contexts.keys {
if !validIds.contains(peerId) {
removeIds.append(peerId)
}
}
for peerId in removeIds {
self.contexts.removeValue(forKey: peerId)
}
}
}
public final class AccountContextImpl: AccountContext {
public let sharedContextImpl: SharedAccountContextImpl
public var sharedContext: SharedAccountContext {
return self.sharedContextImpl
}
public let account: Account
public let engine: TelegramEngine
public let fetchManager: FetchManager
public let prefetchManager: PrefetchManager?
public var keyShortcutsController: KeyShortcutsController?
public let downloadedMediaStoreManager: DownloadedMediaStoreManager
public let liveLocationManager: LiveLocationManager?
public let wallpaperUploadManager: WallpaperUploadManager?
private let themeUpdateManager: ThemeUpdateManager?
public let inAppPurchaseManager: InAppPurchaseManager?
public let starsContext: StarsContext?
public let tonContext: StarsContext?
public let giftAuctionsManager: GiftAuctionsManager?
public let peerChannelMemberCategoriesContextsManager = PeerChannelMemberCategoriesContextsManager()
public let currentLimitsConfiguration: Atomic<LimitsConfiguration>
private let _limitsConfiguration = Promise<LimitsConfiguration>()
public var limitsConfiguration: Signal<LimitsConfiguration, NoError> {
return self._limitsConfiguration.get()
}
public var currentContentSettings: Atomic<ContentSettings>
private let _contentSettings = Promise<ContentSettings>()
public var contentSettings: Signal<ContentSettings, NoError> {
return self._contentSettings.get()
}
public var currentAppConfiguration: Atomic<AppConfiguration>
private let _appConfiguration = Promise<AppConfiguration>()
public var appConfiguration: Signal<AppConfiguration, NoError> {
return self._appConfiguration.get()
}
public var currentCountriesConfiguration: Atomic<CountriesConfiguration>
private let _countriesConfiguration = Promise<CountriesConfiguration>()
public var countriesConfiguration: Signal<CountriesConfiguration, NoError> {
return self._countriesConfiguration.get()
}
private var storedPassword: (String, CFAbsoluteTime, SwiftSignalKit.Timer)?
private var limitsConfigurationDisposable: Disposable?
private var contentSettingsDisposable: Disposable?
private var appConfigurationDisposable: Disposable?
private var countriesConfigurationDisposable: Disposable?
private let deviceSpecificContactImportContexts: QueueLocalObject<DeviceSpecificContactImportContexts>
private var managedAppSpecificContactsDisposable: Disposable?
private var experimentalUISettingsDisposable: Disposable?
public let cachedGroupCallContexts: AccountGroupCallContextCache
public let animationCache: AnimationCache
public let animationRenderer: MultiAnimationRenderer
private var animatedEmojiStickersDisposable: Disposable?
public private(set) var animatedEmojiStickersValue: [String: [StickerPackItem]] = [:]
private let animatedEmojiStickersPromise = Promise<[String: [StickerPackItem]]>()
public var animatedEmojiStickers: Signal<[String: [StickerPackItem]], NoError> {
return self.animatedEmojiStickersPromise.get()
}
private var additionalAnimatedEmojiStickersPromise: Promise<[String: [Int: StickerPackItem]]>?
public var additionalAnimatedEmojiStickers: Signal<[String: [Int: StickerPackItem]], NoError> {
let additionalAnimatedEmojiStickersPromise: Promise<[String: [Int: StickerPackItem]]>
if let current = self.additionalAnimatedEmojiStickersPromise {
additionalAnimatedEmojiStickersPromise = current
} else {
additionalAnimatedEmojiStickersPromise = Promise<[String: [Int: StickerPackItem]]>()
self.additionalAnimatedEmojiStickersPromise = additionalAnimatedEmojiStickersPromise
additionalAnimatedEmojiStickersPromise.set(self.engine.stickers.loadedStickerPack(reference: .animatedEmojiAnimations, forceActualized: false)
|> map { animatedEmoji -> [String: [Int: StickerPackItem]] in
let sequence = "0️⃣1️⃣2️⃣3️⃣4️⃣5️⃣6️⃣7️⃣8️⃣9️⃣".strippedEmoji
var animatedEmojiStickers: [String: [Int: StickerPackItem]] = [:]
switch animatedEmoji {
case let .result(_, items, _):
for item in items {
let indexKeys = item.getStringRepresentationsOfIndexKeys()
if indexKeys.count > 1, let first = indexKeys.first, let last = indexKeys.last {
let emoji: String?
let indexEmoji: String?
if sequence.contains(first.strippedEmoji) {
emoji = last
indexEmoji = first
} else if sequence.contains(last.strippedEmoji) {
emoji = first
indexEmoji = last
} else {
emoji = nil
indexEmoji = nil
}
if let emoji = emoji?.strippedEmoji, let indexEmoji = indexEmoji?.strippedEmoji.first, let strIndex = sequence.firstIndex(of: indexEmoji) {
let index = sequence.distance(from: sequence.startIndex, to: strIndex)
if animatedEmojiStickers[emoji] != nil {
animatedEmojiStickers[emoji]![index] = item
} else {
animatedEmojiStickers[emoji] = [index: item]
}
}
}
}
default:
break
}
return animatedEmojiStickers
})
}
return additionalAnimatedEmojiStickersPromise.get()
}
private var availableReactionsValue: Promise<AvailableReactions?>?
public var availableReactions: Signal<AvailableReactions?, NoError> {
let availableReactionsValue: Promise<AvailableReactions?>
if let current = self.availableReactionsValue {
availableReactionsValue = current
} else {
availableReactionsValue = Promise<AvailableReactions?>()
self.availableReactionsValue = availableReactionsValue
availableReactionsValue.set(self.engine.stickers.availableReactions())
}
return availableReactionsValue.get()
}
private var availableMessageEffectsValue: Promise<AvailableMessageEffects?>?
public var availableMessageEffects: Signal<AvailableMessageEffects?, NoError> {
let availableMessageEffectsValue: Promise<AvailableMessageEffects?>
if let current = self.availableMessageEffectsValue {
availableMessageEffectsValue = current
} else {
availableMessageEffectsValue = Promise<AvailableMessageEffects?>()
self.availableMessageEffectsValue = availableMessageEffectsValue
availableMessageEffectsValue.set(self.engine.stickers.availableMessageEffects())
}
return availableMessageEffectsValue.get()
}
private var userLimitsConfigurationDisposable: Disposable?
public private(set) var userLimits: EngineConfiguration.UserLimits
private var peerNameColorsConfigurationDisposable: Disposable?
public private(set) var peerNameColors: PeerNameColors
private var audioTranscriptionTrialDisposable: Disposable?
public private(set) var audioTranscriptionTrial: AudioTranscription.TrialState
public private(set) var isPremium: Bool
private var isFrozenDisposable: Disposable?
public private(set) var isFrozen: Bool
public let imageCache: AnyObject?
public init(sharedContext: SharedAccountContextImpl, account: Account, limitsConfiguration: LimitsConfiguration, contentSettings: ContentSettings, appConfiguration: AppConfiguration, availableReplyColors: EngineAvailableColorOptions, availableProfileColors: EngineAvailableColorOptions, temp: Bool = false)
{
self.sharedContextImpl = sharedContext
self.account = account
self.engine = TelegramEngine(account: account)
self.imageCache = DirectMediaImageCache(account: account)
self.userLimits = EngineConfiguration.UserLimits(UserLimitsConfiguration.defaultValue)
self.peerNameColors = PeerNameColors.with(availableReplyColors: availableReplyColors, availableProfileColors: availableProfileColors)
self.audioTranscriptionTrial = AudioTranscription.TrialState.defaultValue
self.isPremium = false
self.isFrozen = false
self.downloadedMediaStoreManager = DownloadedMediaStoreManagerImpl(postbox: account.postbox, accountManager: sharedContext.accountManager)
if let locationManager = self.sharedContextImpl.locationManager {
self.liveLocationManager = LiveLocationManagerImpl(engine: self.engine, locationManager: locationManager, inForeground: sharedContext.applicationBindings.applicationInForeground)
} else {
self.liveLocationManager = nil
}
self.fetchManager = FetchManagerImpl(postbox: account.postbox, storeManager: self.downloadedMediaStoreManager)
if sharedContext.applicationBindings.isMainApp && !temp {
self.prefetchManager = PrefetchManagerImpl(sharedContext: sharedContext, account: account, engine: self.engine, fetchManager: self.fetchManager)
self.wallpaperUploadManager = WallpaperUploadManagerImpl(sharedContext: sharedContext, account: account, presentationData: sharedContext.presentationData)
self.themeUpdateManager = ThemeUpdateManagerImpl(sharedContext: sharedContext, account: account)
self.inAppPurchaseManager = InAppPurchaseManager(engine: .authorized(self.engine))
self.starsContext = self.engine.payments.peerStarsContext()
self.tonContext = self.engine.payments.peerTonContext()
self.giftAuctionsManager = GiftAuctionsManager(account: account)
} else {
self.prefetchManager = nil
self.wallpaperUploadManager = nil
self.themeUpdateManager = nil
self.inAppPurchaseManager = nil
self.starsContext = nil
self.tonContext = nil
self.giftAuctionsManager = nil
}
self.account.stateManager.starsContext = self.starsContext
self.account.stateManager.tonContext = self.starsContext
self.cachedGroupCallContexts = AccountGroupCallContextCacheImpl()
let cacheStorageBox = self.account.postbox.mediaBox.cacheStorageBox
self.animationCache = AnimationCacheImpl(basePath: self.account.postbox.mediaBox.basePath + "/animation-cache", allocateTempFile: {
return TempBox.shared.tempFile(fileName: "file").path
}, updateStorageStats: { path, size in
if let pathData = path.data(using: .utf8) {
cacheStorageBox.update(id: pathData, size: size)
}
})
self.animationRenderer = MultiAnimationRendererImpl()
(self.animationRenderer as? MultiAnimationRendererImpl)?.useYuvA = sharedContext.immediateExperimentalUISettings.compressedEmojiCache
let updatedLimitsConfiguration = account.postbox.preferencesView(keys: [PreferencesKeys.limitsConfiguration])
|> map { preferences -> LimitsConfiguration in
return preferences.values[PreferencesKeys.limitsConfiguration]?.get(LimitsConfiguration.self) ?? LimitsConfiguration.defaultValue
}
self.currentLimitsConfiguration = Atomic(value: limitsConfiguration)
self._limitsConfiguration.set(.single(limitsConfiguration) |> then(updatedLimitsConfiguration))
let currentLimitsConfiguration = self.currentLimitsConfiguration
self.limitsConfigurationDisposable = (self._limitsConfiguration.get()
|> deliverOnMainQueue).start(next: { value in
let _ = currentLimitsConfiguration.swap(value)
})
let updatedContentSettings = getContentSettings(postbox: account.postbox)
self.currentContentSettings = Atomic(value: contentSettings)
self._contentSettings.set(.single(contentSettings) |> then(updatedContentSettings))
let currentContentSettings = self.currentContentSettings
self.contentSettingsDisposable = (self._contentSettings.get()
|> deliverOnMainQueue).start(next: { value in
let _ = currentContentSettings.swap(value)
})
let updatedAppConfiguration = getAppConfiguration(postbox: account.postbox)
self.currentAppConfiguration = Atomic(value: appConfiguration)
self._appConfiguration.set(.single(appConfiguration) |> then(updatedAppConfiguration))
let currentAppConfiguration = self.currentAppConfiguration
self.appConfigurationDisposable = (self._appConfiguration.get()
|> deliverOnMainQueue).start(next: { value in
let _ = currentAppConfiguration.swap(value)
guard let data = appConfiguration.data else {
return
}
if data["ios_killswitch_contact_diffing"] != nil {
sharedDisableDeviceContactDataDiffing = true
}
if let url = data["ios_update_url"] as? String, !url.isEmpty {
let _ = (sharedContext.accountManager.transaction { transaction -> Void in
transaction.updateSharedData(ApplicationSpecificSharedDataKeys.updateSettings, { _ in
return PreferencesEntry(UpdateSettings(url: url))
})
}).start()
}
})
let langCode = sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
self.currentCountriesConfiguration = Atomic(value: CountriesConfiguration(countries: loadCountryCodes()))
if !temp {
let currentCountriesConfiguration = self.currentCountriesConfiguration
self.countriesConfigurationDisposable = (self.engine.localization.getCountriesList(accountManager: sharedContext.accountManager, langCode: langCode)
|> deliverOnMainQueue).start(next: { value in
let _ = currentCountriesConfiguration.swap(CountriesConfiguration(countries: value))
})
}
let queue = Queue()
self.deviceSpecificContactImportContexts = QueueLocalObject(queue: queue, generate: {
return DeviceSpecificContactImportContexts(queue: queue)
})
if let contactDataManager = sharedContext.contactDataManager {
let deviceSpecificContactImportContexts = self.deviceSpecificContactImportContexts
self.managedAppSpecificContactsDisposable = (contactDataManager.appSpecificReferences()
|> deliverOn(queue)).start(next: { appSpecificReferences in
deviceSpecificContactImportContexts.with { context in
context.update(account: account, deviceContactDataManager: contactDataManager, references: appSpecificReferences)
}
})
}
account.callSessionManager.updateVersions(versions: PresentationCallManagerImpl.voipVersions(includeExperimental: true, includeReference: true).map { version, supportsVideo -> CallSessionManagerImplementationVersion in
CallSessionManagerImplementationVersion(version: version, supportsVideo: supportsVideo)
})
self.animatedEmojiStickersDisposable = (self.engine.stickers.loadedStickerPack(reference: .animatedEmoji, forceActualized: false)
|> map { animatedEmoji -> [String: [StickerPackItem]] in
var animatedEmojiStickers: [String: [StickerPackItem]] = [:]
switch animatedEmoji {
case let .result(_, items, _):
for item in items {
if let emoji = item.getStringRepresentationsOfIndexKeys().first {
animatedEmojiStickers[emoji.basicEmoji.0] = [item]
let strippedEmoji = emoji.basicEmoji.0.strippedEmoji
if animatedEmojiStickers[strippedEmoji] == nil {
animatedEmojiStickers[strippedEmoji] = [item]
}
}
}
default:
break
}
return animatedEmojiStickers
}
|> deliverOnMainQueue).start(next: { [weak self] stickers in
guard let strongSelf = self else {
return
}
strongSelf.animatedEmojiStickersValue = stickers
strongSelf.animatedEmojiStickersPromise.set(.single(stickers))
})
self.userLimitsConfigurationDisposable = (self.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: account.peerId))
|> mapToSignal { peer -> Signal<(Bool, EngineConfiguration.UserLimits), NoError> in
let isPremium = peer?.isPremium ?? false
return self.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: isPremium))
|> map { userLimits in
return (isPremium, userLimits)
}
}
|> deliverOnMainQueue).startStrict(next: { [weak self] isPremium, userLimits in
guard let self = self else {
return
}
self.isPremium = isPremium
self.userLimits = userLimits
})
self.peerNameColorsConfigurationDisposable = (combineLatest(
self.engine.accountData.observeAvailableColorOptions(scope: .replies),
self.engine.accountData.observeAvailableColorOptions(scope: .profile)
)
|> deliverOnMainQueue).startStrict(next: { [weak self] availableReplyColors, availableProfileColors in
guard let self = self else {
return
}
self.peerNameColors = PeerNameColors.with(availableReplyColors: availableReplyColors, availableProfileColors: availableProfileColors)
})
self.audioTranscriptionTrialDisposable = (self.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: account.peerId))
|> mapToSignal { peer -> Signal<AudioTranscription.TrialState, NoError> in
let isPremium = peer?.isPremium ?? false
if isPremium {
return .single(AudioTranscription.TrialState(cooldownUntilTime: nil, remainingCount: 1))
} else {
return self.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.AudioTranscriptionTrial())
}
}
|> deliverOnMainQueue).startStrict(next: { [weak self] audioTranscriptionTrial in
guard let self = self else {
return
}
self.audioTranscriptionTrial = audioTranscriptionTrial
})
self.isFrozenDisposable = (self.appConfiguration
|> map { appConfiguration in
return AccountFreezeConfiguration.with(appConfiguration: appConfiguration).freezeUntilDate != nil
}
|> distinctUntilChanged
|> deliverOnMainQueue).startStrict(next: { [weak self] isFrozen in
guard let self = self else {
return
}
self.isFrozen = isFrozen
})
self.experimentalUISettingsDisposable = (sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.experimentalUISettings])
|> deliverOnMainQueue).start(next: { [weak self] sharedData in
guard let self else {
return
}
guard let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.experimentalUISettings]?.get(ExperimentalUISettings.self) else {
return
}
(self.animationRenderer as? MultiAnimationRendererImpl)?.useYuvA = settings.compressedEmojiCache
})
}
deinit {
self.limitsConfigurationDisposable?.dispose()
self.managedAppSpecificContactsDisposable?.dispose()
self.contentSettingsDisposable?.dispose()
self.appConfigurationDisposable?.dispose()
self.countriesConfigurationDisposable?.dispose()
self.experimentalUISettingsDisposable?.dispose()
self.animatedEmojiStickersDisposable?.dispose()
self.userLimitsConfigurationDisposable?.dispose()
self.peerNameColorsConfigurationDisposable?.dispose()
self.isFrozenDisposable?.dispose()
}
public func storeSecureIdPassword(password: String) {
self.storedPassword?.2.invalidate()
let timer = SwiftSignalKit.Timer(timeout: 1.0 * 60.0 * 60.0, repeat: false, completion: { [weak self] in
self?.storedPassword = nil
}, queue: Queue.mainQueue())
self.storedPassword = (password, CFAbsoluteTimeGetCurrent(), timer)
timer.start()
}
public func getStoredSecureIdPassword() -> String? {
if let (password, timestamp, timer) = self.storedPassword {
if CFAbsoluteTimeGetCurrent() > timestamp + 1.0 * 60.0 * 60.0 {
timer.invalidate()
self.storedPassword = nil
}
return password
} else {
return nil
}
}
public func chatLocationInput(for location: ChatLocation, contextHolder: Atomic<ChatLocationContextHolder?>) -> ChatLocationInput {
switch location {
case let .peer(peerId):
return .peer(peerId: peerId, threadId: nil)
case let .replyThread(data):
if data.isForumPost || data.peerId.namespace != Namespaces.Peer.CloudChannel {
return .peer(peerId: data.peerId, threadId: data.threadId)
} else {
let context = chatLocationContext(holder: contextHolder, account: self.account, data: data)
return .thread(peerId: data.peerId, threadId: data.threadId, data: context.state)
}
case .customChatContents:
preconditionFailure()
}
}
public func chatLocationOutgoingReadState(for location: ChatLocation, contextHolder: Atomic<ChatLocationContextHolder?>) -> Signal<MessageId?, NoError> {
switch location {
case .peer:
return .single(nil)
case let .replyThread(data):
if data.isForumPost, let peerId = location.peerId {
let viewKey: PostboxViewKey = .messageHistoryThreadInfo(peerId: data.peerId, threadId: data.threadId)
return self.account.postbox.combinedView(keys: [viewKey])
|> map { views -> MessageId? in
if let threadInfo = views.views[viewKey] as? MessageHistoryThreadInfoView, let data = threadInfo.info?.data.get(MessageHistoryThreadData.self) {
return MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: data.maxOutgoingReadId)
} else {
return nil
}
}
} else if data.peerId.namespace == Namespaces.Peer.CloudChannel {
let context = chatLocationContext(holder: contextHolder, account: self.account, data: data)
return context.maxReadOutgoingMessageId
} else {
return .single(nil)
}
case .customChatContents:
return .single(nil)
}
}
public func chatLocationUnreadCount(for location: ChatLocation, contextHolder: Atomic<ChatLocationContextHolder?>) -> Signal<Int, NoError> {
switch location {
case let .peer(peerId):
let unreadCountsKey: PostboxViewKey = .unreadCounts(items: [.peer(id: peerId, handleThreads: false), .total(nil)])
return self.account.postbox.combinedView(keys: [unreadCountsKey])
|> map { views in
var unreadCount: Int32 = 0
if let view = views.views[unreadCountsKey] as? UnreadMessageCountsView {
if let count = view.count(for: .peer(id: peerId, handleThreads: false)) {
unreadCount = count
}
}
return Int(unreadCount)
}
case let .replyThread(data):
if data.isForumPost {
let viewKey: PostboxViewKey = .messageHistoryThreadInfo(peerId: data.peerId, threadId: data.threadId)
return self.account.postbox.combinedView(keys: [viewKey])
|> map { views -> Int in
if let threadInfo = views.views[viewKey] as? MessageHistoryThreadInfoView, let data = threadInfo.info?.data.get(MessageHistoryThreadData.self) {
return Int(data.incomingUnreadCount)
} else {
return 0
}
}
} else if data.peerId.namespace != Namespaces.Peer.CloudChannel {
return .single(0)
} else {
let context = chatLocationContext(holder: contextHolder, account: self.account, data: data)
return context.unreadCount
}
case .customChatContents:
return .single(0)
}
}
public func applyMaxReadIndex(for location: ChatLocation, contextHolder: Atomic<ChatLocationContextHolder?>, messageIndex: MessageIndex) {
switch location {
case .peer:
let _ = self.engine.messages.applyMaxReadIndexInteractively(index: messageIndex).start()
case let .replyThread(data):
let context = chatLocationContext(holder: contextHolder, account: self.account, data: data)
context.applyMaxReadIndex(messageIndex: messageIndex)
case .customChatContents:
break
}
}
public func scheduleGroupCall(peerId: PeerId, parentController: ViewController) {
let _ = self.sharedContext.callManager?.scheduleGroupCall(context: self, peerId: peerId, endCurrentIfAny: true, parentController: parentController)
}
public func joinGroupCall(peerId: PeerId, invite: String?, requestJoinAsPeerId: ((@escaping (PeerId?) -> Void) -> Void)?, activeCall: EngineGroupCallDescription) {
let callResult = self.sharedContext.callManager?.joinGroupCall(context: self, peerId: peerId, invite: invite, requestJoinAsPeerId: requestJoinAsPeerId, initialCall: activeCall, endCurrentIfAny: false)
if let callResult = callResult, case let .alreadyInProgress(currentCallType) = callResult {
if case let .peer(currentPeerId) = currentCallType, currentPeerId == peerId {
self.sharedContext.navigateToCurrentCall()
} else {
let dataInput: Signal<(EnginePeer?, EnginePeer?), NoError>
if case let .peer(currentPeerId) = currentCallType, let currentPeerId {
dataInput = self.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId),
TelegramEngine.EngineData.Item.Peer.Peer(id: currentPeerId)
)
} else {
dataInput = self.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
)
|> map { peer -> (EnginePeer?, EnginePeer?) in
return (peer, nil)
}
}
let _ = (dataInput
|> deliverOnMainQueue).start(next: { [weak self] peer, current in
guard let strongSelf = self else {
return
}
guard let peer = peer else {
return
}
let presentationData = strongSelf.sharedContext.currentPresentationData.with { $0 }
if let current = current {
switch current {
case .channel, .legacyGroup:
let title: String
let text: String
if case let .channel(channel) = current, case .broadcast = channel.info {
title = presentationData.strings.Call_LiveStreamInProgressTitle
text = presentationData.strings.Call_LiveStreamInProgressMessage(current.compactDisplayTitle, peer.compactDisplayTitle).string
} else {
title = presentationData.strings.Call_VoiceChatInProgressTitle
text = presentationData.strings.Call_VoiceChatInProgressMessage(current.compactDisplayTitle, peer.compactDisplayTitle).string
}
strongSelf.sharedContext.mainWindow?.present(textAlertController(context: strongSelf, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {
guard let strongSelf = self else {
return
}
let _ = strongSelf.sharedContext.callManager?.joinGroupCall(context: strongSelf, peerId: peer.id, invite: invite, requestJoinAsPeerId: requestJoinAsPeerId, initialCall: activeCall, endCurrentIfAny: true)
})]), on: .root)
default:
let text: String
if case let .channel(channel) = peer, case .broadcast = channel.info {
text = presentationData.strings.Call_CallInProgressLiveStreamMessage(current.compactDisplayTitle, peer.compactDisplayTitle).string
} else {
text = presentationData.strings.Call_CallInProgressVoiceChatMessage(current.compactDisplayTitle, peer.compactDisplayTitle).string
}
strongSelf.sharedContext.mainWindow?.present(textAlertController(context: strongSelf, title: presentationData.strings.Call_CallInProgressTitle, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {
guard let strongSelf = self else {
return
}
let _ = strongSelf.sharedContext.callManager?.joinGroupCall(context: strongSelf, peerId: peer.id, invite: invite, requestJoinAsPeerId: requestJoinAsPeerId, initialCall: activeCall, endCurrentIfAny: true)
})]), on: .root)
}
} else {
strongSelf.sharedContext.mainWindow?.present(textAlertController(context: strongSelf, title: presentationData.strings.Call_CallInProgressTitle, text: presentationData.strings.Call_ExternalCallInProgressMessage, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {
})]), on: .root)
}
})
}
}
}
public func joinConferenceCall(call: JoinCallLinkInformation, isVideo: Bool, unmuteByDefault: Bool) {
guard let callManager = self.sharedContext.callManager else {
return
}
let result = callManager.joinConferenceCall(
accountContext: self,
initialCall: EngineGroupCallDescription(
id: call.id,
accessHash: call.accessHash,
title: nil,
scheduleTimestamp: nil,
subscribedToScheduled: false,
isStream: false
),
reference: call.reference,
beginWithVideo: isVideo,
invitePeerIds: [],
endCurrentIfAny: false,
unmuteByDefault: unmuteByDefault
)
if case let .alreadyInProgress(currentCallType) = result {
let dataInput: Signal<EnginePeer?, NoError>
if case let .peer(currentPeerId) = currentCallType, let currentPeerId {
dataInput = self.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: currentPeerId)
)
} else {
dataInput = .single(nil)
}
let _ = (dataInput
|> deliverOnMainQueue).start(next: { [weak self] current in
guard let strongSelf = self else {
return
}
let presentationData = strongSelf.sharedContext.currentPresentationData.with { $0 }
if let current = current {
switch current {
case .channel, .legacyGroup:
let title: String
let text: String
if case let .channel(channel) = current, case .broadcast = channel.info {
title = presentationData.strings.Call_LiveStreamInProgressTitle
text = presentationData.strings.Call_LiveStreamInProgressConferenceMessage(current.compactDisplayTitle).string
} else {
title = presentationData.strings.Call_VoiceChatInProgressTitle
text = presentationData.strings.Call_VoiceChatInProgressConferenceMessage(current.compactDisplayTitle).string
}
strongSelf.sharedContext.mainWindow?.present(textAlertController(context: strongSelf, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {
guard let self else {
return
}
let _ = callManager.joinConferenceCall(
accountContext: self,
initialCall: EngineGroupCallDescription(
id: call.id,
accessHash: call.accessHash,
title: nil,
scheduleTimestamp: nil,
subscribedToScheduled: false,
isStream: false
),
reference: call.reference,
beginWithVideo: isVideo,
invitePeerIds: [],
endCurrentIfAny: true,
unmuteByDefault: unmuteByDefault
)
})]), on: .root)
default:
let text: String
text = presentationData.strings.Call_VoiceChatInProgressConferenceMessage(current.compactDisplayTitle).string
strongSelf.sharedContext.mainWindow?.present(textAlertController(context: strongSelf, title: presentationData.strings.Call_CallInProgressTitle, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {
guard let self else {
return
}
let _ = callManager.joinConferenceCall(
accountContext: self,
initialCall: EngineGroupCallDescription(
id: call.id,
accessHash: call.accessHash,
title: nil,
scheduleTimestamp: nil,
subscribedToScheduled: false,
isStream: false
),
reference: call.reference,
beginWithVideo: isVideo,
invitePeerIds: [],
endCurrentIfAny: true,
unmuteByDefault: unmuteByDefault
)
})]), on: .root)
}
} else if case .peer = currentCallType {
let text: String
text = presentationData.strings.Call_AlertMoveToConference
strongSelf.sharedContext.mainWindow?.present(textAlertController(context: strongSelf, title: presentationData.strings.Call_CallInProgressTitle, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {
guard let self else {
return
}
let _ = callManager.joinConferenceCall(
accountContext: self,
initialCall: EngineGroupCallDescription(
id: call.id,
accessHash: call.accessHash,
title: nil,
scheduleTimestamp: nil,
subscribedToScheduled: false,
isStream: false
),
reference: call.reference,
beginWithVideo: isVideo,
invitePeerIds: [],
endCurrentIfAny: true,
unmuteByDefault: unmuteByDefault
)
})]), on: .root)
} else {
strongSelf.sharedContext.mainWindow?.present(textAlertController(context: strongSelf, title: presentationData.strings.Call_CallInProgressTitle, text: presentationData.strings.Call_ExternalCallInProgressMessage, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {
})]), on: .root)
}
})
}
}
public func requestCall(peerId: PeerId, isVideo: Bool, completion: @escaping () -> Void) {
guard let callResult = self.sharedContext.callManager?.requestCall(context: self, peerId: peerId, isVideo: isVideo, endCurrentIfAny: false) else {
return
}
if case let .alreadyInProgress(currentCallType) = callResult {
if case let .peer(currentPeerId) = currentCallType, currentPeerId == peerId {
completion()
self.sharedContext.navigateToCurrentCall()
} else {
let dataInput: Signal<(EnginePeer?, EnginePeer?), NoError>
if case let .peer(currentPeerId) = currentCallType, let currentPeerId {
dataInput = self.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId),
TelegramEngine.EngineData.Item.Peer.Peer(id: currentPeerId)
)
} else {
dataInput = self.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
)
|> map { peer -> (EnginePeer?, EnginePeer?) in
return (peer, nil)
}
}
let _ = (dataInput
|> deliverOnMainQueue).start(next: { [weak self] peer, current in
guard let strongSelf = self else {
return
}
guard let peer = peer else {
return
}
let presentationData = strongSelf.sharedContext.currentPresentationData.with { $0 }
if let current = current {
switch current {
case .channel, .legacyGroup:
let text: String
if case let .channel(channel) = current, case .broadcast = channel.info {
text = presentationData.strings.Call_LiveStreamInProgressCallMessage(current.compactDisplayTitle, peer.compactDisplayTitle).string
} else {
text = presentationData.strings.Call_VoiceChatInProgressCallMessage(current.compactDisplayTitle, peer.compactDisplayTitle).string
}
strongSelf.sharedContext.mainWindow?.present(textAlertController(context: strongSelf, title: presentationData.strings.Call_VoiceChatInProgressTitle, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {
guard let strongSelf = self else {
return
}
let _ = strongSelf.sharedContext.callManager?.requestCall(context: strongSelf, peerId: peerId, isVideo: isVideo, endCurrentIfAny: true)
completion()
})]), on: .root)
default:
strongSelf.sharedContext.mainWindow?.present(textAlertController(context: strongSelf, title: presentationData.strings.Call_CallInProgressTitle, text: presentationData.strings.Call_CallInProgressMessage(current.compactDisplayTitle, peer.compactDisplayTitle).string, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {
guard let strongSelf = self else {
return
}
let _ = strongSelf.sharedContext.callManager?.requestCall(context: strongSelf, peerId: peerId, isVideo: isVideo, endCurrentIfAny: true)
completion()
})]), on: .root)
}
} else if let strongSelf = self {
strongSelf.sharedContext.mainWindow?.present(textAlertController(context: strongSelf, title: presentationData.strings.Call_CallInProgressTitle, text: presentationData.strings.Call_ExternalCallInProgressMessage, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {
})]), on: .root)
}
})
}
} else {
completion()
}
}
}
private func chatLocationContext(holder: Atomic<ChatLocationContextHolder?>, account: Account, data: ChatReplyThreadMessage) -> ReplyThreadHistoryContext {
let holder = holder.modify { current in
if let current = current as? ChatLocationReplyContextHolderImpl {
return current
} else {
return ChatLocationReplyContextHolderImpl(account: account, data: data)
}
} as! ChatLocationReplyContextHolderImpl
return holder.context
}
private final class ChatLocationReplyContextHolderImpl: ChatLocationContextHolder {
let context: ReplyThreadHistoryContext
init(account: Account, data: ChatReplyThreadMessage) {
self.context = ReplyThreadHistoryContext(account: account, peerId: data.peerId, data: data)
}
}
func getAppConfiguration(postbox: Postbox) -> Signal<AppConfiguration, NoError> {
return postbox.preferencesView(keys: [PreferencesKeys.appConfiguration])
|> map { view -> AppConfiguration in
let appConfiguration: AppConfiguration = view.values[PreferencesKeys.appConfiguration]?.get(AppConfiguration.self) ?? AppConfiguration.defaultValue
return appConfiguration
}
|> distinctUntilChanged
}
private func loadCountryCodes() -> [Country] {
guard let filePath = getAppBundle().path(forResource: "PhoneCountries", ofType: "txt") else {
return []
}
guard let stringData = try? Data(contentsOf: URL(fileURLWithPath: filePath)) else {
return []
}
guard let data = String(data: stringData, encoding: .utf8) else {
return []
}
let delimiter = ";"
let endOfLine = "\n"
var result: [Country] = []
// var countriesByPrefix: [String: (Country, Country.CountryCode)] = [:]
var currentLocation = data.startIndex
let locale = Locale(identifier: "en-US")
while true {
guard let codeRange = data.range(of: delimiter, options: [], range: currentLocation ..< data.endIndex) else {
break
}
let countryCode = String(data[currentLocation ..< codeRange.lowerBound])
guard let idRange = data.range(of: delimiter, options: [], range: codeRange.upperBound ..< data.endIndex) else {
break
}
let countryId = String(data[codeRange.upperBound ..< idRange.lowerBound])
guard let patternRange = data.range(of: delimiter, options: [], range: idRange.upperBound ..< data.endIndex) else {
break
}
let pattern = String(data[idRange.upperBound ..< patternRange.lowerBound])
let maybeNameRange = data.range(of: endOfLine, options: [], range: patternRange.upperBound ..< data.endIndex)
let countryName = locale.localizedString(forIdentifier: countryId) ?? ""
if let _ = Int(countryCode) {
let code = Country.CountryCode(code: countryCode, prefixes: [], patterns: !pattern.isEmpty ? [pattern] : [])
let country = Country(id: countryId, name: countryName, localizedName: nil, countryCodes: [code], hidden: false)
result.append(country)
// countriesByPrefix["\(code.code)"] = (country, code)
}
if let maybeNameRange = maybeNameRange {
currentLocation = maybeNameRange.upperBound
} else {
break
}
}
return result
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,990 @@
import Foundation
import UIKit
import Intents
import TelegramPresentationData
import TelegramUIPreferences
import SwiftSignalKit
import Postbox
import TelegramCore
import Display
import LegacyComponents
import DeviceAccess
import TelegramUpdateUI
import AccountContext
import AlertUI
import PresentationDataUtils
import TelegramPermissions
import TelegramNotices
import LegacyUI
import TelegramPermissionsUI
import PasscodeUI
import ImageBlur
import FastBlur
import SettingsUI
import AppLock
import AccountUtils
import ContextUI
import TelegramCallsUI
import AuthorizationUI
import ChatListUI
import StoryContainerScreen
import ChatMessageNotificationItem
import PhoneNumberFormat
import AttachmentUI
import MinimizedContainer
import BrowserUI
final class UnauthorizedApplicationContext {
let sharedContext: SharedAccountContextImpl
let account: UnauthorizedAccount
let rootController: AuthorizationSequenceController
let isReady = Promise<Bool>()
var authorizationCompleted: Bool = false
private var serviceNotificationEventsDisposable: Disposable?
init(apiId: Int32, apiHash: String, sharedContext: SharedAccountContextImpl, account: UnauthorizedAccount, otherAccountPhoneNumbers: ((String, AccountRecordId, Bool)?, [(String, AccountRecordId, Bool)])) {
self.sharedContext = sharedContext
self.account = account
let presentationData = sharedContext.currentPresentationData.with { $0 }
var authorizationCompleted: (() -> Void)?
self.rootController = AuthorizationSequenceController(sharedContext: sharedContext, account: account, otherAccountPhoneNumbers: otherAccountPhoneNumbers, presentationData: presentationData, openUrl: sharedContext.applicationBindings.openUrl, apiId: apiId, apiHash: apiHash, authorizationCompleted: {
authorizationCompleted?()
})
(self.rootController as NavigationController).statusBarHost = sharedContext.mainWindow?.statusBarHost
authorizationCompleted = { [weak self] in
self?.authorizationCompleted = true
}
self.isReady.set(self.rootController.ready.get())
account.shouldBeServiceTaskMaster.set(sharedContext.applicationBindings.applicationInForeground |> map { value -> AccountServiceTaskMasterMode in
if value {
return .always
} else {
return .never
}
})
DeviceAccess.authorizeAccess(to: .cellularData, presentationData: sharedContext.currentPresentationData.with { $0 }, present: { [weak self] c, a in
if let strongSelf = self {
(strongSelf.rootController.viewControllers.last as? ViewController)?.present(c, in: .window(.root))
}
}, openSettings: {
sharedContext.applicationBindings.openSettings()
}, { result in
ApplicationSpecificNotice.setPermissionWarning(accountManager: sharedContext.accountManager, permission: .cellularData, value: 0)
})
self.serviceNotificationEventsDisposable = (account.serviceNotificationEvents
|> deliverOnMainQueue).start(next: { [weak self] text in
if let strongSelf = self {
let presentationData = strongSelf.sharedContext.currentPresentationData.with { $0 }
let alertController = textAlertController(sharedContext: strongSelf.sharedContext, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})])
(strongSelf.rootController.viewControllers.last as? ViewController)?.present(alertController, in: .window(.root))
}
})
}
deinit {
self.serviceNotificationEventsDisposable?.dispose()
}
}
final class AuthorizedApplicationContext {
let sharedApplicationContext: SharedApplicationContext
let mainWindow: Window1
let lockedCoveringView: LockedWindowCoveringView
let context: AccountContextImpl
let rootController: TelegramRootController
let notificationController: NotificationContainerController
private let scheduledCallPeerDisposable = MetaDisposable()
private var scheduledOpenExternalUrl: URL?
private let passcodeStatusDisposable = MetaDisposable()
private let passcodeLockDisposable = MetaDisposable()
private let loggedOutDisposable = MetaDisposable()
private let inAppNotificationSettingsDisposable = MetaDisposable()
private let notificationMessagesDisposable = MetaDisposable()
private let termsOfServiceUpdatesDisposable = MetaDisposable()
private let termsOfServiceProceedToBotDisposable = MetaDisposable()
private let watchNavigateToMessageDisposable = MetaDisposable()
private let permissionsDisposable = MetaDisposable()
private let appUpdateInfoDisposable = MetaDisposable()
private var inAppNotificationSettings: InAppNotificationSettings?
var passcodeController: PasscodeEntryController?
private var currentAppUpdateInfo: AppUpdateInfo?
private var currentTermsOfServiceUpdate: TermsOfServiceUpdate?
private var currentPermissionsController: PermissionController?
private var currentPermissionsState: PermissionState?
private let unlockedStatePromise = Promise<Bool>()
var unlockedState: Signal<Bool, NoError> {
return self.unlockedStatePromise.get()
}
var applicationBadge: Signal<Int32, NoError> {
return renderedTotalUnreadCount(accountManager: self.context.sharedContext.accountManager, engine: self.context.engine)
|> map {
$0.0
}
}
let isReady = Promise<Bool>()
private var presentationDataDisposable: Disposable?
private var displayAlertsDisposable: Disposable?
private var removeNotificationsDisposable: Disposable?
private var applicationInForegroundDisposable: Disposable?
private var showCallsTab: Bool
private var showCallsTabDisposable: Disposable?
private var enablePostboxTransactionsDiposable: Disposable?
init(sharedApplicationContext: SharedApplicationContext, mainWindow: Window1, context: AccountContextImpl, accountManager: AccountManager<TelegramAccountManagerTypes>, showCallsTab: Bool, reinitializedNotificationSettings: @escaping () -> Void) {
self.sharedApplicationContext = sharedApplicationContext
setupLegacyComponents(context: context)
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.mainWindow = mainWindow
self.lockedCoveringView = LockedWindowCoveringView(theme: presentationData.theme)
self.context = context
self.showCallsTab = showCallsTab
self.notificationController = NotificationContainerController(context: context)
self.rootController = TelegramRootController(context: context)
self.rootController.minimizedContainer = self.sharedApplicationContext.minimizedContainer[context.account.id]
self.rootController.minimizedContainerUpdated = { [weak self] minimizedContainer in
guard let self else {
return
}
self.sharedApplicationContext.minimizedContainer[self.context.account.id] = minimizedContainer
}
self.rootController.globalOverlayControllersUpdated = { [weak self] in
guard let strongSelf = self else {
return
}
var hasContext = false
for controller in strongSelf.rootController.globalOverlayControllers {
if controller is ContextController {
hasContext = true
break
}
}
strongSelf.notificationController.updateIsTemporaryHidden(hasContext)
}
if KeyShortcutsController.isAvailable {
let keyShortcutsController = KeyShortcutsController { [weak self] f in
if let strongSelf = self, let appLockContext = strongSelf.context.sharedContext.appLockContext as? AppLockContextImpl {
let _ = (appLockContext.isCurrentlyLocked
|> take(1)
|> deliverOnMainQueue).start(next: { locked in
guard !locked else {
return
}
if let tabController = strongSelf.rootController.rootTabController {
let selectedController = tabController.controllers[tabController.selectedIndex]
if let index = strongSelf.rootController.viewControllers.lastIndex(where: { controller in
guard let controller = controller as? ViewController else {
return false
}
if controller === tabController {
return false
}
switch controller.navigationPresentation {
case .master:
return true
default:
break
}
return false
}), let controller = strongSelf.rootController.viewControllers[index] as? ViewController {
if !f(controller) {
return
}
} else {
if !f(selectedController) {
return
}
}
if let controller = strongSelf.rootController.topViewController as? ViewController, controller !== selectedController {
if !f(controller) {
return
}
}
}
strongSelf.mainWindow.forEachViewController(f)
if let globalOverlayController = strongSelf.rootController.globalOverlayControllers.last {
if !f(globalOverlayController) {
return
}
}
})
}
}
context.keyShortcutsController = keyShortcutsController
}
if self.rootController.rootTabController == nil {
self.rootController.addRootControllers(showCallsTab: self.showCallsTab)
}
if let tabsController = self.rootController.viewControllers.first as? TabBarController, !tabsController.controllers.isEmpty, tabsController.selectedIndex >= 0 {
let controller = tabsController.controllers[tabsController.selectedIndex]
let combinedReady = combineLatest(tabsController.ready.get(), controller.ready.get())
|> map { $0 && $1 }
|> filter { $0 }
|> take(1)
self.isReady.set(combinedReady)
} else {
self.isReady.set(.single(true))
}
let accountId = context.account.id
self.loggedOutDisposable.set((context.account.loggedOut
|> deliverOnMainQueue).start(next: { [weak self] value in
if value {
Logger.shared.log("ApplicationContext", "account logged out")
let _ = logoutFromAccount(id: accountId, accountManager: accountManager, alreadyLoggedOutRemotely: false).start()
if let strongSelf = self {
strongSelf.rootController.currentWindow?.forEachController { controller in
if let controller = controller as? TermsOfServiceController {
controller.dismiss()
}
}
}
}
}))
self.inAppNotificationSettingsDisposable.set(((context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.inAppNotificationSettings])) |> deliverOnMainQueue).start(next: { [weak self] sharedData in
if let strongSelf = self {
if let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.inAppNotificationSettings]?.get(InAppNotificationSettings.self) {
let previousSettings = strongSelf.inAppNotificationSettings
strongSelf.inAppNotificationSettings = settings
if let previousSettings = previousSettings, previousSettings.displayNameOnLockscreen != settings.displayNameOnLockscreen {
reinitializedNotificationSettings()
}
}
}
}))
let engine = context.engine
self.notificationMessagesDisposable.set((context.account.stateManager.notificationMessages
|> mapToSignal { messageList -> Signal<[([Message], PeerGroupId, Bool, MessageHistoryThreadData?)], NoError> in
return engine.data.get(EngineDataMap(
messageList.compactMap { item -> TelegramEngine.EngineData.Item.Messages.ChatListIndex? in
if let message = item.0.first {
return TelegramEngine.EngineData.Item.Messages.ChatListIndex(id: message.id.peerId)
} else {
return nil
}
}
))
|> map { chatListIndexMap -> [([Message], PeerGroupId, Bool, MessageHistoryThreadData?)] in
return messageList.filter { item in
guard let message = item.0.first else {
return false
}
if let maybeChatListIndex = chatListIndexMap[message.id.peerId], maybeChatListIndex != nil {
return true
} else {
return false
}
}
}
}
|> deliverOn(Queue.mainQueue())).start(next: { [weak self] messageList in
if messageList.isEmpty {
return
}
if let strongSelf = self, let (messages, _, notify, threadData) = messageList.last, let firstMessage = messages.first {
if UIApplication.shared.applicationState == .active {
let chatLocation: NavigateToChatControllerParams.Location
if let _ = threadData, let threadId = firstMessage.threadId {
chatLocation = .replyThread(ChatReplyThreadMessage(
peerId: firstMessage.id.peerId, threadId: threadId, channelMessageId: nil, isChannelPost: false, isForumPost: true, isMonoforumPost: false, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false
).normalized)
} else {
guard let peer = firstMessage.peers[firstMessage.id.peerId] else {
return
}
chatLocation = .peer(EnginePeer(peer))
}
var chatIsVisible = false
if let topController = strongSelf.rootController.topViewController as? ChatControllerImpl, topController.traceVisibility() {
if topController.chatLocation.peerId == firstMessage.id.peerId, (topController.chatLocation.threadId == nil || topController.chatLocation.threadId == firstMessage.threadId) {
chatIsVisible = true
}
}
if !notify {
chatIsVisible = true
}
if !chatIsVisible {
strongSelf.mainWindow.forEachViewController({ controller in
if let controller = controller as? ChatControllerImpl, controller.chatLocation.peerId == chatLocation.peerId, (chatLocation.threadId == nil || chatLocation.threadId == controller.chatLocation.threadId) {
chatIsVisible = true
return false
}
return true
})
}
let inAppNotificationSettings: InAppNotificationSettings
if let current = strongSelf.inAppNotificationSettings {
inAppNotificationSettings = current
} else {
inAppNotificationSettings = InAppNotificationSettings.defaultSettings
}
if let appLockContext = strongSelf.context.sharedContext.appLockContext as? AppLockContextImpl {
let _ = (appLockContext.isCurrentlyLocked
|> take(1)
|> deliverOnMainQueue).start(next: { locked in
guard let strongSelf = self else {
return
}
guard !locked else {
return
}
let isMuted = firstMessage.attributes.contains(where: { attribute in
if let attribute = attribute as? NotificationInfoMessageAttribute {
return attribute.flags.contains(.muted)
} else {
return false
}
})
if !isMuted {
if firstMessage.id.peerId == context.account.peerId, !firstMessage.flags.contains(.WasScheduled) {
} else {
if inAppNotificationSettings.playSounds {
serviceSoundManager.playIncomingMessageSound()
}
if inAppNotificationSettings.vibrate {
serviceSoundManager.playVibrationSound()
}
}
}
if let forwardInfo = firstMessage.forwardInfo, forwardInfo.flags.contains(.isImported) {
return
}
for media in firstMessage.media {
if let action = media as? TelegramMediaAction {
if case .messageAutoremoveTimeoutUpdated = action.action {
return
} else if case .conferenceCall = action.action {
return
}
}
}
if chatIsVisible {
return
}
if firstMessage.restrictionReason(platform: "ios", contentSettings: strongSelf.context.currentContentSettings.with { $0 }) != nil {
return
}
if let chatPeer = firstMessage.peers[firstMessage.id.peerId] {
if EnginePeer(chatPeer).restrictionText(platform: "ios", contentSettings: strongSelf.context.currentContentSettings.with { $0 }) != nil {
return
}
}
if inAppNotificationSettings.displayPreviews {
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
strongSelf.notificationController.enqueue(ChatMessageNotificationItem(context: strongSelf.context, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, messages: messages, threadData: threadData, tapAction: {
if let strongSelf = self {
var foundOverlay = false
strongSelf.mainWindow.forEachViewController({ controller in
if isOverlayControllerForChatNotificationOverlayPresentation(controller) {
foundOverlay = true
return false
}
return true
}, excludeNavigationSubControllers: true)
if foundOverlay {
return true
}
if let topController = strongSelf.rootController.topViewController as? ViewController, isInlineControllerForChatNotificationOverlayPresentation(topController) {
return true
}
if let topController = strongSelf.rootController.topViewController as? ChatControllerImpl, topController.chatLocation.peerId == chatLocation.peerId, (topController.chatLocation.threadId == nil || topController.chatLocation.threadId == chatLocation.threadId) {
strongSelf.notificationController.removeItemsWithGroupingKey(firstMessage.id.peerId)
return false
}
if let minimizedContainer = strongSelf.rootController.minimizedContainer, minimizedContainer.isExpanded {
minimizedContainer.collapse()
} else if let topContoller = strongSelf.rootController.topViewController as? AttachmentController {
topContoller.minimizeIfNeeded()
} else if let topContoller = strongSelf.rootController.topViewController as? BrowserScreen {
topContoller.requestMinimize(topEdgeOffset: nil, initialVelocity: nil)
}
for controller in strongSelf.rootController.viewControllers {
if let controller = controller as? ChatControllerImpl, controller.chatLocation.peerId == chatLocation.peerId, (controller.chatLocation.threadId == nil || controller.chatLocation.threadId == chatLocation.threadId) {
return true
}
}
strongSelf.notificationController.removeItemsWithGroupingKey(firstMessage.id.peerId)
var processed = false
for media in firstMessage.media {
if let action = media as? TelegramMediaAction, case .geoProximityReached = action.action {
strongSelf.context.sharedContext.openLocationScreen(context: strongSelf.context, messageId: firstMessage.id, navigationController: strongSelf.rootController)
processed = true
break
}
}
if !processed {
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: strongSelf.rootController, context: strongSelf.context, chatLocation: chatLocation))
}
}
return false
}, expandAction: { expandData in
if let strongSelf = self {
let chatController = ChatControllerImpl(context: strongSelf.context, chatLocation: chatLocation.asChatLocation, mode: .overlay(strongSelf.rootController))
chatController.presentationArguments = ChatControllerOverlayPresentationData(expandData: expandData())
(strongSelf.rootController.viewControllers.last as? ViewController)?.present(chatController, in: .window(.root), with: ChatControllerOverlayPresentationData(expandData: expandData()))
}
}))
}
})
}
}
}
}))
self.termsOfServiceUpdatesDisposable.set((context.account.stateManager.termsOfServiceUpdate
|> deliverOnMainQueue).start(next: { [weak self] termsOfServiceUpdate in
guard let strongSelf = self, strongSelf.currentTermsOfServiceUpdate != termsOfServiceUpdate else {
return
}
strongSelf.currentTermsOfServiceUpdate = termsOfServiceUpdate
if let termsOfServiceUpdate = termsOfServiceUpdate {
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
var acceptImpl: ((String?) -> Void)?
var declineImpl: (() -> Void)?
let controller = TermsOfServiceController(presentationData: presentationData, text: termsOfServiceUpdate.text, entities: termsOfServiceUpdate.entities, ageConfirmation: termsOfServiceUpdate.ageConfirmation, signingUp: false, accept: { proccedBot in
acceptImpl?(proccedBot)
}, decline: {
declineImpl?()
}, openUrl: { url in
if let parsedUrl = URL(string: url) {
UIApplication.shared.open(parsedUrl, options: [:], completionHandler: nil)
}
})
acceptImpl = { [weak controller] botName in
controller?.inProgress = true
guard let strongSelf = self else {
return
}
let _ = (strongSelf.context.engine.accountData.acceptTermsOfService(id: termsOfServiceUpdate.id)
|> deliverOnMainQueue).start(completed: {
controller?.dismiss()
if let strongSelf = self, let botName = botName {
strongSelf.termsOfServiceProceedToBotDisposable.set((strongSelf.context.engine.peers.resolvePeerByName(name: botName, referrer: nil, ageLimit: 10)
|> mapToSignal { result -> Signal<EnginePeer?, NoError> in
guard case let .result(result) = result else {
return .complete()
}
return .single(result)
}
|> deliverOnMainQueue).start(next: { peer in
if let strongSelf = self, let peer = peer {
self?.rootController.pushViewController(ChatControllerImpl(context: strongSelf.context, chatLocation: .peer(id: peer.id)))
}
}))
}
})
}
declineImpl = { [weak controller] in
guard let strongSelf = self else {
return
}
let accountId = strongSelf.context.account.id
let accountManager = strongSelf.context.sharedContext.accountManager
let _ = (strongSelf.context.engine.auth.deleteAccount(reason: "GDPR", password: nil)
|> deliverOnMainQueue).start(error: { _ in
guard let strongSelf = self else {
return
}
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
let controller = textAlertController(context: strongSelf.context, title: nil, text: presentationData.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})])
(strongSelf.rootController.viewControllers.last as? ViewController)?.present(controller, in: .window(.root))
}, completed: {
controller?.dismiss()
let _ = logoutFromAccount(id: accountId, accountManager: accountManager, alreadyLoggedOutRemotely: true).start()
})
}
(strongSelf.rootController.viewControllers.last as? ViewController)?.present(controller, in: .window(.root))
}
}))
self.appUpdateInfoDisposable.set((context.account.stateManager.appUpdateInfo
|> deliverOnMainQueue).start(next: { [weak self] appUpdateInfo in
guard let strongSelf = self, strongSelf.currentAppUpdateInfo != appUpdateInfo else {
return
}
strongSelf.currentAppUpdateInfo = appUpdateInfo
if let appUpdateInfo = appUpdateInfo {
let controller = updateInfoController(context: strongSelf.context, appUpdateInfo: appUpdateInfo)
strongSelf.mainWindow.present(controller, on: .update)
}
}))
if #available(iOS 10.0, *) {
let permissionsPosition = ValuePromise(0, ignoreRepeated: true)
self.permissionsDisposable.set((combineLatest(queue: .mainQueue(), requiredPermissions(context: context), permissionUISplitTest(postbox: context.account.postbox), permissionsPosition.get(), context.sharedContext.accountManager.noticeEntry(key: ApplicationSpecificNotice.permissionWarningKey(permission: .contacts)!), context.sharedContext.accountManager.noticeEntry(key: ApplicationSpecificNotice.permissionWarningKey(permission: .notifications)!), context.sharedContext.accountManager.noticeEntry(key: ApplicationSpecificNotice.permissionWarningKey(permission: .cellularData)!))
|> deliverOnMainQueue).start(next: { [weak self] required, splitTest, position, contactsPermissionWarningNotice, notificationsPermissionWarningNotice, cellularDataPermissionWarningNotice in
guard let strongSelf = self else {
return
}
let contactsTimestamp = contactsPermissionWarningNotice.value.flatMap({ ApplicationSpecificNotice.getTimestampValue($0) })
let notificationsTimestamp = notificationsPermissionWarningNotice.value.flatMap({ ApplicationSpecificNotice.getTimestampValue($0) })
let cellularDataTimestamp = cellularDataPermissionWarningNotice.value.flatMap({ ApplicationSpecificNotice.getTimestampValue($0) })
if contactsTimestamp == nil, case .requestable = required.0.status {
ApplicationSpecificNotice.setPermissionWarning(accountManager: context.sharedContext.accountManager, permission: .contacts, value: 1)
}
if notificationsTimestamp == nil, case .requestable = required.1.status {
ApplicationSpecificNotice.setPermissionWarning(accountManager: context.sharedContext.accountManager, permission: .notifications, value: 1)
}
let config = splitTest.configuration
var order = config.order
if !order.contains(.cellularData) {
order.append(.cellularData)
}
if !order.contains(.siri) {
order.append(.siri)
}
var requestedPermissions: [(PermissionState, Bool)] = []
var i: Int = 0
for subject in order {
if i < position {
i += 1
continue
}
var modal = false
switch subject {
case .contacts:
if case .modal = config.contacts {
modal = true
}
if case .requestable = required.0.status, contactsTimestamp != 0 {
requestedPermissions.append((required.0, modal))
}
case .notifications:
if case .modal = config.notifications {
modal = true
}
if case .requestable = required.1.status, notificationsTimestamp != 0 {
requestedPermissions.append((required.1, modal))
}
case .cellularData:
if case .denied = required.2.status, cellularDataTimestamp != 0 {
requestedPermissions.append((required.2, true))
}
case .siri:
if case .requestable = required.3.status {
requestedPermissions.append((required.3, false))
}
default:
break
}
i += 1
}
if let (state, modal) = requestedPermissions.first {
if modal {
var didAppear = false
let controller: PermissionController
if let currentController = strongSelf.currentPermissionsController {
controller = currentController
didAppear = true
} else {
controller = PermissionController(context: context, splitTest: splitTest)
strongSelf.currentPermissionsController = controller
}
controller.setState(.permission(state), animated: didAppear)
controller.proceed = { resolved in
permissionsPosition.set(position + 1)
switch state {
case .contacts:
ApplicationSpecificNotice.setPermissionWarning(accountManager: context.sharedContext.accountManager, permission: .contacts, value: 0)
case .notifications:
ApplicationSpecificNotice.setPermissionWarning(accountManager: context.sharedContext.accountManager, permission: .notifications, value: 0)
case .cellularData:
ApplicationSpecificNotice.setPermissionWarning(accountManager: context.sharedContext.accountManager, permission: .cellularData, value: 0)
default:
break
}
}
if !didAppear {
Queue.mainQueue().after(0.15, {
(strongSelf.rootController.viewControllers.last as? ViewController)?.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
})
}
} else {
if strongSelf.currentPermissionsState != state {
strongSelf.currentPermissionsState = state
switch state {
case .contacts:
splitTest.addEvent(.ContactsRequest)
DeviceAccess.authorizeAccess(to: .contacts, presentationData: context.sharedContext.currentPresentationData.with { $0 }, { result in
if result {
splitTest.addEvent(.ContactsAllowed)
} else {
splitTest.addEvent(.ContactsDenied)
}
permissionsPosition.set(position + 1)
ApplicationSpecificNotice.setPermissionWarning(accountManager: context.sharedContext.accountManager, permission: .contacts, value: 0)
})
case .notifications:
splitTest.addEvent(.NotificationsRequest)
DeviceAccess.authorizeAccess(to: .notifications, registerForNotifications: { result in
context.sharedContext.applicationBindings.registerForNotifications(result)
}, { result in
if result {
splitTest.addEvent(.NotificationsAllowed)
} else {
splitTest.addEvent(.NotificationsDenied)
}
permissionsPosition.set(position + 1)
ApplicationSpecificNotice.setPermissionWarning(accountManager: context.sharedContext.accountManager, permission: .notifications, value: 0)
})
case .cellularData:
DeviceAccess.authorizeAccess(to: .cellularData, presentationData: context.sharedContext.currentPresentationData.with { $0 }, present: { [weak self] c, a in
if let strongSelf = self {
(strongSelf.rootController.viewControllers.last as? ViewController)?.present(c, in: .window(.root))
}
}, openSettings: {
context.sharedContext.applicationBindings.openSettings()
}, { result in
permissionsPosition.set(position + 1)
ApplicationSpecificNotice.setPermissionWarning(accountManager: context.sharedContext.accountManager, permission: .cellularData, value: 0)
})
case .siri:
DeviceAccess.authorizeAccess(to: .siri, requestSiriAuthorization: { completion in
return context.sharedContext.applicationBindings.requestSiriAuthorization(completion)
}, { result in
permissionsPosition.set(position + 1)
})
default:
break
}
}
}
} else {
if let controller = strongSelf.currentPermissionsController {
strongSelf.currentPermissionsController = nil
controller.dismiss(completion: {})
}
strongSelf.currentPermissionsState = nil
}
}))
}
self.displayAlertsDisposable = (context.account.stateManager.displayAlerts
|> deliverOnMainQueue).start(next: { [weak self] alerts in
if let strongSelf = self {
for (text, isDropAuth) in alerts {
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
let actions: [TextAlertAction]
if isDropAuth {
actions = [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: presentationData.strings.LogoutOptions_LogOut, action: {
if let strongSelf = self {
let _ = logoutFromAccount(id: strongSelf.context.account.id, accountManager: strongSelf.context.sharedContext.accountManager, alreadyLoggedOutRemotely: false).start()
}
})]
} else {
actions = [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]
}
let controller = textAlertController(context: strongSelf.context, title: nil, text: text, actions: actions)
(strongSelf.rootController.viewControllers.last as? ViewController)?.present(controller, in: .window(.root))
}
}
})
self.removeNotificationsDisposable = (context.account.stateManager.appliedIncomingReadMessages
|> deliverOnMainQueue).start(next: { [weak self] ids in
if let strongSelf = self {
strongSelf.context.sharedContext.applicationBindings.clearMessageNotifications(ids)
}
})
let importableContacts = self.context.sharedContext.contactDataManager?.importable() ?? .single([:])
let optionalImportableContacts = self.context.account.postbox.preferencesView(keys: [PreferencesKeys.contactsSettings])
|> mapToSignal { preferences -> Signal<[DeviceContactNormalizedPhoneNumber: ImportableDeviceContactData], NoError> in
let settings: ContactsSettings = preferences.values[PreferencesKeys.contactsSettings]?.get(ContactsSettings.self) ?? .defaultSettings
if settings.synchronizeContacts {
return importableContacts
} else {
return .single([:])
}
}
self.context.account.importableContacts.set(optionalImportableContacts)
self.context.sharedContext.deviceContactPhoneNumbers.set(optionalImportableContacts
|> map { contacts in
return Set(contacts.keys.map { cleanPhoneNumber($0.rawValue) })
})
let previousTheme = Atomic<PresentationTheme?>(value: nil)
self.presentationDataDisposable = (context.sharedContext.presentationData
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
if let strongSelf = self {
if previousTheme.swap(presentationData.theme) !== presentationData.theme {
strongSelf.lockedCoveringView.updateTheme(presentationData.theme)
strongSelf.rootController.updateTheme(NavigationControllerTheme(presentationTheme: presentationData.theme))
}
}
})
let showCallsTabSignal = context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.callListSettings])
|> map { sharedData -> Bool in
var value = CallListSettings.defaultSettings.showTab
if let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.callListSettings]?.get(CallListSettings.self) {
value = settings.showTab
}
return value
}
self.showCallsTabDisposable = (showCallsTabSignal |> deliverOnMainQueue).start(next: { [weak self] value in
if let strongSelf = self {
if strongSelf.showCallsTab != value {
strongSelf.showCallsTab = value
strongSelf.rootController.updateRootControllers(showCallsTab: value)
}
}
})
self.rootController.setForceInCallStatusBar((self.context.sharedContext as! SharedAccountContextImpl).currentCallStatusBarNode)
if let groupCallController = self.context.sharedContext.currentGroupCallController as? VoiceChatController {
if let overlayController = groupCallController.currentOverlayController {
groupCallController.parentNavigationController = self.rootController
self.rootController.presentOverlay(controller: overlayController, inGlobal: true, blockInteraction: false)
}
}
}
deinit {
self.context.account.postbox.clearCaches()
self.context.account.shouldKeepOnlinePresence.set(.single(false))
self.context.account.shouldBeServiceTaskMaster.set(.single(.never))
self.loggedOutDisposable.dispose()
self.inAppNotificationSettingsDisposable.dispose()
self.notificationMessagesDisposable.dispose()
self.termsOfServiceUpdatesDisposable.dispose()
self.passcodeLockDisposable.dispose()
self.passcodeStatusDisposable.dispose()
self.displayAlertsDisposable?.dispose()
self.removeNotificationsDisposable?.dispose()
self.presentationDataDisposable?.dispose()
self.enablePostboxTransactionsDiposable?.dispose()
self.termsOfServiceProceedToBotDisposable.dispose()
self.watchNavigateToMessageDisposable.dispose()
self.permissionsDisposable.dispose()
self.scheduledCallPeerDisposable.dispose()
}
func openNotificationSettings() {
self.rootController.pushViewController(notificationsAndSoundsController(context: self.context, exceptionsList: nil))
}
func startCall(peerId: PeerId, isVideo: Bool) {
guard let appLockContext = self.context.sharedContext.appLockContext as? AppLockContextImpl else {
return
}
self.scheduledCallPeerDisposable.set((appLockContext.isCurrentlyLocked
|> filter {
!$0
}
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] _ in
guard let strongSelf = self else {
return
}
let _ = strongSelf.context.sharedContext.callManager?.requestCall(context: strongSelf.context, peerId: peerId, isVideo: isVideo, endCurrentIfAny: false)
}))
}
func openChatWithPeerId(peerId: PeerId, threadId: Int64?, messageId: MessageId? = nil, activateInput: Bool = false, storyId: StoryId?, openAppIfAny: Bool = false, alwaysKeepMessageId: Bool = false) {
if let storyId {
var controllers = self.rootController.viewControllers
controllers = controllers.filter { c in
if c is StoryContainerScreen {
return false
}
return true
}
self.rootController.setViewControllers(controllers, animated: false)
self.rootController.chatListController?.openStoriesFromNotification(peerId: storyId.peerId, storyId: storyId.id)
} else {
var visiblePeerId: PeerId?
if let controller = self.rootController.topViewController as? ChatControllerImpl, controller.chatLocation.peerId == peerId, controller.chatLocation.threadId == threadId {
visiblePeerId = peerId
}
if visiblePeerId != peerId || messageId != nil {
let isOutgoingMessage: Signal<Bool, NoError>
if let messageId {
let accountPeerId = self.context.account.peerId
isOutgoingMessage = self.context.engine.data.get(TelegramEngine.EngineData.Item.Messages.Message(id: messageId))
|> map { message -> Bool in
if let message {
return !message._asMessage().effectivelyIncoming(accountPeerId)
} else {
return false
}
}
} else {
isOutgoingMessage = .single(false)
}
let _ = combineLatest(
queue: Queue.mainQueue(),
self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)),
isOutgoingMessage
).start(next: { peer, isOutgoingMessage in
guard let peer = peer else {
return
}
let chatLocation: NavigateToChatControllerParams.Location
if let threadId = threadId {
chatLocation = .replyThread(ChatReplyThreadMessage(
peerId: peerId, threadId: threadId, channelMessageId: nil, isChannelPost: false, isForumPost: true, isMonoforumPost: false, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false
))
} else {
chatLocation = .peer(peer)
}
if openAppIfAny, case let .user(user) = peer, let botInfo = user.botInfo, botInfo.flags.contains(.hasWebApp), let parentController = self.rootController.viewControllers.last as? ViewController {
self.context.sharedContext.openWebApp(
context: self.context,
parentController: parentController,
updatedPresentationData: nil,
botPeer: peer,
chatPeer: nil,
threadId: nil,
buttonText: "",
url: "",
simple: true,
source: .generic,
skipTermsOfService: true,
payload: nil,
verifyAgeCompletion: nil
)
} else {
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: self.rootController, context: self.context, chatLocation: chatLocation, subject: alwaysKeepMessageId || isOutgoingMessage ? messageId.flatMap { .message(id: .id($0), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false) } : nil, activateInput: activateInput ? .text : nil))
}
})
}
}
}
func openUrl(_ url: URL) {
if self.rootController.rootTabController != nil {
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
self.context.sharedContext.openExternalUrl(context: self.context, urlContext: .generic, url: url.absoluteString, forceExternal: false, presentationData: presentationData, navigationController: self.rootController, dismissInput: { [weak self] in
self?.rootController.view.endEditing(true)
})
} else {
self.scheduledOpenExternalUrl = url
}
}
func openRootSearch() {
self.rootController.openChatsController(activateSearch: true)
}
func openRootCompose() {
self.rootController.openRootCompose()
}
func openRootCamera() {
self.rootController.openRootCamera()
}
func openAppIcon() {
self.rootController.openAppIcon()
}
func switchAccount() {
let _ = (activeAccountsAndPeers(context: self.context)
|> take(1)
|> map { primaryAndAccounts -> (AccountContext, EnginePeer, Int32)? in
return primaryAndAccounts.1.first
}
|> map { accountAndPeer -> AccountContext? in
if let (context, _, _) = accountAndPeer {
return context
} else {
return nil
}
}
|> deliverOnMainQueue).start(next: { [weak self] context in
guard let strongSelf = self, let context = context else {
return
}
strongSelf.context.sharedContext.switchToAccount(id: context.account.id, fromSettingsController: nil, withChatListController: nil)
})
}
private func updateCoveringViewSnaphot(_ visible: Bool) {
if visible {
let scale: CGFloat = 0.5
let unscaledSize = self.mainWindow.hostView.containerView.frame.size
let image = generateImage(CGSize(width: floor(unscaledSize.width * scale), height: floor(unscaledSize.height * scale)), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.scaleBy(x: scale, y: scale)
UIGraphicsPushContext(context)
self.mainWindow.hostView.containerView.drawHierarchy(in: CGRect(origin: CGPoint(), size: unscaledSize), afterScreenUpdates: false)
UIGraphicsPopContext()
}).flatMap(applyScreenshotEffectToImage)
self.lockedCoveringView.updateSnapshot(image)
} else {
self.lockedCoveringView.updateSnapshot(nil)
}
}
}
@@ -0,0 +1,59 @@
import Foundation
import UIKit
import TelegramPresentationData
import DeviceAccess
enum ApplicationShortcutItemType: String {
case search
case compose
case camera
case savedMessages
case account
case appIcon
}
struct ApplicationShortcutItem: Equatable {
let type: ApplicationShortcutItemType
let title: String
let subtitle: String?
}
@available(iOS 9.1, *)
extension ApplicationShortcutItem {
func shortcutItem() -> UIApplicationShortcutItem {
let icon: UIApplicationShortcutIcon
switch self.type {
case .search:
icon = UIApplicationShortcutIcon(type: .search)
case .compose:
icon = UIApplicationShortcutIcon(type: .compose)
case .camera:
icon = UIApplicationShortcutIcon(templateImageName: "Shortcuts/Camera")
case .savedMessages:
icon = UIApplicationShortcutIcon(templateImageName: "Shortcuts/SavedMessages")
case .account:
icon = UIApplicationShortcutIcon(templateImageName: "Shortcuts/Account")
case .appIcon:
icon = UIApplicationShortcutIcon(templateImageName: "Shortcuts/AppIcon")
}
return UIApplicationShortcutItem(type: self.type.rawValue, localizedTitle: self.title, localizedSubtitle: self.subtitle, icon: icon, userInfo: nil)
}
}
func applicationShortcutItems(strings: PresentationStrings, otherAccountName: String?) -> [ApplicationShortcutItem] {
if let otherAccountName = otherAccountName {
return [
ApplicationShortcutItem(type: .search, title: strings.Common_Search, subtitle: nil),
ApplicationShortcutItem(type: .compose, title: strings.Compose_NewMessage, subtitle: nil),
ApplicationShortcutItem(type: .savedMessages, title: strings.Conversation_SavedMessages, subtitle: nil),
ApplicationShortcutItem(type: .account, title: strings.Shortcut_SwitchAccount, subtitle: otherAccountName)
]
} else {
return [
ApplicationShortcutItem(type: .search, title: strings.Common_Search, subtitle: nil),
ApplicationShortcutItem(type: .compose, title: strings.Compose_NewMessage, subtitle: nil),
ApplicationShortcutItem(type: .savedMessages, title: strings.Conversation_SavedMessages, subtitle: nil),
ApplicationShortcutItem(type: .appIcon, title: strings.Shortcut_AppIcon, subtitle: nil)
]
}
}
@@ -0,0 +1,55 @@
import Foundation
import AVFoundation
private func loadAudioRecordingToneData() -> Data? {
let outputSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM as NSNumber,
AVSampleRateKey: 44100.0 as NSNumber,
AVLinearPCMBitDepthKey: 16 as NSNumber,
AVLinearPCMIsNonInterleaved: false as NSNumber,
AVLinearPCMIsFloatKey: false as NSNumber,
AVLinearPCMIsBigEndianKey: false as NSNumber
]
guard let url = Bundle.main.url(forResource: "begin_record", withExtension: "mp3") else {
return nil
}
let asset = AVURLAsset(url: url)
guard let assetReader = try? AVAssetReader(asset: asset) else {
return nil
}
let readerOutput = AVAssetReaderAudioMixOutput(audioTracks: asset.tracks, audioSettings: outputSettings)
if !assetReader.canAdd(readerOutput) {
return nil
}
assetReader.add(readerOutput)
if !assetReader.startReading() {
return nil
}
var data = Data()
while assetReader.status == .reading {
if let nextBuffer = readerOutput.copyNextSampleBuffer() {
var abl = AudioBufferList()
var blockBuffer: CMBlockBuffer? = nil
CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(nextBuffer, bufferListSizeNeededOut: nil, bufferListOut: &abl, bufferListSize: MemoryLayout<AudioBufferList>.size, blockBufferAllocator: nil, blockBufferMemoryAllocator: nil, flags: kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment, blockBufferOut: &blockBuffer)
let size = Int(CMSampleBufferGetTotalSampleSize(nextBuffer))
if size != 0, let mData = abl.mBuffers.mData {
data.append(Data(bytes: mData, count: size))
}
} else {
break
}
}
return data
}
let audioRecordingToneData: Data? = loadAudioRecordingToneData()
@@ -0,0 +1,34 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import AccountContext
extension ChatControllerImpl {
func presentAccountFrozenInfoIfNeeded(delay: Bool = false) -> Bool {
if self.context.isFrozen {
let accountFreezeConfiguration = AccountFreezeConfiguration.with(appConfiguration: self.context.currentAppConfiguration.with { $0 })
if let freezeAppealUrl = accountFreezeConfiguration.freezeAppealUrl {
let components = freezeAppealUrl.components(separatedBy: "/")
if let username = components.last, let peer = self.presentationInterfaceState.renderedPeer?.peer, peer.addressName == username {
return false
}
}
let present = {
self.push(self.context.sharedContext.makeAccountFreezeInfoScreen(context: self.context))
}
if delay {
Queue.mainQueue().after(0.3) {
present()
}
} else {
present()
}
return true
}
return false
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,777 @@
import Foundation
import UIKit
import Postbox
import SwiftSignalKit
import Display
import AsyncDisplayKit
import TelegramCore
import SafariServices
import MobileCoreServices
import Intents
import LegacyComponents
import TelegramPresentationData
import TelegramUIPreferences
import DeviceAccess
import TextFormat
import TelegramBaseController
import AccountContext
import TelegramStringFormatting
import OverlayStatusController
import DeviceLocationManager
import ShareController
import UrlEscaping
import ContextUI
import ComposePollUI
import AlertUI
import PresentationDataUtils
import UndoUI
import TelegramCallsUI
import TelegramNotices
import GameUI
import ScreenCaptureDetection
import GalleryUI
import OpenInExternalAppUI
import LegacyUI
import InstantPageUI
import LocationUI
import BotPaymentsUI
import DeleteChatPeerActionSheetItem
import HashtagSearchUI
import LegacyMediaPickerUI
import Emoji
import PeerAvatarGalleryUI
import PeerInfoUI
import RaiseToListen
import UrlHandling
import AvatarNode
import AppBundle
import LocalizedPeerData
import PhoneNumberFormat
import SettingsUI
import UrlWhitelist
import TelegramIntents
import TooltipUI
import StatisticsUI
import MediaResources
import LocalMediaResources
import GalleryData
import ChatInterfaceState
import InviteLinksUI
import Markdown
import TelegramPermissionsUI
import Speak
import TranslateUI
import UniversalMediaPlayer
import WallpaperBackgroundNode
import ChatListUI
import CalendarMessageScreen
import ReactionSelectionNode
import ReactionListContextMenuContent
import AttachmentUI
import AttachmentTextInputPanelNode
import MediaPickerUI
import ChatPresentationInterfaceState
import Pasteboard
import ChatSendMessageActionUI
import ChatTextLinkEditUI
import WebUI
import PremiumUI
import ImageTransparency
import StickerPackPreviewUI
import TextNodeWithEntities
import EntityKeyboard
import ChatTitleView
import EmojiStatusComponent
import ChatTimerScreen
import MediaPasteboardUI
import ChatListHeaderComponent
import ChatControllerInteraction
import FeaturedStickersScreen
import ChatEntityKeyboardInputNode
import StorageUsageScreen
import AvatarEditorScreen
import ChatScheduleTimeController
import ICloudResources
import StoryContainerScreen
import MoreHeaderButton
import VolumeButtons
import ChatAvatarNavigationNode
import ChatContextQuery
import PeerReportScreen
import PeerSelectionController
import SaveToCameraRoll
import ChatMessageDateAndStatusNode
import ReplyAccessoryPanelNode
import TextSelectionNode
import ChatMessagePollBubbleContentNode
import ChatMessageItem
import ChatMessageItemImpl
import ChatMessageItemView
import ChatMessageItemCommon
import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen
import WallpaperGridScreen
import VideoMessageCameraScreen
import TopMessageReactions
import AudioWaveform
import PeerNameColorScreen
import ChatEmptyNode
import ChatMediaInputStickerGridItem
import AdsInfoScreen
extension ChatControllerImpl {
func requestAudioRecorder(beginWithTone: Bool, existingDraft: ChatInterfaceMediaDraftState.Audio? = nil) {
if self.audioRecorderValue == nil {
if self.recorderFeedback == nil && existingDraft == nil {
self.recorderFeedback = HapticFeedback()
self.recorderFeedback?.prepareImpact(.light)
}
var resumeData: AudioRecorderResumeData?
if let existingDraft, let path = self.context.account.postbox.mediaBox.completedResourcePath(existingDraft.resource), let compressedData = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.mappedIfSafe]), let recorderResumeData = existingDraft.resumeData {
resumeData = AudioRecorderResumeData(compressedData: compressedData, resumeData: recorderResumeData)
}
self.audioRecorder.set(
self.context.sharedContext.mediaManager.audioRecorder(
resumeData: resumeData,
beginWithTone: beginWithTone,
applicationBindings: self.context.sharedContext.applicationBindings,
beganWithTone: { _ in
}
)
)
}
}
func requestVideoRecorder() {
if self.videoRecorderValue == nil {
if let currentInputPanelFrame = self.chatDisplayNode.currentInputPanelFrame() {
if self.recorderFeedback == nil {
self.recorderFeedback = HapticFeedback()
self.recorderFeedback?.prepareImpact(.light)
}
var isScheduledMessages = false
if case .scheduledMessages = self.presentationInterfaceState.subject {
isScheduledMessages = true
}
var isBot = false
var allowLiveUpload = false
var viewOnceAvailable = false
if let peerId = self.chatLocation.peerId {
allowLiveUpload = peerId.namespace != Namespaces.Peer.SecretChat
viewOnceAvailable = !isScheduledMessages && peerId.namespace == Namespaces.Peer.CloudUser && peerId != self.context.account.peerId && !isBot && self.presentationInterfaceState.sendPaidMessageStars == nil
} else if case .customChatContents = self.chatLocation {
allowLiveUpload = true
}
if let user = self.presentationInterfaceState.renderedPeer?.peer as? TelegramUser, user.botInfo != nil {
isBot = true
}
let controller = VideoMessageCameraScreen(
context: self.context,
updatedPresentationData: self.updatedPresentationData,
allowLiveUpload: allowLiveUpload,
viewOnceAvailable: viewOnceAvailable,
inputPanelFrame: (currentInputPanelFrame, self.chatDisplayNode.inputNode != nil),
chatNode: self.chatDisplayNode.historyNode,
completion: { [weak self] message, silentPosting, scheduleTime in
guard let self, let videoController = self.videoRecorderValue else {
return
}
guard var message else {
self.recorderFeedback?.error()
self.recorderFeedback = nil
self.videoRecorder.set(.single(nil))
return
}
let replyMessageSubject = self.presentationInterfaceState.interfaceState.replyMessageSubject
let correlationId = Int64.random(in: 0 ..< Int64.max)
message = message
.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel)
.withUpdatedCorrelationId(correlationId)
var shouldAnimateMessageTransition = self.chatDisplayNode.shouldAnimateMessageTransition
if self.chatLocation.threadId == nil, let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum, let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = self.presentationInterfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect) {
shouldAnimateMessageTransition = false
}
var usedCorrelationId = false
if scheduleTime == nil, shouldAnimateMessageTransition, let extractedView = videoController.extractVideoSnapshot() {
usedCorrelationId = true
self.chatDisplayNode.messageTransitionNode.add(correlationId: correlationId, source: .videoMessage(ChatMessageTransitionNodeImpl.Source.VideoMessage(view: extractedView)), initiated: { [weak videoController, weak self] in
videoController?.hideVideoSnapshot()
guard let self else {
return
}
self.videoRecorder.set(.single(nil))
})
} else {
self.videoRecorder.set(.single(nil))
}
self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in
if let self {
self.chatDisplayNode.collapseInput()
self.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedMediaDraftState(nil).withUpdatedPostSuggestionState(nil) }
})
}
}, usedCorrelationId ? correlationId : nil)
let messages = [message]
let transformedMessages: [EnqueueMessage]
if let silentPosting {
transformedMessages = self.transformEnqueueMessages(messages, silentPosting: silentPosting)
} else if let scheduleTime {
transformedMessages = self.transformEnqueueMessages(messages, silentPosting: false, scheduleTime: scheduleTime)
} else {
transformedMessages = self.transformEnqueueMessages(messages)
}
self.sendMessages(transformedMessages)
}
)
controller.onResume = { [weak self] in
guard let self else {
return
}
self.resumeMediaRecorder()
}
self.videoRecorder.set(.single(controller))
}
}
}
func dismissMediaRecorder(_ action: ChatFinishMediaRecordingAction) {
var updatedAction = action
var isScheduledMessages = false
if case .scheduledMessages = self.presentationInterfaceState.subject {
isScheduledMessages = true
}
if let _ = self.presentationInterfaceState.slowmodeState, !isScheduledMessages {
updatedAction = .preview
}
var sendImmediately = false
if let _ = self.presentationInterfaceState.sendPaidMessageStars, case .send = action {
updatedAction = .preview
sendImmediately = true
}
if let audioRecorderValue = self.audioRecorderValue {
switch action {
case .pause:
audioRecorderValue.pause()
default:
audioRecorderValue.stop()
}
self.dismissAllTooltips()
switch updatedAction {
case .dismiss:
self.recorderDataDisposable.set(nil)
self.chatDisplayNode.updateRecordedMediaDeleted(true)
self.audioRecorder.set(.single(nil))
case .preview, .pause:
self.updateChatPresentationInterfaceState(animated: true, interactive: true, {
$0.updatedInputTextPanelState { panelState in
return panelState.withUpdatedMediaRecordingState(.waitingForPreview)
}
})
var resource: LocalFileMediaResource?
self.recorderDataDisposable.set(
(audioRecorderValue.takenRecordedData()
|> deliverOnMainQueue).startStrict(
next: { [weak self] data in
if let strongSelf = self, let data = data {
if data.duration < 0.5 {
strongSelf.recorderFeedback?.error()
strongSelf.recorderFeedback = nil
strongSelf.audioRecorder.set(.single(nil))
strongSelf.recorderDataDisposable.set(nil)
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, {
$0.updatedInputTextPanelState { panelState in
return panelState.withUpdatedMediaRecordingState(nil)
}
})
} else if let waveform = data.waveform {
if resource == nil {
resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max), size: Int64(data.compressedData.count))
strongSelf.context.account.postbox.mediaBox.storeResourceData(resource!.id, data: data.compressedData)
}
let audioWaveform: AudioWaveform
if let recordedMediaPreview = strongSelf.presentationInterfaceState.interfaceState.mediaDraftState, case let .audio(audio) = recordedMediaPreview {
audioWaveform = audio.waveform
} else {
audioWaveform = AudioWaveform(bitstream: waveform, bitsPerSample: 5)
}
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, {
$0.updatedInterfaceState {
$0.withUpdatedMediaDraftState(.audio(
ChatInterfaceMediaDraftState.Audio(
resource: resource!,
fileSize: Int32(data.compressedData.count),
duration: data.duration,
waveform: audioWaveform,
trimRange: data.trimRange,
resumeData: data.resumeData
)
))
}.updatedInputTextPanelState { panelState in
return panelState.withUpdatedMediaRecordingState(nil)
}
})
strongSelf.recorderFeedback = nil
strongSelf.updateDownButtonVisibility()
if sendImmediately {
strongSelf.interfaceInteraction?.sendRecordedMedia(false, false)
}
}
}
})
)
case let .send(viewOnce):
self.chatDisplayNode.updateRecordedMediaDeleted(false)
self.recorderDataDisposable.set((audioRecorderValue.takenRecordedData()
|> deliverOnMainQueue).startStrict(next: { [weak self] data in
if let strongSelf = self, let data = data {
if data.duration < 0.5 {
strongSelf.recorderFeedback?.error()
strongSelf.recorderFeedback = nil
strongSelf.audioRecorder.set(.single(nil))
strongSelf.recorderDataDisposable.set(nil)
} else {
let randomId = Int64.random(in: Int64.min ... Int64.max)
let resource = LocalFileMediaResource(fileId: randomId)
strongSelf.context.account.postbox.mediaBox.storeResourceData(resource.id, data: data.compressedData)
let waveformBuffer: Data? = data.waveform
let correlationId = Int64.random(in: 0 ..< Int64.max)
var usedCorrelationId = false
var shouldAnimateMessageTransition = strongSelf.chatDisplayNode.shouldAnimateMessageTransition
if strongSelf.chatLocation.threadId == nil, let channel = strongSelf.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum, let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = strongSelf.presentationInterfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect) {
shouldAnimateMessageTransition = false
}
if shouldAnimateMessageTransition, let textInputPanelNode = strongSelf.chatDisplayNode.textInputPanelNode, let micButton = textInputPanelNode.micButton {
usedCorrelationId = true
strongSelf.chatDisplayNode.messageTransitionNode.add(correlationId: correlationId, source: .audioMicInput(ChatMessageTransitionNodeImpl.Source.AudioMicInput(micButton: micButton)), initiated: {
guard let strongSelf = self else {
return
}
strongSelf.audioRecorder.set(.single(nil))
})
} else {
strongSelf.audioRecorder.set(.single(nil))
}
strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil) }
})
}
}, usedCorrelationId ? correlationId : nil)
var attributes: [MessageAttribute] = []
if viewOnce {
attributes.append(AutoremoveTimeoutMessageAttribute(timeout: viewOnceTimeout, countdownBeginTime: nil))
}
strongSelf.sendMessages([.message(text: "", attributes: attributes, inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(data.compressedData.count), attributes: [.Audio(isVoice: true, duration: Int(data.duration), title: nil, performer: nil, waveform: waveformBuffer)], alternativeRepresentations: [])), threadId: strongSelf.chatLocation.threadId, replyToMessageId: strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject?.subjectModel, replyToStoryId: nil, localGroupingKey: nil, correlationId: correlationId, bubbleUpEmojiOrStickersets: [])])
strongSelf.recorderFeedback?.tap()
strongSelf.recorderFeedback = nil
strongSelf.recorderDataDisposable.set(nil)
}
}
}))
}
} else if let videoRecorderValue = self.videoRecorderValue {
if case .send = updatedAction {
self.chatDisplayNode.updateRecordedMediaDeleted(false)
videoRecorderValue.sendVideoRecording()
self.recorderDataDisposable.set(nil)
} else {
if case .dismiss = updatedAction {
self.chatDisplayNode.updateRecordedMediaDeleted(true)
self.recorderDataDisposable.set(nil)
}
switch updatedAction {
case .preview, .pause:
if videoRecorderValue.stopVideoRecording() {
self.recorderDataDisposable.set((videoRecorderValue.takenRecordedData()
|> deliverOnMainQueue).startStrict(next: { [weak self] data in
if let strongSelf = self, let data = data {
if data.duration < 1.0 {
strongSelf.recorderFeedback?.error()
strongSelf.recorderFeedback = nil
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, {
$0.updatedInputTextPanelState { panelState in
return panelState.withUpdatedMediaRecordingState(nil)
}
})
strongSelf.recorderDataDisposable.set(nil)
strongSelf.videoRecorder.set(.single(nil))
} else {
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, {
$0.updatedInterfaceState {
$0.withUpdatedMediaDraftState(.video(
ChatInterfaceMediaDraftState.Video(
duration: data.duration,
frames: data.frames,
framesUpdateTimestamp: data.framesUpdateTimestamp,
trimRange: data.trimRange
)
))
}.updatedInputTextPanelState { panelState in
return panelState.withUpdatedMediaRecordingState(nil)
}
})
strongSelf.recorderFeedback = nil
strongSelf.updateDownButtonVisibility()
}
}
}))
}
default:
self.recorderDataDisposable.set(nil)
self.videoRecorder.set(.single(nil))
}
}
}
}
func stopMediaRecorder(pause: Bool = false) {
if let audioRecorderValue = self.audioRecorderValue {
if let _ = self.presentationInterfaceState.inputTextPanelState.mediaRecordingState {
self.dismissMediaRecorder(pause ? .pause : .preview)
} else {
audioRecorderValue.stop()
self.audioRecorder.set(.single(nil))
}
} else if let _ = self.videoRecorderValue {
if let _ = self.presentationInterfaceState.inputTextPanelState.mediaRecordingState {
self.dismissMediaRecorder(pause ? .pause : .preview)
} else {
self.videoRecorder.set(.single(nil))
}
}
}
func resumeMediaRecorder() {
self.recorderDataDisposable.set(nil)
self.context.sharedContext.mediaManager.playlistControl(.playback(.pause), type: nil)
if let videoRecorderValue = self.videoRecorderValue {
self.updateChatPresentationInterfaceState(animated: true, interactive: true, {
$0.updatedInputTextPanelState { panelState in
let recordingStatus = videoRecorderValue.recordingStatus
return panelState.withUpdatedMediaRecordingState(.video(status: .recording(InstantVideoControllerRecordingStatus(micLevel: recordingStatus.micLevel, duration: recordingStatus.duration)), isLocked: true))
}.updatedInterfaceState { $0.withUpdatedMediaDraftState(nil) }
})
} else {
let proceed = {
self.withAudioRecorder(resuming: true, { audioRecorder in
audioRecorder.resume()
self.updateChatPresentationInterfaceState(animated: true, interactive: true, {
$0.updatedInputTextPanelState { panelState in
return panelState.withUpdatedMediaRecordingState(.audio(recorder: audioRecorder, isLocked: true))
}.updatedInterfaceState { $0.withUpdatedMediaDraftState(nil) }
})
})
}
let _ = (ApplicationSpecificNotice.getVoiceMessagesResumeTrimWarning(accountManager: self.context.sharedContext.accountManager)
|> deliverOnMainQueue).start(next: { [weak self] count in
guard let self else {
return
}
if count > 0 {
proceed()
return
}
if let recordedMediaPreview = self.presentationInterfaceState.interfaceState.mediaDraftState, case let .audio(audio) = recordedMediaPreview, let trimRange = audio.trimRange, trimRange.lowerBound > 0.1 || trimRange.upperBound < audio.duration {
self.present(
textAlertController(
context: self.context,
title: self.presentationData.strings.Chat_TrimVoiceMessageToResume_Title,
text: self.presentationData.strings.Chat_TrimVoiceMessageToResume_Text,
actions: [
TextAlertAction(type: .genericAction, title: self.presentationData.strings.Common_Cancel, action: {}),
TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Chat_TrimVoiceMessageToResume_Proceed, action: {
proceed()
let _ = ApplicationSpecificNotice.incrementVoiceMessagesResumeTrimWarning(accountManager: self.context.sharedContext.accountManager).start()
})
]
), in: .window(.root)
)
} else {
proceed()
}
})
}
}
func lockMediaRecorder() {
if self.presentationInterfaceState.inputTextPanelState.mediaRecordingState != nil {
self.updateChatPresentationInterfaceState(animated: true, interactive: true, {
return $0.updatedInputTextPanelState { panelState in
return panelState.withUpdatedMediaRecordingState(panelState.mediaRecordingState?.withLocked(true))
}
})
}
if let _ = self.audioRecorderValue {
self.maybePresentAudioPauseTooltip()
} else if let videoRecorderValue = self.videoRecorderValue {
videoRecorderValue.lockVideoRecording()
}
}
func deleteMediaRecording() {
if let _ = self.audioRecorderValue {
self.audioRecorder.set(.single(nil))
} else if let _ = self.videoRecorderValue {
self.videoRecorder.set(.single(nil))
}
self.recorderDataDisposable.set(nil)
self.chatDisplayNode.updateRecordedMediaDeleted(true)
self.updateChatPresentationInterfaceState(animated: true, interactive: true, {
$0.updatedInterfaceState { $0.withUpdatedMediaDraftState(nil) }
})
self.updateDownButtonVisibility()
self.dismissAllTooltips()
}
private func maybePresentAudioPauseTooltip() {
let _ = (ApplicationSpecificNotice.getVoiceMessagesPauseSuggestion(accountManager: self.context.sharedContext.accountManager)
|> deliverOnMainQueue).startStandalone(next: { [weak self] pauseCounter in
guard let self else {
return
}
if pauseCounter >= 3 {
return
} else {
Queue.mainQueue().after(0.3) {
self.displayPauseTooltip(text: self.presentationData.strings.Chat_PauseVoiceMessageTooltip)
}
let _ = ApplicationSpecificNotice.incrementVoiceMessagesPauseSuggestion(accountManager: self.context.sharedContext.accountManager).startStandalone()
}
})
}
private func displayPauseTooltip(text: String) {
guard let layout = self.validLayout else {
return
}
self.dismissAllTooltips()
let insets = layout.insets(options: [.input])
var screenWidth = layout.size.width
if layout.metrics.isTablet {
if layout.size.height == layout.deviceMetrics.screenSize.width {
screenWidth = layout.deviceMetrics.screenSize.height
} else {
screenWidth = layout.deviceMetrics.screenSize.width
}
}
let location = CGRect(origin: CGPoint(x: screenWidth - layout.safeInsets.right - 50.0, y: layout.size.height - insets.bottom - 128.0), size: CGSize())
let tooltipController = TooltipScreen(
account: self.context.account,
sharedContext: self.context.sharedContext,
text: .markdown(text: text),
balancedTextLayout: true,
constrainWidth: 240.0,
style: .customBlur(UIColor(rgb: 0x18181a), 0.0),
arrowStyle: .small,
icon: nil,
location: .point(location, .right),
displayDuration: .default,
inset: 8.0,
cornerRadius: 8.0,
shouldDismissOnTouch: { _, _ in
return .ignore
}
)
self.present(tooltipController, in: .window(.root))
}
private func withAudioRecorder(resuming: Bool, _ f: (ManagedAudioRecorder) -> Void) {
if let audioRecorder = self.audioRecorderValue {
f(audioRecorder)
} else if let recordedMediaPreview = self.presentationInterfaceState.interfaceState.mediaDraftState, case let .audio(audio) = recordedMediaPreview {
self.requestAudioRecorder(beginWithTone: false, existingDraft: audio)
if let audioRecorder = self.audioRecorderValue {
f(audioRecorder)
if !resuming {
self.recorderDataDisposable.set(
(audioRecorder.takenRecordedData()
|> deliverOnMainQueue).startStrict(
next: { [weak self] data in
if let strongSelf = self, let data = data {
let audioWaveform = audio.waveform
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, {
$0.updatedInterfaceState {
$0.withUpdatedMediaDraftState(.audio(
ChatInterfaceMediaDraftState.Audio(
resource: audio.resource,
fileSize: Int32(data.compressedData.count),
duration: data.duration,
waveform: audioWaveform,
trimRange: data.trimRange,
resumeData: data.resumeData
)
))
}.updatedInputTextPanelState { panelState in
return panelState.withUpdatedMediaRecordingState(nil)
}
})
strongSelf.updateDownButtonVisibility()
}
})
)
}
}
}
}
func updateTrimRange(start: Double, end: Double, updatedEnd: Bool, apply: Bool) {
if let videoRecorder = self.videoRecorderValue {
videoRecorder.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply)
} else {
self.withAudioRecorder(resuming: false, { audioRecorder in
audioRecorder.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply)
})
}
}
func sendMediaRecording(
silentPosting: Bool? = nil,
scheduleTime: Int32? = nil,
repeatPeriod: Int32? = nil,
viewOnce: Bool = false,
messageEffect: ChatSendMessageEffect? = nil,
postpone: Bool = false
) {
self.chatDisplayNode.updateRecordedMediaDeleted(false)
guard let recordedMediaPreview = self.presentationInterfaceState.interfaceState.mediaDraftState else {
return
}
switch recordedMediaPreview {
case let .audio(audio):
self.audioRecorder.set(.single(nil))
var isScheduledMessages = false
if case .scheduledMessages = self.presentationInterfaceState.subject {
isScheduledMessages = true
}
if let _ = self.presentationInterfaceState.slowmodeState, !isScheduledMessages {
if let rect = self.chatDisplayNode.frameForInputActionButton() {
self.interfaceInteraction?.displaySlowmodeTooltip(self.chatDisplayNode.view, rect)
}
return
}
self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedMediaDraftState(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil) }
})
strongSelf.updateDownButtonVisibility()
}
}, nil)
var attributes: [MessageAttribute] = []
if viewOnce {
attributes.append(AutoremoveTimeoutMessageAttribute(timeout: viewOnceTimeout, countdownBeginTime: nil))
}
if let messageEffect {
attributes.append(EffectMessageAttribute(id: messageEffect.id))
}
let resource: TelegramMediaResource
var waveform = audio.waveform
var finalDuration: Int = Int(audio.duration)
if let trimRange = audio.trimRange, trimRange.lowerBound > 0.0 || trimRange.upperBound < Double(audio.duration) {
let randomId = Int64.random(in: Int64.min ... Int64.max)
let tempPath = NSTemporaryDirectory() + "\(Int64.random(in: 0 ..< .max)).ogg"
resource = LocalFileAudioMediaResource(randomId: randomId, path: tempPath, trimRange: trimRange)
self.context.account.postbox.mediaBox.moveResourceData(audio.resource.id, toTempPath: tempPath)
waveform = waveform.subwaveform(from: trimRange.lowerBound / Double(audio.duration), to: trimRange.upperBound / Double(audio.duration))
finalDuration = Int(trimRange.upperBound - trimRange.lowerBound)
} else {
resource = audio.resource
}
let waveformBuffer = waveform.makeBitstream()
let messages: [EnqueueMessage] = [.message(text: "", attributes: attributes, inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(audio.fileSize), attributes: [.Audio(isVoice: true, duration: finalDuration, title: nil, performer: nil, waveform: waveformBuffer)], alternativeRepresentations: [])), threadId: self.chatLocation.threadId, replyToMessageId: self.presentationInterfaceState.interfaceState.replyMessageSubject?.subjectModel, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]
let transformedMessages: [EnqueueMessage]
if let silentPosting = silentPosting {
transformedMessages = self.transformEnqueueMessages(messages, silentPosting: silentPosting, postpone: postpone)
} else if let scheduleTime = scheduleTime {
transformedMessages = self.transformEnqueueMessages(messages, silentPosting: false, scheduleTime: scheduleTime, repeatPeriod: repeatPeriod, postpone: postpone)
} else {
transformedMessages = self.transformEnqueueMessages(messages)
}
guard let peerId = self.chatLocation.peerId else {
return
}
let _ = (enqueueMessages(account: self.context.account, peerId: peerId, messages: transformedMessages)
|> deliverOnMainQueue).startStandalone(next: { [weak self] _ in
if let strongSelf = self, strongSelf.presentationInterfaceState.subject != .scheduledMessages {
strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory()
}
})
donateSendMessageIntent(account: self.context.account, sharedContext: self.context.sharedContext, intentContext: .chat, peerIds: [peerId])
case .video:
self.videoRecorderValue?.sendVideoRecording(silentPosting: silentPosting, scheduleTime: scheduleTime, messageEffect: messageEffect)
}
}
}
@@ -0,0 +1,586 @@
import Foundation
import UIKit
import Postbox
import TelegramCore
import SwiftSignalKit
import Display
import ChatPresentationInterfaceState
import AccountContext
import ChatControllerInteraction
import OverlayStatusController
import TelegramPresentationData
import PresentationDataUtils
import UndoUI
extension ChatControllerImpl {
func navigateToMessage(
fromId: MessageId,
id: MessageId,
params: NavigateToMessageParams
) {
var id = id
if case let .replyThread(message) = self.chatLocation, let effectiveMessageId = message.effectiveMessageId {
if let channelMessageId = message.channelMessageId, id == channelMessageId {
id = effectiveMessageId
}
}
let continueNavigation: () -> Void = { [weak self] in
guard let self else {
return
}
self.navigateToMessage(from: fromId, to: .id(id, params), forceInCurrentChat: fromId.peerId == id.peerId && !params.forceNew, forceNew: params.forceNew, progress: params.progress)
}
let _ = (self.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: id.peerId)
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] toPeer in
guard let self else {
return
}
if params.quote != nil {
if let toPeer {
switch toPeer {
case let .channel(channel):
if channel.username == nil && channel.usernames.isEmpty {
switch channel.participationStatus {
case .kicked, .left:
self.controllerInteraction?.attemptedNavigationToPrivateQuote(toPeer._asPeer())
return
case .member:
break
}
}
default:
break
}
} else {
self.controllerInteraction?.attemptedNavigationToPrivateQuote(nil)
return
}
}
continueNavigation()
})
}
func navigateToMessage(
from fromId: MessageId?,
to messageLocation: NavigateToMessageLocation,
scrollPosition: ListViewScrollPosition = .center(.bottom),
rememberInStack: Bool = true,
forceInCurrentChat: Bool = false,
forceNew: Bool = false,
dropStack: Bool = false,
animated: Bool = true,
completion: (() -> Void)? = nil,
customPresentProgress: ((ViewController, Any?) -> Void)? = nil,
progress: Promise<Bool>? = nil,
statusSubject: ChatLoadingMessageSubject = .generic
) {
if !self.isNodeLoaded {
completion?()
return
}
var fromIndex: MessageIndex?
var fromMessage: Message?
if let fromId = fromId, let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(fromId) {
fromIndex = message.index
fromMessage = message
} else {
if let message = self.chatDisplayNode.historyNode.anchorMessageInCurrentHistoryView() {
fromIndex = message.index
}
}
var isScheduledMessages = false
var isPinnedMessages = false
if case .scheduledMessages = self.presentationInterfaceState.subject {
isScheduledMessages = true
} else if case .pinnedMessages = self.presentationInterfaceState.subject {
isPinnedMessages = true
}
var forceInCurrentChat = forceInCurrentChat
if case let .peer(peerId) = self.chatLocation, messageLocation.peerId == peerId, !isPinnedMessages, !isScheduledMessages {
forceInCurrentChat = true
}
if case .customChatContents = self.chatLocation, !forceNew {
forceInCurrentChat = true
}
if isPinnedMessages || forceNew, let messageId = messageLocation.messageId {
let peerSignal: Signal<EnginePeer?, NoError>
if forceNew, let fromMessage, let peer = fromMessage.peers[fromMessage.id.peerId] {
peerSignal = .single(EnginePeer(peer))
} else {
peerSignal = self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId))
}
let _ = (combineLatest(
peerSignal,
self.context.engine.messages.getMessagesLoadIfNecessary([messageId], strategy: forceNew ? .cloud(skipLocal: false) : .local)
|> `catch` { _ in
return .single(.result([]))
}
|> mapToSignal { result -> Signal<[Message], NoError> in
guard case let .result(result) = result else {
return .complete()
}
return .single(result)
}
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] peer, messages in
guard let self, let peer = peer else {
return
}
guard let navigationController = self.effectiveNavigationController else {
return
}
self.dismiss()
let navigateToLocation: NavigateToChatControllerParams.Location
if let message = messages.first, let threadId = message.threadId, let channel = message.peers[message.id.peerId] as? TelegramChannel, channel.isForumOrMonoForum {
navigateToLocation = .replyThread(ChatReplyThreadMessage(peerId: peer.id, threadId: threadId, channelMessageId: nil, isChannelPost: false, isForumPost: true, isMonoforumPost: false,maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false))
} else {
navigateToLocation = .peer(peer)
}
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: navigateToLocation, subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false), keepStack: .always))
completion?()
})
} else if case let .peer(peerId) = self.chatLocation, let messageId = messageLocation.messageId, (messageId.peerId != peerId && !forceInCurrentChat) || (isScheduledMessages && messageId.id != 0 && !Namespaces.Message.allNonRegular.contains(messageId.namespace)) {
let _ = (self.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId),
TelegramEngine.EngineData.Item.Messages.Message(id: messageId)
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] peer, message in
guard let self, let peer = peer else {
return
}
var quote: ChatControllerSubject.MessageHighlight.Quote?
if case let .id(_, params) = messageLocation {
quote = params.quote.flatMap { quote in ChatControllerSubject.MessageHighlight.Quote(string: quote.string, offset: quote.offset) }
}
var progressValue: Promise<Bool>?
if let value = progress {
progressValue = value
} else if case let .id(_, params) = messageLocation {
progressValue = params.progress
}
self.loadingMessage.set(.single(statusSubject) |> delay(0.1, queue: .mainQueue()))
var chatLocation: NavigateToChatControllerParams.Location = .peer(peer)
var preloadChatLocation: ChatLocation = .peer(id: peer.id)
var displayMessageNotFoundToast = false
if case let .channel(channel) = peer, channel.isForumOrMonoForum {
if let message = message, let threadId = message.threadId {
let replyThreadMessage = ChatReplyThreadMessage(peerId: peer.id, threadId: threadId, channelMessageId: nil, isChannelPost: false, isForumPost: true, isMonoforumPost: false, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false)
chatLocation = .replyThread(replyThreadMessage)
preloadChatLocation = .replyThread(message: replyThreadMessage)
} else {
displayMessageNotFoundToast = true
}
}
let searchLocation: ChatHistoryInitialSearchLocation
switch messageLocation {
case let .id(id, _):
if case let .replyThread(message) = chatLocation, id == message.effectiveMessageId {
searchLocation = .index(.absoluteLowerBound())
} else {
searchLocation = .id(id)
}
case let .index(index):
searchLocation = .index(index)
case .upperBound:
searchLocation = .index(MessageIndex.upperBound(peerId: chatLocation.peerId))
}
var historyView: Signal<ChatHistoryViewUpdate, NoError>
let subject: ChatControllerSubject = .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: quote), timecode: nil, setupReply: false)
historyView = preloadedChatHistoryViewForLocation(ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: searchLocation), count: 50, highlight: true, setupReply: false), id: 0), context: self.context, chatLocation: preloadChatLocation, subject: subject, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>(value: nil), fixedCombinedReadStates: nil, tag: nil, additionalData: [])
var signal: Signal<(MessageIndex?, Bool), NoError>
signal = historyView
|> mapToSignal { historyView -> Signal<(MessageIndex?, Bool), NoError> in
switch historyView {
case .Loading:
return .single((nil, true))
case let .HistoryView(view, _, _, _, _, _, _):
for entry in view.entries {
if entry.message.id == messageLocation.messageId {
return .single((entry.message.index, false))
}
}
if case let .index(index) = searchLocation {
return .single((index, false))
}
return .single((nil, false))
}
}
|> take(until: { index in
return SignalTakeAction(passthrough: true, complete: !index.1)
})
/*#if DEBUG
signal = .single((nil, true)) |> then(signal |> delay(2.0, queue: .mainQueue()))
#endif*/
var cancelImpl: (() -> Void)?
let presentationData = self.presentationData
let displayTime = CACurrentMediaTime()
let progressSignal = Signal<Never, NoError> { [weak self] subscriber in
if let progressValue {
progressValue.set(.single(true))
return ActionDisposable {
Queue.mainQueue().async() {
progressValue.set(.single(false))
}
}
} else if case .generic = statusSubject {
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
if CACurrentMediaTime() - displayTime > 1.5 {
cancelImpl?()
}
}))
if let customPresentProgress = customPresentProgress {
customPresentProgress(controller, nil)
} else {
self?.present(controller, in: .window(.root))
}
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
} else {
return EmptyDisposable
}
}
|> runOn(Queue.mainQueue())
|> delay(progressValue == nil ? 0.05 : 0.0, queue: Queue.mainQueue())
let progressDisposable = MetaDisposable()
var progressStarted = false
self.messageIndexDisposable.set((signal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
|> deliverOnMainQueue).startStrict(next: { [weak self] index in
guard let self else {
return
}
if let index = index.0 {
let _ = index
//strongSelf.chatDisplayNode.historyNode.scrollToMessage(from: scrollFromIndex, to: index, animated: animated, quote: quote, scrollPosition: scrollPosition)
} else if index.1 {
if !progressStarted {
progressStarted = true
progressDisposable.set(progressSignal.start())
}
return
}
if let navigationController = self.effectiveNavigationController {
let context = self.context
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: chatLocation, subject: subject, keepStack: .always, chatListCompletion: { chatListController in
if displayMessageNotFoundToast {
let presentationData = context.sharedContext.currentPresentationData.with({ $0 })
chatListController.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.Conversation_MessageDoesntExist, timeout: nil, customUndoText: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in
return true
}), in: .current)
}
}))
}
}, completed: { [weak self] in
if let strongSelf = self {
strongSelf.loadingMessage.set(.single(nil))
}
completion?()
}))
cancelImpl = { [weak self] in
if let strongSelf = self {
strongSelf.loadingMessage.set(.single(nil))
strongSelf.messageIndexDisposable.set(nil)
}
}
completion?()
})
} else if forceInCurrentChat {
if let _ = fromId, let fromIndex = fromIndex, rememberInStack {
self.contentData?.historyNavigationStack.add(fromIndex)
}
let scrollFromIndex: MessageIndex?
if let fromIndex = fromIndex {
scrollFromIndex = fromIndex
} else if let message = self.chatDisplayNode.historyNode.lastVisbleMesssage() {
scrollFromIndex = message.index
} else {
scrollFromIndex = nil
}
if let scrollFromIndex = scrollFromIndex {
if let messageId = messageLocation.messageId, let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) {
self.loadingMessage.set(.single(nil))
self.messageIndexDisposable.set(nil)
var delayCompletion = true
if self.chatDisplayNode.historyNode.isMessageVisible(id: messageId) {
delayCompletion = false
}
var quote: (string: String, offset: Int?)?
var todoTaskId: Int32?
var setupReply = false
if case let .id(_, params) = messageLocation {
quote = params.quote.flatMap { quote in (string: quote.string, offset: quote.offset) }
setupReply = params.setupReply
todoTaskId = params.todoTaskId
}
self.chatDisplayNode.historyNode.scrollToMessage(from: scrollFromIndex, to: message.index, animated: animated, quote: quote, todoTaskId: todoTaskId, scrollPosition: scrollPosition, setupReply: setupReply)
if delayCompletion {
Queue.mainQueue().after(0.25, {
completion?()
})
} else {
Queue.mainQueue().justDispatch({
completion?()
})
}
if case let .id(_, params) = messageLocation, let timecode = params.timestamp {
let _ = self.controllerInteraction?.openMessage(message, OpenMessageParams(mode: .timecode(timecode)))
}
} else if case let .index(index) = messageLocation, index.id.id == 0, index.timestamp > 0, case .scheduledMessages = self.presentationInterfaceState.subject {
self.chatDisplayNode.historyNode.scrollToMessage(from: scrollFromIndex, to: index, animated: animated, scrollPosition: scrollPosition)
} else {
var setupReply = false
var quote: (string: String, offset: Int?)?
if case let .id(messageId, params) = messageLocation {
if params.timestamp != nil {
self.scheduledScrollToMessageId = (messageId, params)
}
quote = params.quote.flatMap { ($0.string, $0.offset) }
setupReply = params.setupReply
}
var progress: Promise<Bool>?
if case let .id(_, params) = messageLocation {
progress = params.progress
}
self.loadingMessage.set(.single(statusSubject) |> delay(0.1, queue: .mainQueue()))
let searchLocation: ChatHistoryInitialSearchLocation
switch messageLocation {
case let .id(id, _):
if case let .replyThread(message) = self.chatLocation, id == message.effectiveMessageId {
searchLocation = .index(.absoluteLowerBound())
} else {
searchLocation = .id(id)
}
case let .index(index):
searchLocation = .index(index)
case .upperBound:
if let peerId = self.chatLocation.peerId {
searchLocation = .index(MessageIndex.upperBound(peerId: peerId))
} else {
searchLocation = .index(.absoluteUpperBound())
}
}
var historyView: Signal<ChatHistoryViewUpdate, NoError>
historyView = preloadedChatHistoryViewForLocation(ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: searchLocation), count: 50, highlight: true, setupReply: setupReply), id: 0), context: self.context, chatLocation: self.chatLocation, subject: self.subject, chatLocationContextHolder: self.chatLocationContextHolder, fixedCombinedReadStates: nil, tag: nil, additionalData: [])
var signal: Signal<(MessageIndex?, Bool), NoError>
signal = historyView
|> mapToSignal { historyView -> Signal<(MessageIndex?, Bool), NoError> in
switch historyView {
case .Loading:
return .single((nil, true))
case let .HistoryView(view, _, _, _, _, _, _):
for entry in view.entries {
if entry.message.id == messageLocation.messageId {
return .single((entry.message.index, false))
}
}
if case let .index(index) = searchLocation {
return .single((index, false))
}
return .single((nil, false))
}
}
|> take(until: { index in
return SignalTakeAction(passthrough: true, complete: !index.1)
})
/*#if DEBUG
signal = .single((nil, true)) |> then(signal |> delay(2.0, queue: .mainQueue()))
#endif*/
var cancelImpl: (() -> Void)?
let presentationData = self.presentationData
let displayTime = CACurrentMediaTime()
let progressSignal = Signal<Never, NoError> { [weak self] subscriber in
if let progress {
progress.set(.single(true))
return ActionDisposable {
Queue.mainQueue().async() {
progress.set(.single(false))
}
}
} else if case .generic = statusSubject {
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
if CACurrentMediaTime() - displayTime > 1.5 {
cancelImpl?()
}
}))
if let customPresentProgress = customPresentProgress {
customPresentProgress(controller, nil)
} else {
self?.present(controller, in: .window(.root))
}
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
} else {
return EmptyDisposable
}
}
|> runOn(Queue.mainQueue())
|> delay(0.05, queue: Queue.mainQueue())
let progressDisposable = MetaDisposable()
var progressStarted = false
self.messageIndexDisposable.set((signal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
|> deliverOnMainQueue).startStrict(next: { [weak self] index in
if let strongSelf = self, let index = index.0 {
strongSelf.chatDisplayNode.historyNode.scrollToMessage(from: scrollFromIndex, to: index, animated: animated, quote: quote, scrollPosition: scrollPosition, setupReply: setupReply)
} else if index.1 {
if !progressStarted {
progressStarted = true
progressDisposable.set(progressSignal.start())
}
} else if let strongSelf = self {
strongSelf.controllerInteraction?.displayUndo(.info(title: nil, text: strongSelf.presentationData.strings.Conversation_MessageDoesntExist, timeout: nil, customUndoText: nil))
}
}, completed: { [weak self] in
if let strongSelf = self {
strongSelf.loadingMessage.set(.single(nil))
}
completion?()
}))
cancelImpl = { [weak self] in
if let strongSelf = self {
strongSelf.loadingMessage.set(.single(nil))
strongSelf.messageIndexDisposable.set(nil)
}
}
}
} else {
completion?()
}
} else {
if let fromIndex = fromIndex {
let searchLocation: ChatHistoryInitialSearchLocation
switch messageLocation {
case let .id(id, _):
searchLocation = .id(id)
case let .index(index):
searchLocation = .index(index)
case .upperBound:
return
}
if let _ = fromId, rememberInStack {
self.contentData?.historyNavigationStack.add(fromIndex)
}
self.loadingMessage.set(.single(statusSubject) |> delay(0.1, queue: .mainQueue()))
var quote: ChatControllerSubject.MessageHighlight.Quote?
var todoTaskId: Int32?
var setupReply = false
if case let .id(_, params) = messageLocation {
quote = params.quote.flatMap { quote in ChatControllerSubject.MessageHighlight.Quote(string: quote.string, offset: quote.offset) }
todoTaskId = params.todoTaskId
setupReply = params.setupReply
}
let historyView = preloadedChatHistoryViewForLocation(ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: searchLocation, quote: quote.flatMap { quote in MessageHistoryInitialSearchSubject.Quote(string: quote.string, offset: quote.offset) }, todoTaskId: todoTaskId), count: 50, highlight: true, setupReply: setupReply), id: 0), context: self.context, chatLocation: self.chatLocation, subject: self.subject, chatLocationContextHolder: self.chatLocationContextHolder, fixedCombinedReadStates: nil, tag: nil, additionalData: [])
var signal: Signal<MessageIndex?, NoError>
signal = historyView
|> mapToSignal { historyView -> Signal<MessageIndex?, NoError> in
switch historyView {
case .Loading:
return .complete()
case let .HistoryView(view, _, _, _, _, _, _):
for entry in view.entries {
if entry.message.id == messageLocation.messageId {
return .single(entry.message.index)
}
}
return .single(nil)
}
}
|> take(1)
self.messageIndexDisposable.set((signal |> deliverOnMainQueue).startStrict(next: { [weak self] index in
if let strongSelf = self {
if let index = index {
strongSelf.chatDisplayNode.historyNode.scrollToMessage(from: fromIndex, to: index, animated: animated, scrollPosition: scrollPosition)
completion?()
} else {
let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: messageLocation.peerId))
|> deliverOnMainQueue).startStandalone(next: { peer in
guard let strongSelf = self, let peer = peer else {
return
}
if let navigationController = strongSelf.effectiveNavigationController {
var quote: ChatControllerSubject.MessageHighlight.Quote?
var setupReply = false
if case let .id(_, params) = messageLocation {
quote = params.quote.flatMap { quote in ChatControllerSubject.MessageHighlight.Quote(string: quote.string, offset: quote.offset) }
setupReply = params.setupReply
}
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), subject: messageLocation.messageId.flatMap { .message(id: .id($0), highlight: ChatControllerSubject.MessageHighlight(quote: quote), timecode: nil, setupReply: setupReply) }, keepStack: .always))
}
})
completion?()
}
}
}, completed: { [weak self] in
if let strongSelf = self {
strongSelf.loadingMessage.set(.single(nil))
}
}))
} else {
let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: messageLocation.peerId))
|> deliverOnMainQueue).startStandalone(next: { [weak self] peer in
guard let self, let peer = peer else {
return
}
if let navigationController = self.effectiveNavigationController {
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), subject: messageLocation.messageId.flatMap { .message(id: .id($0), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false) }))
}
completion?()
})
}
}
}
}
@@ -0,0 +1,705 @@
import Foundation
import UIKit
import Postbox
import SwiftSignalKit
import Display
import AsyncDisplayKit
import TelegramCore
import SafariServices
import MobileCoreServices
import Intents
import LegacyComponents
import TelegramPresentationData
import TelegramUIPreferences
import DeviceAccess
import TextFormat
import TelegramBaseController
import AccountContext
import TelegramStringFormatting
import OverlayStatusController
import DeviceLocationManager
import ShareController
import UrlEscaping
import ContextUI
import ComposePollUI
import AlertUI
import PresentationDataUtils
import UndoUI
import TelegramCallsUI
import TelegramNotices
import GameUI
import ScreenCaptureDetection
import GalleryUI
import OpenInExternalAppUI
import LegacyUI
import InstantPageUI
import LocationUI
import BotPaymentsUI
import DeleteChatPeerActionSheetItem
import HashtagSearchUI
import LegacyMediaPickerUI
import Emoji
import PeerAvatarGalleryUI
import PeerInfoUI
import RaiseToListen
import UrlHandling
import AvatarNode
import AppBundle
import LocalizedPeerData
import PhoneNumberFormat
import SettingsUI
import UrlWhitelist
import TelegramIntents
import TooltipUI
import StatisticsUI
import MediaResources
import GalleryData
import ChatInterfaceState
import InviteLinksUI
import Markdown
import TelegramPermissionsUI
import Speak
import TranslateUI
import UniversalMediaPlayer
import WallpaperBackgroundNode
import ChatListUI
import CalendarMessageScreen
import ReactionSelectionNode
import ReactionListContextMenuContent
import AttachmentUI
import AttachmentTextInputPanelNode
import MediaPickerUI
import ChatPresentationInterfaceState
import Pasteboard
import ChatSendMessageActionUI
import ChatTextLinkEditUI
import WebUI
import PremiumUI
import ImageTransparency
import StickerPackPreviewUI
import TextNodeWithEntities
import EntityKeyboard
import ChatTitleView
import EmojiStatusComponent
import ChatTimerScreen
import MediaPasteboardUI
import ChatListHeaderComponent
import ChatControllerInteraction
import FeaturedStickersScreen
import ChatEntityKeyboardInputNode
import StorageUsageScreen
import AvatarEditorScreen
import ChatScheduleTimeController
import ICloudResources
import StoryContainerScreen
import MoreHeaderButton
import VolumeButtons
import ChatAvatarNavigationNode
import ChatContextQuery
import PeerReportScreen
import PeerSelectionController
import SaveToCameraRoll
import ChatMessageDateAndStatusNode
import ReplyAccessoryPanelNode
import TextSelectionNode
import ChatMessagePollBubbleContentNode
import ChatMessageItem
import ChatMessageItemImpl
import ChatMessageItemView
import ChatMessageItemCommon
import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen
import WallpaperGridScreen
import VideoMessageCameraScreen
import TopMessageReactions
import AudioWaveform
import PeerNameColorScreen
import ChatEmptyNode
import ChatMediaInputStickerGridItem
import AdsInfoScreen
extension ChatControllerImpl {
func navigationButtonAction(_ action: ChatNavigationButtonAction) {
switch action {
case .spacer, .toggleInfoPanel:
break
case .cancelMessageSelection:
self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } })
case .clearHistory:
guard !self.presentAccountFrozenInfoIfNeeded() else {
return
}
if case let .peer(peerId) = self.chatLocation {
let beginClear: (InteractiveHistoryClearingType) -> Void = { [weak self] type in
self?.beginClearHistory(type: type)
}
let context = self.context
let _ = (self.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.ParticipantCount(id: peerId),
TelegramEngine.EngineData.Item.Peer.CanDeleteHistory(id: peerId)
)
|> map { participantCount, canDeleteHistory -> (isLargeGroupOrChannel: Bool, canClearChannel: Bool) in
if let participantCount = participantCount {
return (participantCount > 1000, canDeleteHistory)
} else {
return (false, false)
}
}
|> deliverOnMainQueue).startStandalone(next: { [weak self] parameters in
guard let strongSelf = self else {
return
}
let (isLargeGroupOrChannel, canClearChannel) = parameters
guard let peer = strongSelf.presentationInterfaceState.renderedPeer, let chatPeer = peer.peers[peer.peerId], let mainPeer = peer.chatMainPeer else {
return
}
enum ClearType {
case savedMessages
case secretChat
case group
case channel
case user
}
let canClearCache: Bool
let canClearForMyself: ClearType?
let canClearForEveryone: ClearType?
if peerId == strongSelf.context.account.peerId {
canClearCache = false
canClearForMyself = .savedMessages
canClearForEveryone = nil
} else if chatPeer is TelegramSecretChat {
canClearCache = false
canClearForMyself = .secretChat
canClearForEveryone = nil
} else if let group = chatPeer as? TelegramGroup {
canClearCache = false
switch group.role {
case .creator:
canClearForMyself = .group
canClearForEveryone = nil
case .admin, .member:
canClearForMyself = .group
canClearForEveryone = nil
}
} else if let channel = chatPeer as? TelegramChannel {
if let username = channel.addressName, !username.isEmpty {
if isLargeGroupOrChannel {
canClearCache = true
canClearForMyself = nil
canClearForEveryone = canClearChannel ? .channel : nil
} else {
canClearCache = true
canClearForMyself = nil
switch channel.info {
case .broadcast:
if channel.flags.contains(.isCreator) {
canClearForEveryone = canClearChannel ? .channel : nil
} else {
canClearForEveryone = canClearChannel ? .channel : nil
}
case .group:
if channel.flags.contains(.isCreator) {
canClearForEveryone = canClearChannel ? .channel : nil
} else {
canClearForEveryone = canClearChannel ? .channel : nil
}
}
}
} else {
if isLargeGroupOrChannel {
switch channel.info {
case .broadcast:
canClearCache = true
canClearForMyself = .channel
canClearForEveryone = nil
case .group:
canClearCache = false
canClearForMyself = .channel
canClearForEveryone = nil
}
} else {
switch channel.info {
case .broadcast:
canClearCache = true
if channel.flags.contains(.isCreator) {
canClearForMyself = .channel
canClearForEveryone = nil
} else {
canClearForMyself = .channel
canClearForEveryone = nil
}
case .group:
canClearCache = false
if channel.flags.contains(.isCreator) {
canClearForMyself = .group
canClearForEveryone = nil
} else {
canClearForMyself = .group
canClearForEveryone = nil
}
}
}
}
} else {
canClearCache = false
canClearForMyself = .user
if let user = chatPeer as? TelegramUser, user.botInfo != nil {
canClearForEveryone = nil
} else {
canClearForEveryone = .user
}
}
let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData)
var items: [ActionSheetItem] = []
if case .scheduledMessages = strongSelf.presentationInterfaceState.subject {
items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.ScheduledMessages_ClearAllConfirmation, color: .destructive, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
guard let strongSelf = self else {
return
}
strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: strongSelf.presentationData.strings.ChatList_DeleteSavedMessagesConfirmationTitle, text: strongSelf.presentationData.strings.ChatList_DeleteSavedMessagesConfirmationText, actions: [
TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {
}),
TextAlertAction(type: .destructiveAction, title: strongSelf.presentationData.strings.ChatList_DeleteSavedMessagesConfirmationAction, action: {
beginClear(.scheduledMessages)
})
], parseMarkdown: true), in: .window(.root))
}))
} else {
if let _ = canClearForMyself ?? canClearForEveryone {
items.append(DeleteChatPeerActionSheetItem(context: strongSelf.context, peer: EnginePeer(mainPeer), chatPeer: EnginePeer(chatPeer), action: .clearHistory(canClearCache: canClearCache), strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder))
if let canClearForEveryone = canClearForEveryone {
let text: String
let confirmationText: String
switch canClearForEveryone {
case .user:
text = strongSelf.presentationData.strings.ChatList_DeleteForEveryone(EnginePeer(mainPeer).compactDisplayTitle).string
confirmationText = strongSelf.presentationData.strings.ChatList_DeleteForEveryoneConfirmationText
default:
text = strongSelf.presentationData.strings.Conversation_DeleteMessagesForEveryone
confirmationText = strongSelf.presentationData.strings.ChatList_DeleteForAllMembersConfirmationText
}
items.append(ActionSheetButtonItem(title: text, color: .destructive, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
guard let strongSelf = self else {
return
}
strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: strongSelf.presentationData.strings.ChatList_DeleteForEveryoneConfirmationTitle, text: confirmationText, actions: [
TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {
}),
TextAlertAction(type: .destructiveAction, title: strongSelf.presentationData.strings.ChatList_DeleteForEveryoneConfirmationAction, action: {
beginClear(.forEveryone)
})
], parseMarkdown: true), in: .window(.root))
}))
}
if let canClearForMyself = canClearForMyself {
let text: String
switch canClearForMyself {
case .savedMessages, .secretChat:
text = strongSelf.presentationData.strings.Conversation_ClearAll
default:
text = strongSelf.presentationData.strings.ChatList_DeleteForCurrentUser
}
items.append(ActionSheetButtonItem(title: text, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
if mainPeer.id == context.account.peerId, let strongSelf = self {
strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: strongSelf.presentationData.strings.ChatList_DeleteSavedMessagesConfirmationTitle, text: strongSelf.presentationData.strings.ChatList_DeleteSavedMessagesConfirmationText, actions: [
TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {
}),
TextAlertAction(type: .destructiveAction, title: strongSelf.presentationData.strings.ChatList_DeleteSavedMessagesConfirmationAction, action: {
beginClear(.forLocalPeer)
})
], parseMarkdown: true), in: .window(.root))
} else {
beginClear(.forLocalPeer)
}
}))
}
}
if canClearCache {
items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.Conversation_ClearCache, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
guard let strongSelf = self else {
return
}
strongSelf.navigationButtonAction(.clearCache)
}))
}
if chatPeer.canSetupAutoremoveTimeout(accountPeerId: strongSelf.context.account.peerId) {
items.append(ActionSheetButtonItem(title: strongSelf.presentationInterfaceState.autoremoveTimeout == nil ? strongSelf.presentationData.strings.Conversation_AutoremoveActionEnable : strongSelf.presentationData.strings.Conversation_AutoremoveActionEdit, color: .accent, action: { [weak actionSheet] in
guard let actionSheet = actionSheet else {
return
}
guard let strongSelf = self else {
return
}
actionSheet.dismissAnimated()
strongSelf.presentAutoremoveSetup()
}))
}
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
strongSelf.chatDisplayNode.dismissInput()
strongSelf.present(actionSheet, in: .window(.root))
})
}
case let .openChatInfo(expandAvatar, section):
let _ = self.presentVoiceMessageDiscardAlert(action: { [weak self] in
guard let self else {
return
}
guard let contentData = self.contentData else {
return
}
switch contentData.chatLocationInfoData {
case let .peer(peerView):
self.navigationActionDisposable.set((peerView.get()
|> take(1)
|> deliverOnMainQueue).startStrict(next: { [weak self] peerView in
guard let self else {
return
}
guard var peer = peerView.peers[peerView.peerId] else {
return
}
if let channel = peer as? TelegramChannel, channel.isMonoForum, let linkedMonoforumId = channel.linkedMonoforumId, let mainPeer = peerView.peers[linkedMonoforumId] as? TelegramChannel {
peer = mainPeer
guard let navigationController = self.effectiveNavigationController else {
return
}
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(
navigationController: navigationController,
context: self.context,
chatLocation: .peer(.channel(mainPeer)),
chatLocationContextHolder: Atomic(value: nil),
keepStack: .always,
useExisting: false,
animated: true
))
return
}
if peer.restrictionText(platform: "ios", contentSettings: self.context.currentContentSettings.with { $0 }) == nil && !self.presentationInterfaceState.isNotAccessible {
if peer.id == self.context.account.peerId {
if let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer, let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: true, requestsContext: nil) {
self.effectiveNavigationController?.pushViewController(infoController)
}
} else {
var expandAvatar = expandAvatar
if peer.smallProfileImage == nil {
expandAvatar = false
}
if let validLayout = self.validLayout, validLayout.deviceMetrics.type == .tablet {
expandAvatar = false
}
let mode: PeerInfoControllerMode
switch section {
case .groupsInCommon:
mode = .groupsInCommon
case .recommendedChannels:
mode = .recommendedChannels
default:
mode = .generic
}
if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: peer, mode: mode, avatarInitiallyExpanded: expandAvatar, fromChat: true, requestsContext: self.contentData?.inviteRequestsContext) {
self.effectiveNavigationController?.pushViewController(infoController)
}
}
let _ = self.dismissPreviewing?(false)
}
}))
case .replyThread:
if let peer = self.presentationInterfaceState.renderedPeer?.peer, case let .replyThread(replyThreadMessage) = self.chatLocation, replyThreadMessage.peerId == self.context.account.peerId {
if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: peer, mode: .forumTopic(thread: replyThreadMessage), avatarInitiallyExpanded: false, fromChat: true, requestsContext: nil) {
self.effectiveNavigationController?.pushViewController(infoController)
}
} else if let monoforumPeer = self.presentationInterfaceState.renderedPeer?.peer, case let .replyThread(replyThreadMessage) = self.chatLocation, monoforumPeer.isMonoForum {
let context = self.context
if #available(iOS 13.0, *) {
Task { @MainActor [weak self] in
guard let peer = await context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: PeerId(replyThreadMessage.threadId))
).get() else {
return
}
guard let self else {
return
}
if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: peer._asPeer(), mode: .monoforum(monoforumPeer.id), avatarInitiallyExpanded: false, fromChat: true, requestsContext: nil) {
self.effectiveNavigationController?.pushViewController(infoController)
}
}
}
} else if let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isForumOrMonoForum, case let .replyThread(message) = self.chatLocation {
if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: channel, mode: .forumTopic(thread: message), avatarInitiallyExpanded: false, fromChat: true, requestsContext: self.contentData?.inviteRequestsContext) {
self.effectiveNavigationController?.pushViewController(infoController)
}
}
case .customChatContents:
break
}
})
case .search:
self.interfaceInteraction?.beginMessageSearch(.everything, "")
case .dismiss:
if self.attemptNavigation({}) {
self.dismiss()
}
case .clearCache:
guard let contentData = self.contentData else {
return
}
let controller = OverlayStatusController(theme: self.presentationData.theme, type: .loading(cancelled: nil))
self.present(controller, in: .window(.root))
let disposable: MetaDisposable
if let currentDisposable = self.clearCacheDisposable {
disposable = currentDisposable
} else {
disposable = MetaDisposable()
self.clearCacheDisposable = disposable
}
switch contentData.chatLocationInfoData {
case let .peer(peerView):
self.navigationActionDisposable.set((peerView.get()
|> take(1)
|> deliverOnMainQueue).startStrict(next: { [weak self] peerView in
guard let strongSelf = self, let peer = peerView.peers[peerView.peerId] else {
return
}
let peerId = peer.id
let _ = (strongSelf.context.engine.resources.collectCacheUsageStats(peerId: peer.id)
|> deliverOnMainQueue).startStandalone(next: { [weak self, weak controller] result in
controller?.dismiss()
guard let strongSelf = self, case let .result(stats) = result, let categories = stats.media[peer.id] else {
return
}
let presentationData = strongSelf.presentationData
let controller = ActionSheetController(presentationData: presentationData)
let dismissAction: () -> Void = { [weak controller] in
controller?.dismissAnimated()
}
var sizeIndex: [PeerCacheUsageCategory: (Bool, Int64)] = [:]
var itemIndex = 1
var selectedSize: Int64 = 0
let updateTotalSize: () -> Void = { [weak controller] in
controller?.updateItem(groupIndex: 0, itemIndex: itemIndex, { item in
let title: String
let filteredSize = sizeIndex.values.reduce(0, { $0 + ($1.0 ? $1.1 : 0) })
selectedSize = filteredSize
if filteredSize == 0 {
title = presentationData.strings.Cache_ClearNone
} else {
title = presentationData.strings.Cache_Clear("\(dataSizeString(filteredSize, formatting: DataSizeStringFormatting(presentationData: presentationData)))").string
}
if let item = item as? ActionSheetButtonItem {
return ActionSheetButtonItem(title: title, color: filteredSize != 0 ? .accent : .disabled, enabled: filteredSize != 0, action: item.action)
}
return item
})
}
let toggleCheck: (PeerCacheUsageCategory, Int) -> Void = { [weak controller] category, itemIndex in
if let (value, size) = sizeIndex[category] {
sizeIndex[category] = (!value, size)
}
controller?.updateItem(groupIndex: 0, itemIndex: itemIndex, { item in
if let item = item as? ActionSheetCheckboxItem {
return ActionSheetCheckboxItem(title: item.title, label: item.label, value: !item.value, action: item.action)
}
return item
})
updateTotalSize()
}
var items: [ActionSheetItem] = []
items.append(DeleteChatPeerActionSheetItem(context: strongSelf.context, peer: EnginePeer(peer), chatPeer: EnginePeer(peer), action: .clearCache, strings: presentationData.strings, nameDisplayOrder: presentationData.nameDisplayOrder))
let validCategories: [PeerCacheUsageCategory] = [.image, .video, .audio, .file]
var totalSize: Int64 = 0
func stringForCategory(strings: PresentationStrings, category: PeerCacheUsageCategory) -> String {
switch category {
case .image:
return strings.Cache_Photos
case .video:
return strings.Cache_Videos
case .audio:
return strings.Cache_Music
case .file:
return strings.Cache_Files
}
}
for categoryId in validCategories {
if let media = categories[categoryId] {
var categorySize: Int64 = 0
for (_, size) in media {
categorySize += size
}
sizeIndex[categoryId] = (true, categorySize)
totalSize += categorySize
if categorySize > 1024 {
let index = itemIndex
items.append(ActionSheetCheckboxItem(title: stringForCategory(strings: presentationData.strings, category: categoryId), label: dataSizeString(categorySize, formatting: DataSizeStringFormatting(presentationData: presentationData)), value: true, action: { value in
toggleCheck(categoryId, index)
}))
itemIndex += 1
}
}
}
selectedSize = totalSize
if items.isEmpty {
strongSelf.presentClearCacheSuggestion()
} else {
items.append(ActionSheetButtonItem(title: presentationData.strings.Cache_Clear("\(dataSizeString(totalSize, formatting: DataSizeStringFormatting(presentationData: presentationData)))").string, action: {
let clearCategories = sizeIndex.keys.filter({ sizeIndex[$0]!.0 })
var clearMediaIds = Set<MediaId>()
var media = stats.media
if var categories = media[peerId] {
for category in clearCategories {
if let contents = categories[category] {
for (mediaId, _) in contents {
clearMediaIds.insert(mediaId)
}
}
categories.removeValue(forKey: category)
}
media[peerId] = categories
}
var clearResourceIds = Set<MediaResourceId>()
for id in clearMediaIds {
if let ids = stats.mediaResourceIds[id] {
for resourceId in ids {
clearResourceIds.insert(resourceId)
}
}
}
var signal = strongSelf.context.engine.resources.clearCachedMediaResources(mediaResourceIds: clearResourceIds)
var cancelImpl: (() -> Void)?
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
let progressSignal = Signal<Never, NoError> { subscriber in
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
cancelImpl?()
}))
strongSelf.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
}
|> runOn(Queue.mainQueue())
|> delay(0.15, queue: Queue.mainQueue())
let progressDisposable = progressSignal.startStrict()
signal = signal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
cancelImpl = {
disposable.set(nil)
}
disposable.set((signal
|> deliverOnMainQueue).startStrict(completed: { [weak self] in
if let strongSelf = self, let _ = strongSelf.validLayout {
strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .succeed(text: presentationData.strings.ClearCache_Success("\(dataSizeString(selectedSize, formatting: DataSizeStringFormatting(presentationData: presentationData)))", stringForDeviceType()).string, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), in: .current)
}
}))
dismissAction()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) })
}))
items.append(ActionSheetButtonItem(title: presentationData.strings.ClearCache_StorageUsage, action: { [weak self] in
dismissAction()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) })
if let strongSelf = self {
let context = strongSelf.context
let controller = StorageUsageScreen(context: context, makeStorageUsageExceptionsScreen: { category in
return storageUsageExceptionsScreen(context: context, category: category)
})
strongSelf.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
}
}))
controller.setItemGroups([
ActionSheetItemGroup(items: items),
ActionSheetItemGroup(items: [ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, action: { dismissAction() })])
])
strongSelf.chatDisplayNode.dismissInput()
strongSelf.present(controller, in: .window(.root))
}
})
}))
case .replyThread:
break
case .customChatContents:
break
}
case .edit:
self.editChat()
}
}
}
@@ -0,0 +1,85 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import ContextUI
import UndoUI
import AccountContext
import ChatMessageItemView
import ChatMessageItemCommon
import ChatControllerInteraction
extension ChatControllerImpl {
func openBankCardContextMenu(number: String, params: ChatControllerInteraction.LongTapParams) -> Void {
guard let message = params.message, let contentNode = params.contentNode else {
return
}
guard let messages = self.chatDisplayNode.historyNode.messageGroupInCurrentHistoryView(message.id) else {
return
}
var updatedMessages = messages
for i in 0 ..< updatedMessages.count {
if updatedMessages[i].id == message.id {
let message = updatedMessages.remove(at: i)
updatedMessages.insert(message, at: 0)
break
}
}
let recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil// anyRecognizer as? TapLongTapOrDoubleTapGestureRecognizer
let gesture: ContextGesture? = nil // anyRecognizer as? ContextGesture
let source: ContextContentSource
// if let location = location {
// source = .location(ChatMessageContextLocationContentSource(controller: self, location: messageNode.view.convert(messageNode.bounds, to: nil).origin.offsetBy(dx: location.x, dy: location.y)))
// } else {
source = .extracted(ChatMessageLinkContextExtractedContentSource(chatNode: self.chatDisplayNode, contentNode: contentNode))
// }
params.progress?.set(.single(true))
let _ = (self.context.engine.payments.getBankCardInfo(cardNumber: number)
|> deliverOnMainQueue).start(next: { [weak self] info in
guard let self else {
return
}
params.progress?.set(.single(false))
var items: [ContextMenuItem] = []
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Card_Copy, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
UIPasteboard.general.string = number
self.present(UndoOverlayController(presentationData: self.presentationData, content: .copy(text: presentationData.strings.Conversation_CardNumberCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}))
)
if let info {
items.append(.separator)
let emptyAction: ((ContextMenuActionItem.Action) -> Void)? = nil
items.append(.action(ContextMenuActionItem(text: info.title, textLayout: .multiline, textFont: .small, icon: { _ in return nil }, action: emptyAction)))
}
self.canReadHistory.set(false)
let controller = ContextController(presentationData: self.presentationData, source: source, items: .single(ContextController.Items(content: .list(items))), recognizer: recognizer, gesture: gesture, disableScreenshots: false)
controller.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
self.window?.presentInGlobalOverlay(controller)
})
}
}
@@ -0,0 +1,69 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import ContextUI
import UndoUI
import AccountContext
import ChatMessageItemView
import ChatMessageItemCommon
import ChatControllerInteraction
extension ChatControllerImpl {
func openCommandContextMenu(command: String, params: ChatControllerInteraction.LongTapParams) -> Void {
guard let message = params.message, let contentNode = params.contentNode else {
return
}
guard let messages = self.chatDisplayNode.historyNode.messageGroupInCurrentHistoryView(message.id) else {
return
}
var updatedMessages = messages
for i in 0 ..< updatedMessages.count {
if updatedMessages[i].id == message.id {
let message = updatedMessages.remove(at: i)
updatedMessages.insert(message, at: 0)
break
}
}
let recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil// anyRecognizer as? TapLongTapOrDoubleTapGestureRecognizer
let gesture: ContextGesture? = nil // anyRecognizer as? ContextGesture
let source: ContextContentSource
// if let location = location {
// source = .location(ChatMessageContextLocationContentSource(controller: self, location: messageNode.view.convert(messageNode.bounds, to: nil).origin.offsetBy(dx: location.x, dy: location.y)))
// } else {
source = .extracted(ChatMessageLinkContextExtractedContentSource(chatNode: self.chatDisplayNode, contentNode: contentNode))
// }
var items: [ContextMenuItem] = []
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Command_Copy, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
UIPasteboard.general.string = command
self.present(UndoOverlayController(presentationData: self.presentationData, content: .copy(text: presentationData.strings.Conversation_TextCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}))
)
self.canReadHistory.set(false)
let controller = ContextController(presentationData: self.presentationData, source: source, items: .single(ContextController.Items(content: .list(items))), recognizer: recognizer, gesture: gesture, disableScreenshots: false)
controller.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
self.window?.presentInGlobalOverlay(controller)
}
}
@@ -0,0 +1,79 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import ContextUI
import UndoUI
import AccountContext
import ChatMessageItemView
import ChatMessageItemCommon
import ChatControllerInteraction
extension ChatControllerImpl {
func openHashtagContextMenu(hashtag: String, params: ChatControllerInteraction.LongTapParams) -> Void {
guard let message = params.message, let contentNode = params.contentNode else {
return
}
guard let messages = self.chatDisplayNode.historyNode.messageGroupInCurrentHistoryView(message.id) else {
return
}
var updatedMessages = messages
for i in 0 ..< updatedMessages.count {
if updatedMessages[i].id == message.id {
let message = updatedMessages.remove(at: i)
updatedMessages.insert(message, at: 0)
break
}
}
let recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil// anyRecognizer as? TapLongTapOrDoubleTapGestureRecognizer
let gesture: ContextGesture? = nil // anyRecognizer as? ContextGesture
let source: ContextContentSource
// if let location = location {
// source = .location(ChatMessageContextLocationContentSource(controller: self, location: messageNode.view.convert(messageNode.bounds, to: nil).origin.offsetBy(dx: location.x, dy: location.y)))
// } else {
source = .extracted(ChatMessageLinkContextExtractedContentSource(chatNode: self.chatDisplayNode, contentNode: contentNode))
// }
var items: [ContextMenuItem] = []
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Hashtag_Search, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Search"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
guard let self else {
return
}
f(.default)
self.controllerInteraction?.openHashtag(nil, hashtag)
}))
)
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Hashtag_Copy, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
UIPasteboard.general.string = hashtag
self.present(UndoOverlayController(presentationData: self.presentationData, content: .copy(text: presentationData.strings.Conversation_HashtagCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}))
)
self.canReadHistory.set(false)
let controller = ContextController(presentationData: self.presentationData, source: source, items: .single(ContextController.Items(content: .list(items))), recognizer: recognizer, gesture: gesture, disableScreenshots: false)
controller.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
self.window?.presentInGlobalOverlay(controller)
}
}
@@ -0,0 +1,207 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import ContextUI
import UndoUI
import AccountContext
import ChatMessageItemView
import ChatMessageItemCommon
import MessageUI
import ChatControllerInteraction
import UrlWhitelist
import OpenInExternalAppUI
import SafariServices
extension ChatControllerImpl {
func openLinkContextMenu(url: String, params: ChatControllerInteraction.LongTapParams) -> Void {
guard let message = params.message, let contentNode = params.contentNode else {
var (cleanUrl, _) = parseUrl(url: url, wasConcealed: false)
var canAddToReadingList = true
var canOpenIn = availableOpenInOptions(context: self.context, item: .url(url: url)).count > 1
let mailtoString = "mailto:"
let telString = "tel:"
var openText = self.presentationData.strings.Conversation_LinkDialogOpen
var phoneNumber: String?
var isPhoneNumber = false
var isEmail = false
var hasOpenAction = true
if cleanUrl.hasPrefix(mailtoString) {
canAddToReadingList = false
cleanUrl = String(cleanUrl[cleanUrl.index(cleanUrl.startIndex, offsetBy: mailtoString.distance(from: mailtoString.startIndex, to: mailtoString.endIndex))...])
isEmail = true
} else if cleanUrl.hasPrefix(telString) {
canAddToReadingList = false
phoneNumber = String(cleanUrl[cleanUrl.index(cleanUrl.startIndex, offsetBy: telString.distance(from: telString.startIndex, to: telString.endIndex))...])
cleanUrl = phoneNumber!
openText = self.presentationData.strings.UserInfo_PhoneCall
canOpenIn = false
isPhoneNumber = true
if cleanUrl.hasPrefix("+888") {
hasOpenAction = false
}
} else if canOpenIn {
openText = self.presentationData.strings.Conversation_FileOpenIn
}
let actionSheet = ActionSheetController(presentationData: self.presentationData)
var items: [ActionSheetItem] = []
items.append(ActionSheetTextItem(title: cleanUrl))
if hasOpenAction {
items.append(ActionSheetButtonItem(title: openText, color: .accent, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
if let strongSelf = self {
if canOpenIn {
strongSelf.openUrlIn(url)
} else {
strongSelf.openUrl(url, concealed: false)
}
}
}))
}
if let phoneNumber = phoneNumber {
items.append(ActionSheetButtonItem(title: self.presentationData.strings.Conversation_AddContact, color: .accent, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
if let strongSelf = self {
strongSelf.controllerInteraction?.addContact(phoneNumber)
}
}))
}
items.append(ActionSheetButtonItem(title: canAddToReadingList ? self.presentationData.strings.ShareMenu_CopyShareLink : self.presentationData.strings.Conversation_ContextMenuCopy, color: .accent, action: { [weak actionSheet, weak self] in
actionSheet?.dismissAnimated()
guard let self else {
return
}
UIPasteboard.general.string = cleanUrl
let content: UndoOverlayContent
if isPhoneNumber {
content = .copy(text: self.presentationData.strings.Conversation_PhoneCopied)
} else if isEmail {
content = .copy(text: self.presentationData.strings.Conversation_EmailCopied)
} else if canAddToReadingList {
content = .linkCopied(title: nil, text: self.presentationData.strings.Conversation_LinkCopied)
} else {
content = .copy(text: self.presentationData.strings.Conversation_TextCopied)
}
self.present(UndoOverlayController(presentationData: self.presentationData, content: content, elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}))
if canAddToReadingList {
items.append(ActionSheetButtonItem(title: self.presentationData.strings.Conversation_AddToReadingList, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
if let link = URL(string: url) {
let _ = try? SSReadingList.default()?.addItem(with: link, title: nil, previewText: nil)
}
}))
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
self.chatDisplayNode.dismissInput()
self.present(actionSheet, in: .window(.root))
return
}
guard let messages = self.chatDisplayNode.historyNode.messageGroupInCurrentHistoryView(message.id) else {
return
}
var updatedMessages = messages
for i in 0 ..< updatedMessages.count {
if updatedMessages[i].id == message.id {
let message = updatedMessages.remove(at: i)
updatedMessages.insert(message, at: 0)
break
}
}
var (cleanUrl, _) = parseUrl(url: url, wasConcealed: false)
var canAddToReadingList = true
let canOpenIn = availableOpenInOptions(context: self.context, item: .url(url: url)).count > 1
var isEmail = false
let mailtoString = "mailto:"
var openText = self.presentationData.strings.Conversation_LinkDialogOpen
var copyText = self.presentationData.strings.Conversation_ContextMenuCopyLink
if cleanUrl.hasPrefix(mailtoString) {
canAddToReadingList = false
cleanUrl = String(cleanUrl[cleanUrl.index(cleanUrl.startIndex, offsetBy: mailtoString.distance(from: mailtoString.startIndex, to: mailtoString.endIndex))...])
copyText = self.presentationData.strings.Conversation_ContextMenuCopyEmail
isEmail = true
} else if canOpenIn {
openText = self.presentationData.strings.Conversation_FileOpenIn
}
let recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil// anyRecognizer as? TapLongTapOrDoubleTapGestureRecognizer
let gesture: ContextGesture? = nil // anyRecognizer as? ContextGesture
let source: ContextContentSource
// if let location = location {
// source = .location(ChatMessageContextLocationContentSource(controller: self, location: messageNode.view.convert(messageNode.bounds, to: nil).origin.offsetBy(dx: location.x, dy: location.y)))
// } else {
source = .extracted(ChatMessageLinkContextExtractedContentSource(chatNode: self.chatDisplayNode, contentNode: contentNode))
// }
var items: [ContextMenuItem] = []
items.append(
.action(ContextMenuActionItem(text: openText, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Browser"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
guard let self else {
return
}
f(.default)
if canOpenIn {
self.openUrlIn(url)
}
else {
self.openUrl(url, concealed: false)
}
}))
)
items.append(
.action(ContextMenuActionItem(text: copyText, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
UIPasteboard.general.string = cleanUrl
self.present(UndoOverlayController(presentationData: self.presentationData, content: .copy(text: isEmail ? presentationData.strings.Conversation_EmailCopied : presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}))
)
if canAddToReadingList {
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Conversation_AddToReadingList, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReadingList"), color: theme.contextMenu.primaryColor) }, action: { _, f in
f(.default)
if let link = URL(string: url) {
let _ = try? SSReadingList.default()?.addItem(with: link, title: nil, previewText: nil)
}
}))
)
}
self.canReadHistory.set(false)
let controller = ContextController(presentationData: self.presentationData, source: source, items: .single(ContextController.Items(content: .list(items))), recognizer: recognizer, gesture: gesture, disableScreenshots: false)
controller.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
self.window?.presentInGlobalOverlay(controller)
}
}
@@ -0,0 +1,40 @@
import Foundation
import Display
import ChatControllerInteraction
import AccountContext
extension ChatControllerImpl {
func openLinkLongTap(_ action: ChatControllerInteractionLongTapAction, params: ChatControllerInteraction.LongTapParams?) {
if self.presentationInterfaceState.interfaceState.selectionState != nil {
return
}
self.dismissAllTooltips()
(self.view.window as? WindowHost)?.cancelInteractiveKeyboardGestures()
self.chatDisplayNode.cancelInteractiveKeyboardGestures()
self.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts()
guard let params else {
return
}
switch action {
case let .url(url):
self.openLinkContextMenu(url: url, params: params)
case let .mention(mention):
self.openMentionContextMenu(username: mention, peerId: nil, params: params)
case let .peerMention(peerId, mention):
self.openMentionContextMenu(username: mention, peerId: peerId, params: params)
case let .command(command):
self.openCommandContextMenu(command: command, params: params)
case let .hashtag(hashtag):
self.openHashtagContextMenu(hashtag: hashtag, params: params)
case let .timecode(value, timecode):
self.openTimecodeContextMenu(timecode: timecode, value: value, params: params)
case let .bankCard(number):
self.openBankCardContextMenu(number: number, params: params)
case let .phone(number):
self.openPhoneContextMenu(number: number, params: params)
}
}
}
@@ -0,0 +1,666 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import TelegramNotices
import ContextUI
import AccountContext
import ChatMessageItemView
import ChatMessageItemCommon
import ReactionSelectionNode
import EntityKeyboard
import TextNodeWithEntities
import PremiumUI
import TooltipUI
import TopMessageReactions
import TelegramNotices
extension ChatControllerImpl {
func openMessageContextMenu(message: Message, selectAll: Bool, node: ASDisplayNode, frame: CGRect, anyRecognizer: UIGestureRecognizer?, location: CGPoint?) -> Void {
if self.presentationInterfaceState.interfaceState.selectionState != nil {
return
}
let presentationData = self.presentationData
self.dismissAllTooltips()
let recognizer: TapLongTapOrDoubleTapGestureRecognizer? = anyRecognizer as? TapLongTapOrDoubleTapGestureRecognizer
let gesture: ContextGesture? = anyRecognizer as? ContextGesture
if let messages = self.chatDisplayNode.historyNode.messageGroupInCurrentHistoryView(message.id) {
(self.view.window as? WindowHost)?.cancelInteractiveKeyboardGestures()
self.chatDisplayNode.cancelInteractiveKeyboardGestures()
var updatedMessages = messages
for i in 0 ..< updatedMessages.count {
if updatedMessages[i].id == message.id {
let message = updatedMessages.remove(at: i)
updatedMessages.insert(message, at: 0)
break
}
}
guard let topMessage = messages.first else {
return
}
let _ = combineLatest(queue: .mainQueue(),
self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)),
contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState: self.presentationInterfaceState, context: self.context, messages: updatedMessages, controllerInteraction: self.controllerInteraction, selectAll: selectAll, interfaceInteraction: self.interfaceInteraction, messageNode: node as? ChatMessageItemView),
peerMessageAllowedReactions(context: self.context, message: topMessage),
peerMessageSelectedReactions(context: self.context, message: topMessage),
topMessageReactions(context: self.context, message: topMessage, subPeerId: self.chatLocation.threadId.flatMap(EnginePeer.Id.init)),
ApplicationSpecificNotice.getChatTextSelectionTips(accountManager: self.context.sharedContext.accountManager)
).startStandalone(next: { [weak self] peer, actions, allowedReactionsAndStars, selectedReactions, topReactions, chatTextSelectionTips in
guard let self else {
return
}
var (allowedReactions, _) = allowedReactionsAndStars
var actions = actions
switch actions.content {
case let .list(itemList):
if itemList.isEmpty {
return
}
case .custom, .twoLists:
break
}
if allowedReactions != nil, case let .customChatContents(customChatContents) = self.presentationInterfaceState.subject {
if case let .hashTagSearch(publicPosts) = customChatContents.kind, publicPosts {
allowedReactions = nil
}
}
var tip: ContextController.Tip?
if tip == nil {
let isAd = message.adAttribute != nil
var isAction = false
for media in message.media {
if media is TelegramMediaAction {
isAction = true
break
}
}
if self.presentationInterfaceState.copyProtectionEnabled && !isAction && !isAd {
if case .scheduledMessages = self.subject {
} else {
var isChannel = false
if let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, case .broadcast = channel.info {
isChannel = true
}
tip = .messageCopyProtection(isChannel: isChannel)
}
} else {
let numberOfComponents = message.text.components(separatedBy: CharacterSet.whitespacesAndNewlines).count
let displayTextSelectionTip = numberOfComponents >= 3 && !message.text.isEmpty && chatTextSelectionTips < 3 && !isAd
if displayTextSelectionTip {
let _ = ApplicationSpecificNotice.incrementChatTextSelectionTips(accountManager: self.context.sharedContext.accountManager).startStandalone()
tip = .textSelection
}
}
}
if messages.contains(where: { $0.pendingProcessingAttribute != nil }) {
tip = .videoProcessing
}
if actions.tip == nil {
actions.tip = tip
}
actions.context = self.context
actions.animationCache = self.controllerInteraction?.presentationContext.animationCache
if canAddMessageReactions(message: topMessage), let allowedReactions = allowedReactions, !topReactions.isEmpty {
actions.reactionItems = topReactions.map { ReactionContextItem.reaction(item: $0, icon: .none) }
actions.selectedReactionItems = selectedReactions.reactions
if message.areReactionsTags(accountPeerId: self.context.account.peerId) {
if self.presentationInterfaceState.isPremium {
actions.reactionsTitle = presentationData.strings.Chat_ContextMenuTagsTitle
} else {
actions.reactionsTitle = presentationData.strings.Chat_MessageContextMenu_NonPremiumTagsTitle
actions.reactionsLocked = true
actions.selectedReactionItems = Set()
}
actions.allPresetReactionsAreAvailable = true
}
if let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, case .broadcast = channel.info {
actions.alwaysAllowPremiumReactions = true
}
if !actions.reactionItems.isEmpty {
let reactionItems: [EmojiComponentReactionItem] = actions.reactionItems.compactMap { item -> EmojiComponentReactionItem? in
switch item {
case let .reaction(reaction, _):
return EmojiComponentReactionItem(reaction: reaction.reaction.rawValue, file: reaction.stillAnimation)
default:
return nil
}
}
var allReactionsAreAvailable = false
switch allowedReactions {
case .set:
allReactionsAreAvailable = false
case .all:
allReactionsAreAvailable = true
}
if let channel = self.presentationInterfaceState.renderedPeer?.chatMainPeer as? TelegramChannel, case .broadcast = channel.info {
allReactionsAreAvailable = false
}
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
if premiumConfiguration.isPremiumDisabled {
allReactionsAreAvailable = false
}
if allReactionsAreAvailable {
actions.getEmojiContent = { [weak self] animationCache, animationRenderer in
guard let self else {
preconditionFailure()
}
return EmojiPagerContentComponent.emojiInputData(
context: self.context,
animationCache: animationCache,
animationRenderer: animationRenderer,
isStandalone: false,
subject: message.areReactionsTags(accountPeerId: self.context.account.peerId) ? .messageTag : .reaction(onlyTop: false),
hasTrending: false,
topReactionItems: reactionItems,
areUnicodeEmojiEnabled: false,
areCustomEmojiEnabled: true,
chatPeerId: self.chatLocation.peerId,
selectedItems: selectedReactions.files
)
}
} else if reactionItems.count > 16 {
actions.getEmojiContent = { [weak self] animationCache, animationRenderer in
guard let self else {
preconditionFailure()
}
return EmojiPagerContentComponent.emojiInputData(
context: self.context,
animationCache: animationCache,
animationRenderer: animationRenderer,
isStandalone: false,
subject: .reaction(onlyTop: true),
hasTrending: false,
topReactionItems: reactionItems,
areUnicodeEmojiEnabled: false,
areCustomEmojiEnabled: false,
chatPeerId: self.chatLocation.peerId,
selectedItems: selectedReactions.files
)
}
}
}
}
self.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts()
let presentationContext = self.controllerInteraction?.presentationContext
var disableTransitionAnimations = false
var actionsSignal: Signal<ContextController.Items, NoError> = .single(actions)
if let entitiesAttribute = message.textEntitiesAttribute {
var emojiFileIds: [Int64] = []
for entity in entitiesAttribute.entities {
if case let .CustomEmoji(_, fileId) = entity.type {
emojiFileIds.append(fileId)
}
}
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
if !emojiFileIds.isEmpty && !premiumConfiguration.isPremiumDisabled {
tip = .animatedEmoji(text: nil, arguments: nil, file: nil, action: nil)
actions.tip = tip
disableTransitionAnimations = true
let context = self.context
actionsSignal = .single(actions)
|> then(
context.engine.stickers.resolveInlineStickers(fileIds: emojiFileIds)
|> mapToSignal { files -> Signal<ContextController.Items, NoError> in
var packReferences: [StickerPackReference] = []
var existingIds = Set<Int64>()
for (_, file) in files {
loop: for attribute in file.attributes {
if case let .CustomEmoji(_, _, _, packReference) = attribute, let packReference = packReference {
if case let .id(id, _) = packReference, !existingIds.contains(id) {
packReferences.append(packReference)
existingIds.insert(id)
}
break loop
}
}
}
let action = { [weak self] in
guard let self else {
return
}
self.presentEmojiList(references: packReferences)
}
if packReferences.count > 1 {
actions.tip = .animatedEmoji(text: presentationData.strings.ChatContextMenu_EmojiSet(Int32(packReferences.count)), arguments: nil, file: nil, action: action)
return .single(actions)
} else if let reference = packReferences.first {
return context.engine.stickers.loadedStickerPack(reference: reference, forceActualized: false)
|> filter { result in
if case .result = result {
return true
} else {
return false
}
}
|> mapToSignal { result in
if case let .result(info, items, _) = result, let presentationContext = presentationContext {
actions.tip = .animatedEmoji(
text: presentationData.strings.ChatContextMenu_EmojiSetSingle(info.title).string,
arguments: TextNodeWithEntities.Arguments(
context: context,
cache: presentationContext.animationCache,
renderer: presentationContext.animationRenderer,
placeholderColor: .clear,
attemptSynchronous: true
),
file: items.first?.file._parse(),
action: action)
return .single(actions)
} else {
return .complete()
}
}
} else {
actions.tip = nil
return .single(actions)
}
}
)
}
}
var keepDefaultContentTouches = false
for media in message.media {
if media is TelegramMediaImage {
keepDefaultContentTouches = true
} else if let file = media as? TelegramMediaFile, file.isVideo {
keepDefaultContentTouches = true
}
}
let source: ContextContentSource
if let location = location {
source = .location(ChatMessageContextLocationContentSource(controller: self, location: node.view.convert(node.bounds, to: nil).origin.offsetBy(dx: location.x, dy: location.y)))
} else {
source = .extracted(ChatMessageContextExtractedContentSource(chatController: self, chatNode: self.chatDisplayNode, engine: self.context.engine, message: message, selectAll: selectAll, keepDefaultContentTouches: keepDefaultContentTouches))
}
self.canReadHistory.set(false)
var hideReactionPanelTail = false
for media in message.media {
if let action = media as? TelegramMediaAction {
switch action.action {
case .phoneCall:
break
case .conferenceCall:
break
default:
hideReactionPanelTail = true
}
}
}
let isSecret = self.presentationInterfaceState.copyProtectionEnabled || self.chatLocation.peerId?.namespace == Namespaces.Peer.SecretChat
let controller = ContextController(presentationData: self.presentationData, source: source, items: actionsSignal, recognizer: recognizer, gesture: gesture, disableScreenshots: isSecret, hideReactionPanelTail: hideReactionPanelTail)
controller.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
controller.immediateItemsTransitionAnimation = disableTransitionAnimations
self.currentContextController = controller
controller.premiumReactionsSelected = { [weak self, weak controller] in
guard let self else {
return
}
controller?.dismissWithoutContent()
guard !self.presentAccountFrozenInfoIfNeeded(delay: true) else {
return
}
self.presentTagPremiumPaywall()
}
controller.reactionSelected = { [weak self, weak controller] chosenUpdatedReaction, isLarge in
guard let self else {
return
}
guard !self.presentAccountFrozenInfoIfNeeded(delay: true) else {
controller?.dismiss(completion: {})
return
}
guard let message = messages.first else {
return
}
controller?.view.endEditing(true)
if case .stars = chosenUpdatedReaction.reaction {
if isLarge {
if let controller {
controller.dismiss(completion: { [weak self] in
guard let self else {
return
}
self.openMessageSendStarsScreen(message: message)
})
}
return
}
let isFirst = !"".isEmpty
self.chatDisplayNode.historyNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ChatMessageItemView, let item = itemNode.item {
if item.message.id == message.id {
let chosenReaction: MessageReaction.Reaction = .stars
itemNode.awaitingAppliedReaction = (chosenReaction, { [weak self, weak itemNode] in
guard let self, let controller = controller else {
return
}
if let itemNode = itemNode, let targetView = itemNode.targetReactionView(value: chosenReaction) {
self.chatDisplayNode.messageTransitionNode.addMessageContextController(messageId: item.message.id, contextController: controller)
var hideTargetButton: UIView?
if isFirst {
hideTargetButton = targetView.superview
}
controller.dismissWithReaction(value: chosenReaction, targetView: targetView, hideNode: true, animateTargetContainer: hideTargetButton, addStandaloneReactionAnimation: { [weak self] standaloneReactionAnimation in
guard let self else {
return
}
self.chatDisplayNode.messageTransitionNode.addMessageStandaloneReactionAnimation(messageId: item.message.id, standaloneReactionAnimation: standaloneReactionAnimation)
standaloneReactionAnimation.frame = self.chatDisplayNode.bounds
self.chatDisplayNode.addSubnode(standaloneReactionAnimation)
}, onHit: { [weak self, weak itemNode] in
guard let self else {
return
}
if let itemNode = itemNode, let targetView = itemNode.targetReactionView(value: chosenReaction) {
if !"".isEmpty {
if self.context.sharedContext.energyUsageSettings.fullTranslucency {
self.chatDisplayNode.wrappingNode.triggerRipple(at: targetView.convert(targetView.bounds.center, to: self.chatDisplayNode.view))
}
}
}
}, completion: {})
} else {
controller.dismiss()
}
})
}
}
}
guard let starsContext = self.context.starsContext else {
return
}
let _ = (combineLatest(
starsContext.state,
self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.ReactionSettings(id: message.id.peerId))
)
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] state, reactionSettings in
guard let strongSelf = self, let balance = state?.balance else {
return
}
if case let .known(reactionSettings) = reactionSettings, let starsAllowed = reactionSettings.starsAllowed, !starsAllowed {
if let peer = strongSelf.presentationInterfaceState.renderedPeer?.chatMainPeer {
strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: nil, text: strongSelf.presentationData.strings.Chat_ToastStarsReactionsDisabled(peer.debugDisplayTitle).string, actions: [
TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_OK, action: {})
]), in: .window(.root))
}
return
}
if balance < StarsAmount(value: 1, nanos: 0) {
controller?.dismiss(completion: {
guard let strongSelf = self else {
return
}
let _ = (strongSelf.context.engine.payments.starsTopUpOptions()
|> take(1)
|> deliverOnMainQueue).startStandalone(next: { [weak strongSelf] options in
guard let strongSelf else {
return
}
guard let starsContext = strongSelf.context.starsContext else {
return
}
let purchaseScreen = strongSelf.context.sharedContext.makeStarsPurchaseScreen(context: strongSelf.context, starsContext: starsContext, options: options, purpose: .reactions(peerId: message.id.peerId, requiredStars: 1), targetPeerId: nil, customTheme: nil, completion: { result in
let _ = result
})
strongSelf.push(purchaseScreen)
})
})
return
}
let _ = (strongSelf.context.engine.messages.sendStarsReaction(id: message.id, count: 1, privacy: nil)
|> deliverOnMainQueue).startStandalone(next: { privacy in
guard let strongSelf = self else {
return
}
strongSelf.displayOrUpdateSendStarsUndo(messageId: message.id, count: 1, privacy: privacy)
})
})
} else {
let chosenReaction: MessageReaction.Reaction = chosenUpdatedReaction.reaction
let currentReactions = mergedMessageReactions(attributes: message.attributes, isTags: message.areReactionsTags(accountPeerId: self.context.account.peerId))?.reactions ?? []
var updatedReactions: [MessageReaction.Reaction] = currentReactions.filter(\.isSelected).map(\.value)
var removedReaction: MessageReaction.Reaction?
var isFirst = false
if let index = updatedReactions.firstIndex(where: { $0 == chosenReaction }) {
removedReaction = chosenReaction
updatedReactions.remove(at: index)
} else {
updatedReactions.append(chosenReaction)
isFirst = !currentReactions.contains(where: { $0.value == chosenReaction })
}
if message.areReactionsTags(accountPeerId: self.context.account.peerId) {
if removedReaction == nil, !topReactions.contains(where: { $0.reaction.rawValue == chosenReaction }) {
if !self.presentationInterfaceState.isPremium {
controller?.premiumReactionsSelected?()
return
}
}
} else {
if removedReaction == nil, case .custom = chosenReaction {
if let peer = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, case .broadcast = peer.info {
} else {
if !self.presentationInterfaceState.isPremium {
controller?.premiumReactionsSelected?()
return
}
}
}
}
self.chatDisplayNode.historyNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ChatMessageItemView, let item = itemNode.item {
if item.message.id == message.id {
if removedReaction == nil && !updatedReactions.isEmpty {
itemNode.awaitingAppliedReaction = (chosenReaction, { [weak self, weak itemNode] in
guard let self, let controller = controller else {
return
}
if let itemNode = itemNode, let targetView = itemNode.targetReactionView(value: chosenReaction) {
self.chatDisplayNode.messageTransitionNode.addMessageContextController(messageId: item.message.id, contextController: controller)
var hideTargetButton: UIView?
if isFirst {
hideTargetButton = targetView.superview
}
controller.dismissWithReaction(value: chosenReaction, targetView: targetView, hideNode: true, animateTargetContainer: hideTargetButton, addStandaloneReactionAnimation: { [weak self] standaloneReactionAnimation in
guard let self else {
return
}
self.chatDisplayNode.messageTransitionNode.addMessageStandaloneReactionAnimation(messageId: item.message.id, standaloneReactionAnimation: standaloneReactionAnimation)
standaloneReactionAnimation.frame = self.chatDisplayNode.bounds
self.chatDisplayNode.addSubnode(standaloneReactionAnimation)
}, onHit: nil, completion: { [weak self, weak itemNode, weak targetView] in
guard let self, let itemNode, let targetView else {
return
}
if self.chatLocation.peerId == self.context.account.peerId {
let _ = (ApplicationSpecificNotice.getSavedMessageTagLabelSuggestion(accountManager: self.context.sharedContext.accountManager)
|> take(1)
|> deliverOnMainQueue).startStandalone(next: { [weak self, weak targetView, weak itemNode] value in
guard let self, let targetView, let itemNode else {
return
}
if value >= 3 {
return
}
let _ = itemNode
let rect = self.chatDisplayNode.view.convert(targetView.bounds, from: targetView).insetBy(dx: -8.0, dy: -8.0)
let tooltipScreen = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: self.presentationData.strings.Chat_TooltipAddTagLabel), location: .point(rect, .bottom), displayDuration: .manual, shouldDismissOnTouch: { _, _ in
return .dismiss(consume: false)
})
self.present(tooltipScreen, in: .current)
let _ = ApplicationSpecificNotice.incrementSavedMessageTagLabelSuggestion(accountManager: self.context.sharedContext.accountManager).startStandalone()
})
}
})
} else {
controller.dismiss()
}
})
} else {
itemNode.awaitingAppliedReaction = (nil, {
controller?.dismiss()
})
}
}
}
}
let mappedUpdatedReactions = updatedReactions.map { reaction -> UpdateMessageReaction in
switch reaction {
case let .builtin(value):
return .builtin(value)
case let .custom(fileId):
var customFile: TelegramMediaFile?
if case let .custom(customFileId, file) = chosenUpdatedReaction, fileId == customFileId {
customFile = file
}
return .custom(fileId: fileId, file: customFile)
case .stars:
return .stars
}
}
let _ = updateMessageReactionsInteractively(account: self.context.account, messageIds: [message.id], reactions: mappedUpdatedReactions, isLarge: isLarge, storeAsRecentlyUsed: true).startStandalone()
}
}
self.forEachController({ controller in
if let controller = controller as? TooltipScreen {
controller.dismiss()
}
return true
})
self.window?.presentInGlobalOverlay(controller)
})
}
}
}
final class ChatContextControllerContentSourceImpl: ContextControllerContentSource {
let controller: ViewController
weak var sourceNode: ASDisplayNode?
weak var sourceView: UIView?
let sourceRect: CGRect?
let navigationController: NavigationController? = nil
let passthroughTouches: Bool
init(controller: ViewController, sourceNode: ASDisplayNode?, sourceRect: CGRect? = nil, passthroughTouches: Bool) {
self.controller = controller
self.sourceNode = sourceNode
self.sourceRect = sourceRect
self.passthroughTouches = passthroughTouches
}
init(controller: ViewController, sourceView: UIView?, sourceRect: CGRect? = nil, passthroughTouches: Bool) {
self.controller = controller
self.sourceView = sourceView
self.sourceRect = sourceRect
self.passthroughTouches = passthroughTouches
}
func transitionInfo() -> ContextControllerTakeControllerInfo? {
let sourceView = self.sourceView
let sourceNode = self.sourceNode
let sourceRect = self.sourceRect
return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceNode] in
if let sourceView = sourceView {
return (sourceView, sourceRect ?? sourceView.bounds)
} else if let sourceNode = sourceNode {
return (sourceNode.view, sourceRect ?? sourceNode.bounds)
} else {
return nil
}
})
}
func animatedIn() {
}
}
final class ChatControllerContextReferenceContentSource: ContextReferenceContentSource {
let controller: ViewController
let sourceView: UIView
let insets: UIEdgeInsets
let contentInsets: UIEdgeInsets
init(controller: ViewController, sourceView: UIView, insets: UIEdgeInsets, contentInsets: UIEdgeInsets = UIEdgeInsets()) {
self.controller = controller
self.sourceView = sourceView
self.insets = insets
self.contentInsets = contentInsets
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds.inset(by: self.insets), insets: self.contentInsets)
}
}
@@ -0,0 +1,31 @@
import Foundation
import TelegramCore
import FactCheckAlertController
extension ChatControllerImpl {
func openEditMessageFactCheck(messageId: EngineMessage.Id) {
guard let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) else {
return
}
var currentText: String = ""
var currentEntities: [MessageTextEntity] = []
for attribute in message.attributes {
if let attribute = attribute as? FactCheckMessageAttribute, case let .Loaded(text, entities, _) = attribute.content {
currentText = text
currentEntities = entities
break
}
}
let controller = factCheckAlertController(context: self.context, updatedPresentationData: self.updatedPresentationData, value: currentText, entities: currentEntities, apply: { [weak self] text, entities in
guard let self else {
return
}
if !currentText.isEmpty && text.isEmpty {
let _ = self.context.engine.messages.deleteMessageFactCheck(messageId: messageId).startStandalone()
} else {
let _ = self.context.engine.messages.editMessageFactCheck(messageId: messageId, text: text, entities: entities).startStandalone()
}
})
self.present(controller, in: .window(.root))
}
}
@@ -0,0 +1,466 @@
import Foundation
import UIKit
import Postbox
import SwiftSignalKit
import Display
import AsyncDisplayKit
import TelegramCore
import SafariServices
import MobileCoreServices
import Intents
import LegacyComponents
import TelegramPresentationData
import TelegramUIPreferences
import DeviceAccess
import TextFormat
import TelegramBaseController
import AccountContext
import TelegramStringFormatting
import OverlayStatusController
import DeviceLocationManager
import ShareController
import UrlEscaping
import ContextUI
import ComposePollUI
import AlertUI
import PresentationDataUtils
import UndoUI
import TelegramCallsUI
import TelegramNotices
import GameUI
import ScreenCaptureDetection
import GalleryUI
import OpenInExternalAppUI
import LegacyUI
import InstantPageUI
import LocationUI
import BotPaymentsUI
import DeleteChatPeerActionSheetItem
import HashtagSearchUI
import LegacyMediaPickerUI
import Emoji
import PeerAvatarGalleryUI
import PeerInfoUI
import RaiseToListen
import UrlHandling
import AvatarNode
import AppBundle
import LocalizedPeerData
import PhoneNumberFormat
import SettingsUI
import UrlWhitelist
import TelegramIntents
import TooltipUI
import StatisticsUI
import MediaResources
import GalleryData
import ChatInterfaceState
import InviteLinksUI
import Markdown
import TelegramPermissionsUI
import Speak
import TranslateUI
import UniversalMediaPlayer
import WallpaperBackgroundNode
import ChatListUI
import CalendarMessageScreen
import ReactionSelectionNode
import ReactionListContextMenuContent
import AttachmentUI
import AttachmentTextInputPanelNode
import MediaPickerUI
import ChatPresentationInterfaceState
import Pasteboard
import ChatSendMessageActionUI
import ChatTextLinkEditUI
import WebUI
import PremiumUI
import ImageTransparency
import StickerPackPreviewUI
import TextNodeWithEntities
import EntityKeyboard
import ChatTitleView
import EmojiStatusComponent
import ChatTimerScreen
import MediaPasteboardUI
import ChatListHeaderComponent
import ChatControllerInteraction
import FeaturedStickersScreen
import ChatEntityKeyboardInputNode
import StorageUsageScreen
import AvatarEditorScreen
import ChatScheduleTimeController
import ICloudResources
import StoryContainerScreen
import MoreHeaderButton
import VolumeButtons
import ChatAvatarNavigationNode
import ChatContextQuery
import PeerReportScreen
import PeerSelectionController
import SaveToCameraRoll
import ChatMessageDateAndStatusNode
import ReplyAccessoryPanelNode
import TextSelectionNode
import ChatMessagePollBubbleContentNode
import ChatMessageItem
import ChatMessageItemImpl
import ChatMessageItemView
import ChatMessageItemCommon
import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen
import WallpaperGridScreen
import VideoMessageCameraScreen
import TopMessageReactions
import AudioWaveform
import PeerNameColorScreen
import ChatEmptyNode
import ChatMediaInputStickerGridItem
import AdsInfoScreen
import FaceScanScreen
import ForumCreateTopicScreen
extension ChatControllerImpl {
func openPeer(peer: EnginePeer?, navigation: ChatControllerInteractionNavigateToPeer, fromMessage: MessageReference?, fromReactionMessageId: MessageId? = nil, expandAvatar: Bool = false, peerTypes: ReplyMarkupButtonAction.PeerTypes? = nil, skipAgeVerification: Bool = false) {
let _ = self.presentVoiceMessageDiscardAlert(action: {
if case let .peer(currentPeerId) = self.chatLocation, peer?.id == currentPeerId {
switch navigation {
case let .info(params):
var section: ChatNavigationButtonAction.ChatInfoSection?
if let params {
if params.switchToRecommendedChannels {
section = .recommendedChannels
} else if params.switchToGroupsInCommon {
section = .groupsInCommon
}
if params.ignoreInSavedMessages && currentPeerId == self.context.account.peerId {
self.playShakeAnimation()
return
}
}
self.navigationButtonAction(.openChatInfo(expandAvatar: expandAvatar, section: section))
case let .chat(textInputState, _, _):
if let textInputState = textInputState {
self.updateChatPresentationInterfaceState(animated: true, interactive: true, {
return ($0.updatedInterfaceState {
return $0.withUpdatedComposeInputState(textInputState)
}).updatedInputMode({ _ in
return .text
})
})
} else {
self.playShakeAnimation()
}
case let .withBotStartPayload(botStart):
self.updateChatPresentationInterfaceState(animated: true, interactive: true, {
$0.updatedBotStartPayload(botStart.payload)
})
case .withAttachBot:
self.presentAttachmentMenu(subject: .default)
default:
break
}
} else {
if let peer = peer {
do {
var chatPeerId: PeerId?
if let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer as? TelegramGroup {
chatPeerId = peer.id
} else if let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer as? TelegramChannel, case .group = peer.info, case .member = peer.participationStatus {
chatPeerId = peer.id
}
switch navigation {
case .info, .default:
let peerSignal: Signal<Peer?, NoError>
if let messageId = fromMessage?.id {
peerSignal = loadedPeerFromMessage(account: self.context.account, peerId: peer.id, messageId: messageId)
} else {
peerSignal = self.context.account.postbox.loadedPeerWithId(peer.id) |> map(Optional.init)
}
self.navigationActionDisposable.set((peerSignal |> take(1) |> deliverOnMainQueue).startStrict(next: { [weak self] peer in
if let strongSelf = self, let peer = peer {
var mode: PeerInfoControllerMode = .generic
if let _ = fromMessage, let chatPeerId = chatPeerId {
mode = .group(chatPeerId)
}
if let fromReactionMessageId = fromReactionMessageId {
mode = .reaction(fromReactionMessageId)
}
if case let .info(params) = navigation, let params {
if params.switchToRecommendedChannels {
mode = .recommendedChannels
} else if params.switchToGroupsInCommon {
mode = .groupsInCommon
}
}
if peer.id == strongSelf.context.account.peerId {
mode = .myProfile
}
var expandAvatar = expandAvatar
if peer.smallProfileImage == nil {
expandAvatar = false
}
if let validLayout = strongSelf.validLayout, validLayout.deviceMetrics.type == .tablet {
expandAvatar = false
}
if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, peer: peer, mode: mode, avatarInitiallyExpanded: expandAvatar, fromChat: false, requestsContext: nil) {
strongSelf.effectiveNavigationController?.pushViewController(infoController)
}
}
}))
case let .chat(textInputState, subject, peekData):
if let textInputState = textInputState {
let _ = (ChatInterfaceState.update(engine: self.context.engine, peerId: peer.id, threadId: nil, { currentState in
return currentState.withUpdatedComposeInputState(textInputState)
})
|> deliverOnMainQueue).startStandalone(completed: { [weak self] in
if let strongSelf = self, let navigationController = strongSelf.effectiveNavigationController {
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), subject: subject, updateTextInputState: textInputState, peekData: peekData))
}
})
} else {
let _ = (requireAgeVerification(context: self.context, peer: peer)
|> deliverOnMainQueue).start(next: { [weak self] require in
guard let self else {
return
}
if require && !skipAgeVerification {
presentAgeVerification(context: self.context, parentController: self, completion: {
self.openPeer(peer: peer, navigation: navigation, fromMessage: fromMessage, fromReactionMessageId: fromReactionMessageId, expandAvatar: expandAvatar, peerTypes: peerTypes)
})
} else {
if case let .channel(channel) = peer, channel.isForumOrMonoForum {
self.effectiveNavigationController?.pushViewController(ChatListControllerImpl(context: self.context, location: .forum(peerId: channel.id), controlsHistoryPreload: false, enableDebugActions: false))
} else {
self.effectiveNavigationController?.pushViewController(ChatControllerImpl(context: self.context, chatLocation: .peer(id: peer.id), subject: subject))
}
}
})
}
case let .withBotStartPayload(botStart):
self.effectiveNavigationController?.pushViewController(ChatControllerImpl(context: self.context, chatLocation: .peer(id: peer.id), botStart: botStart))
case let .withAttachBot(attachBotStart):
if let navigationController = self.effectiveNavigationController {
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), attachBotStart: attachBotStart))
}
case let .withBotApp(botAppStart):
if let navigationController = self.effectiveNavigationController {
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), botAppStart: botAppStart))
}
}
}
} else {
switch navigation {
case .info:
break
case let .chat(textInputState, _, _):
if let textInputState = textInputState {
let controller = self.context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: self.context, updatedPresentationData: self.updatedPresentationData, requestPeerType: peerTypes.flatMap { $0.requestPeerTypes }, selectForumThreads: true))
controller.peerSelected = { [weak self, weak controller] peer, threadId in
let peerId = peer.id
if let strongSelf = self, let strongController = controller {
if case let .peer(currentPeerId) = strongSelf.chatLocation, peerId == currentPeerId {
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, {
return ($0.updatedInterfaceState {
return $0.withUpdatedComposeInputState(textInputState)
}).updatedInputMode({ _ in
return .text
})
})
strongController.dismiss()
} else {
let _ = (ChatInterfaceState.update(engine: strongSelf.context.engine, peerId: peerId, threadId: threadId, { currentState in
return currentState.withUpdatedComposeInputState(textInputState)
})
|> deliverOnMainQueue).startStandalone(completed: { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) })
if let navigationController = strongSelf.effectiveNavigationController {
let chatController: Signal<ChatController, NoError>
if let threadId {
chatController = chatControllerForForumThreadImpl(context: strongSelf.context, peerId: peerId, threadId: threadId)
} else {
chatController = .single(ChatControllerImpl(context: strongSelf.context, chatLocation: .peer(id: peerId)))
}
let _ = (chatController
|> deliverOnMainQueue).start(next: { [weak self, weak navigationController] chatController in
guard let strongSelf = self, let navigationController else {
return
}
var viewControllers = navigationController.viewControllers
let lastController = viewControllers.last as! ViewController
if threadId != nil {
viewControllers.remove(at: viewControllers.count - 2)
lastController.navigationPresentation = .modal
}
viewControllers.insert(chatController, at: viewControllers.count - 1)
navigationController.setViewControllers(viewControllers, animated: false)
strongSelf.controllerNavigationDisposable.set((chatController.ready.get()
|> filter { $0 }
|> take(1)
|> deliverOnMainQueue).startStrict(next: { [weak lastController] _ in
lastController?.dismiss()
}))
})
}
})
}
}
}
self.chatDisplayNode.dismissInput()
self.effectiveNavigationController?.pushViewController(controller)
}
default:
break
}
}
}
})
}
func openBotForumMoreMenu(sourceView: UIView, gesture: ContextGesture?) {
guard let peerId = self.chatLocation.peerId else {
return
}
let strings = self.presentationData.strings
var items: [ContextMenuItem] = []
if let _ = self.chatLocation.threadId {
} else {
items.append(.action(ContextMenuActionItem(text: strings.Conversation_ContextMenuOpenProfile, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, f in
f(.default)
guard let self, let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer else {
return
}
guard let controller = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else {
return
}
(self.navigationController as? NavigationController)?.pushViewController(controller)
})))
}
items.append(.separator)
items.append(.action(ContextMenuActionItem(text: strings.Conversation_Search, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Search"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] action in
action.dismissWithResult(.default)
self?.beginMessageSearch("")
})))
if let threadId = self.chatLocation.threadId, let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer, (peer is TelegramChannel || peer is TelegramGroup) {
items.append(.action(ContextMenuActionItem(text: strings.CreateTopic_EditTitle, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] action in
guard let self else {
return
}
Task { @MainActor [weak self] in
guard let self else {
return
}
guard let threadData = await self.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.ThreadData(id: peerId, threadId: threadId)
).get() else {
return
}
action.dismissWithResult(.default)
let controller = ForumCreateTopicScreen(context: self.context, peerId: peerId, mode: .edit(threadId: threadId, threadInfo: threadData.info, isHidden: threadData.isHidden))
controller.navigationPresentation = .modal
controller.completion = { [weak self, weak controller] title, fileId, _, isHidden in
guard let self else {
return
}
let _ = (self.context.engine.peers.editForumChannelTopic(id: peerId, threadId: threadId, title: title, iconFileId: fileId)
|> deliverOnMainQueue).startStandalone(completed: {
controller?.dismiss()
})
if let isHidden {
let _ = (self.context.engine.peers.setForumChannelTopicHidden(id: peerId, threadId: threadId, isHidden: isHidden)
|> deliverOnMainQueue).startStandalone(completed: {
controller?.dismiss()
})
}
}
self.push(controller)
}
})))
} else {
items.append(.action(ContextMenuActionItem(text: strings.Chat_CreateTopic, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] action in
guard let self else {
return
}
action.dismissWithResult(.default)
let controller = ForumCreateTopicScreen(context: self.context, peerId: peerId, mode: .create)
controller.navigationPresentation = .modal
controller.completion = { [weak self, weak controller] title, fileId, iconColor, _ in
controller?.isInProgress = true
controller?.view.endEditing(true)
guard let self else {
return
}
let _ = (self.context.engine.peers.createForumChannelTopic(id: peerId, title: title, iconColor: iconColor, iconFileId: fileId)
|> deliverOnMainQueue).startStandalone(next: { [weak self, weak controller] topicId in
guard let self else {
return
}
self.updateChatLocationThread(threadId: topicId)
controller?.dismiss()
}, error: { _ in
controller?.isInProgress = false
})
}
self.push(controller)
})))
}
let presentationData = self.presentationData
let contextController = ContextController(presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: self, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture)
self.presentInGlobalOverlay(contextController)
}
}
private final class HeaderContextReferenceContentSource: ContextReferenceContentSource {
private let controller: ViewController
private let sourceView: UIView
init(controller: ViewController, sourceView: UIView) {
self.controller = controller
self.sourceView = sourceView
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
@@ -0,0 +1,265 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import TelegramNotices
import ContextUI
import AccountContext
import ChatMessageItemView
import ChatMessageItemCommon
import AvatarNode
import UndoUI
import MessageUI
import PeerInfoUI
import ChatControllerInteraction
extension ChatControllerImpl: MFMessageComposeViewControllerDelegate {
func openPhoneContextMenu(number: String, params: ChatControllerInteraction.LongTapParams) -> Void {
guard let message = params.message, let contentNode = params.contentNode else {
return
}
guard let messages = self.chatDisplayNode.historyNode.messageGroupInCurrentHistoryView(message.id) else {
return
}
var updatedMessages = messages
for i in 0 ..< updatedMessages.count {
if updatedMessages[i].id == message.id {
let message = updatedMessages.remove(at: i)
updatedMessages.insert(message, at: 0)
break
}
}
let recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil// anyRecognizer as? TapLongTapOrDoubleTapGestureRecognizer
let gesture: ContextGesture? = nil // anyRecognizer as? ContextGesture
let source: ContextContentSource
// if let location = location {
// source = .location(ChatMessageContextLocationContentSource(controller: self, location: messageNode.view.convert(messageNode.bounds, to: nil).origin.offsetBy(dx: location.x, dy: location.y)))
// } else {
source = .extracted(ChatMessageLinkContextExtractedContentSource(chatNode: self.chatDisplayNode, contentNode: contentNode))
// }
params.progress?.set(.single(true))
let _ = (self.context.engine.peers.resolvePeerByPhone(phone: number)
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let self else {
return
}
params.progress?.set(.single(false))
var firstName = ""
var lastName = ""
let phoneNumber: String
if let peer, case let .user(user) = peer, let phone = user.phone {
phoneNumber = "+\(phone)"
} else {
phoneNumber = number
}
if case let .user(user) = peer {
firstName = user.firstName ?? ""
lastName = user.lastName ?? ""
}
var items: [ContextMenuItem] = []
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Phone_AddToContacts, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddUser"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in
guard let self, let c else {
return
}
let basicData = DeviceContactBasicData(firstName: firstName, lastName: lastName, phoneNumbers: [
DeviceContactPhoneNumberData(label: "", value: phoneNumber)
])
let contactData = DeviceContactExtendedData(basicData: basicData, middleName: "", prefix: "", suffix: "", organization: "", jobTitle: "", department: "", emailAddresses: [], urls: [], addresses: [], birthdayDate: nil, socialProfiles: [], instantMessagingProfiles: [], note: "")
pushContactContextOptionsController(context: self.context, contextController: c, presentationData: self.presentationData, peer: nil, contactData: contactData, parentController: self, push: { [weak self] c in
self?.push(c)
})
}))
)
items.append(.separator)
if let peer {
if peer.id == self.context.account.peerId {
} else {
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Phone_SendMessage, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/MessageBubble"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.openPeer(peer: peer, navigation: .chat(textInputState: nil, subject: nil, peekData: nil), fromMessage: nil)
}))
)
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Phone_TelegramVoiceCall, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Call"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.controllerInteraction?.callPeer(peer.id, false)
}))
)
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Phone_TelegramVideoCall, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/VideoCall"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.controllerInteraction?.callPeer(peer.id, true)
}))
)
}
} else {
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Phone_InviteToTelegram, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Telegram"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.inviteToTelegram(numbers: [number])
}))
)
}
if number.hasPrefix("+888") {
} else {
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Phone_CallViaCarrier, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/PhoneCall"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.openUrl("tel:\(phoneNumber)", concealed: false)
}))
)
}
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Phone_CopyNumber, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
UIPasteboard.general.string = number
self.present(UndoOverlayController(presentationData: self.presentationData, content: .copy(text: presentationData.strings.Conversation_PhoneCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}))
)
items.append(.separator)
if let peer {
let avatarSize = CGSize(width: 28.0, height: 28.0)
let avatarSignal = peerAvatarCompleteImage(account: self.context.account, peer: peer, size: avatarSize)
let subtitle = NSMutableAttributedString(string: self.presentationData.strings.Chat_Context_Phone_ViewProfile + " >")
if let range = subtitle.string.range(of: ">"), let arrowImage = UIImage(bundleImageName: "Item List/InlineTextRightArrow") {
subtitle.addAttribute(.attachment, value: arrowImage, range: NSRange(range, in: subtitle.string))
subtitle.addAttribute(.baselineOffset, value: 1.0, range: NSRange(range, in: subtitle.string))
}
items.append(
.action(ContextMenuActionItem(text: peer.displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder), textLayout: .secondLineWithAttributedValue(subtitle), icon: { theme in return nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: avatarSignal), iconPosition: .left, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.openPeer(peer: peer, navigation: .info(ChatControllerInteractionNavigateToPeer.InfoParams(ignoreInSavedMessages: true)), fromMessage: nil)
}))
)
} else {
let emptyAction: ((ContextMenuActionItem.Action) -> Void)? = nil
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Phone_NotOnTelegram, textLayout: .multiline, textFont: .small, icon: { _ in return nil }, action: emptyAction))
)
}
self.canReadHistory.set(false)
let controller = ContextController(presentationData: self.presentationData, source: source, items: .single(ContextController.Items(content: .list(items))), recognizer: recognizer, gesture: gesture, disableScreenshots: false)
controller.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
self.window?.presentInGlobalOverlay(controller)
})
}
private func inviteToTelegram(numbers: [String]) {
if MFMessageComposeViewController.canSendText() {
let composer = MFMessageComposeViewController()
composer.messageComposeDelegate = self
composer.recipients = Array(Set(numbers))
let url = self.presentationData.strings.InviteText_URL
let body = self.presentationData.strings.InviteText_SingleContact(url).string
composer.body = body
self.messageComposeController = composer
if let window = self.view.window {
window.rootViewController?.present(composer, animated: true)
}
}
}
@objc public func messageComposeViewController(_ controller: MFMessageComposeViewController, didFinishWith result: MessageComposeResult) {
self.messageComposeController = nil
controller.dismiss(animated: true, completion: nil)
}
}
final class ChatMessageLinkContextExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool = false
let ignoreContentTouches: Bool = true
let blurBackground: Bool = true
let adjustContentHorizontally = true
private weak var chatNode: ChatControllerNode?
private let contentNode: ContextExtractedContentContainingNode
var shouldBeDismissed: Signal<Bool, NoError> {
return .single(false)
}
init(chatNode: ChatControllerNode, contentNode: ContextExtractedContentContainingNode) {
self.chatNode = chatNode
self.contentNode = contentNode
}
func takeView() -> ContextControllerTakeViewInfo? {
guard let chatNode = self.chatNode else {
return nil
}
let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut)
transition.updateAlpha(node: self.contentNode.contentNode, alpha: 1.0)
return ContextControllerTakeViewInfo(containingItem: .node(self.contentNode), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
}
func putBack() -> ContextControllerPutBackViewInfo? {
guard let chatNode = self.chatNode else {
return nil
}
let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut)
transition.updateAlpha(node: self.contentNode.contentNode, alpha: 0.0, completion: { _ in
self.contentNode.removeFromSupernode()
})
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
}
}
@@ -0,0 +1,122 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import ContextUI
import UndoUI
import AccountContext
import ChatMessageItemView
import ChatMessageItemCommon
import ChatControllerInteraction
extension ChatControllerImpl {
func openTimecodeContextMenu(timecode: String, value: Double, params: ChatControllerInteraction.LongTapParams) -> Void {
guard let message = params.message, let contentNode = params.contentNode else {
return
}
guard let messages = self.chatDisplayNode.historyNode.messageGroupInCurrentHistoryView(message.id) else {
return
}
var updatedMessages = messages
for i in 0 ..< updatedMessages.count {
if updatedMessages[i].id == message.id {
let message = updatedMessages.remove(at: i)
updatedMessages.insert(message, at: 0)
break
}
}
let recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil// anyRecognizer as? TapLongTapOrDoubleTapGestureRecognizer
let gesture: ContextGesture? = nil // anyRecognizer as? ContextGesture
let source: ContextContentSource
// if let location = location {
// source = .location(ChatMessageContextLocationContentSource(controller: self, location: messageNode.view.convert(messageNode.bounds, to: nil).origin.offsetBy(dx: location.x, dy: location.y)))
// } else {
source = .extracted(ChatMessageLinkContextExtractedContentSource(chatNode: self.chatDisplayNode, contentNode: contentNode))
// }
var items: [ContextMenuItem] = []
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Timecode_Copy, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
if message.id.namespace == Namespaces.Message.Cloud, let channel = message.peers[message.id.peerId] as? TelegramChannel, let addressName = channel.addressName {
var timestampSuffix = ""
let startAtTimestamp = parseTimeString(timecode)
var startAtTimestampString = ""
let hours = startAtTimestamp / 3600
let minutes = startAtTimestamp / 60 % 60
let seconds = startAtTimestamp % 60
if hours == 0 && minutes == 0 {
startAtTimestampString = "\(startAtTimestamp)"
} else {
if hours != 0 {
startAtTimestampString += "\(hours)h"
}
if minutes != 0 {
startAtTimestampString += "\(minutes)m"
}
if seconds != 0 {
startAtTimestampString += "\(seconds)s"
}
}
timestampSuffix = "?t=\(startAtTimestampString)"
let inputCopyText = "https://t.me/\(addressName)/\(message.id.id)\(timestampSuffix)"
UIPasteboard.general.string = inputCopyText
} else {
UIPasteboard.general.string = timecode
}
self.present(UndoOverlayController(presentationData: self.presentationData, content: .copy(text: presentationData.strings.Conversation_TextCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}))
)
self.canReadHistory.set(false)
let controller = ContextController(presentationData: self.presentationData, source: source, items: .single(ContextController.Items(content: .list(items))), recognizer: recognizer, gesture: gesture, disableScreenshots: false)
controller.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
self.window?.presentInGlobalOverlay(controller)
}
}
private func parseTimeString(_ timeString: String) -> Int {
let parts = timeString.split(separator: ":").map(String.init)
switch parts.count {
case 1:
// Single component (e.g. "1", "10") => seconds
return Int(parts[0]) ?? 0
case 2:
// Two components (e.g. "1:01", "10:30") => minutes:seconds
let minutes = Int(parts[0]) ?? 0
let seconds = Int(parts[1]) ?? 0
return minutes * 60 + seconds
case 3:
// Three components (e.g. "1:01:01", "10:00:00") => hours:minutes:seconds
let hours = Int(parts[0]) ?? 0
let minutes = Int(parts[1]) ?? 0
let seconds = Int(parts[2]) ?? 0
return hours * 3600 + minutes * 60 + seconds
default:
// Fallback to 0 or handle invalid format
return 0
}
}
@@ -0,0 +1,308 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import ContextUI
import UndoUI
import AccountContext
import ChatMessageItemView
import ChatMessageItemCommon
import AvatarNode
import ChatControllerInteraction
import Pasteboard
import TelegramStringFormatting
import TelegramPresentationData
private enum OptionsId: Hashable {
case item
case message
}
extension ChatControllerImpl {
func openTodoItemContextMenu(todoItemId: Int32, params: ChatControllerInteraction.LongTapParams) -> Void {
guard let message = params.message, let todo = message.media.first(where: { $0 is TelegramMediaTodo }) as? TelegramMediaTodo, let todoItem = todo.items.first(where: { $0.id == todoItemId }), let contentNode = params.contentNode else {
return
}
let completion = todo.completions.first(where: { $0.id == todoItemId })
// let recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil// anyRecognizer as? TapLongTapOrDoubleTapGestureRecognizer
// let gesture: ContextGesture? = nil // anyRecognizer as? ContextGesture
var canMark = false
if (todo.flags.contains(.othersCanComplete) || message.author?.id == context.account.peerId) {
canMark = true
}
let canEdit = canEditMessage(context: self.context, limitsConfiguration: self.context.currentLimitsConfiguration.with { EngineConfiguration.Limits($0) }, message: message)
let _ = (contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState: self.presentationInterfaceState, context: self.context, messages: [message], controllerInteraction: self.controllerInteraction, selectAll: false, interfaceInteraction: self.interfaceInteraction, messageNode: params.messageNode as? ChatMessageItemView)
|> deliverOnMainQueue).start(next: { [weak self] actions in
guard let self else {
return
}
var items: [ContextMenuItem] = []
if let completion {
let dateText = humanReadableStringForTimestamp(strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, timestamp: completion.date, alwaysShowTime: true, allowYesterday: true, format: HumanReadableStringFormat(
dateFormatString: { value in
return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_TodoItemCompletionTimestamp_Date(value).string, ranges: [])
},
tomorrowFormatString: { value in
return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_TodoItemCompletionTimestamp_TodayAt(value).string, ranges: [])
},
todayFormatString: { value in
return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_TodoItemCompletionTimestamp_TodayAt(value).string, ranges: [])
},
yesterdayFormatString: { value in
return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_TodoItemCompletionTimestamp_YesterdayAt(value).string, ranges: [])
}
)).string
let nop: ((ContextMenuActionItem.Action) -> Void)? = nil
items.append(.action(ContextMenuActionItem(text: dateText, textFont: .small, icon: { _ in return nil }, action: nop)))
items.append(.separator)
if canMark {
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Todo_ContextMenu_UncheckTask, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in
guard let self else {
return
}
if !self.context.isPremium {
f(.default)
let controller = UndoOverlayController(
presentationData: self.presentationData,
content: .premiumPaywall(title: nil, text: self.presentationData.strings.Chat_Todo_PremiumRequired, customUndoText: nil, timeout: nil, linkAction: nil),
action: { [weak self] action in
guard let self else {
return false
}
if case .info = action {
let controller = self.context.sharedContext.makePremiumIntroController(context: context, source: .presence, forceDark: false, dismissed: nil)
self.push(controller)
}
return false
}
)
self.present(controller, in: .current)
} else {
c?.dismiss(completion: {
let _ = self.context.engine.messages.requestUpdateTodoMessageItems(messageId: message.id, completedIds: [], incompletedIds: [todoItemId]).start()
})
}
})))
}
} else {
if canMark {
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Todo_ContextMenu_CheckTask, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in
guard let self else {
return
}
if !self.context.isPremium {
f(.default)
let controller = UndoOverlayController(
presentationData: self.presentationData,
content: .premiumPaywall(title: nil, text: self.presentationData.strings.Chat_Todo_PremiumRequired, customUndoText: nil, timeout: nil, linkAction: nil),
action: { [weak self] action in
guard let self else {
return false
}
if case .info = action {
let controller = self.context.sharedContext.makePremiumIntroController(context: context, source: .presence, forceDark: false, dismissed: nil)
self.push(controller)
}
return false
}
)
self.present(controller, in: .current)
} else {
c?.dismiss(completion: {
let _ = self.context.engine.messages.requestUpdateTodoMessageItems(messageId: message.id, completedIds: [todoItemId], incompletedIds: []).start()
})
}
})))
}
}
if canReplyInChat(self.presentationInterfaceState, accountPeerId: self.context.account.peerId) {
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Todo_ReplyToItem, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Reply"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] c, _ in
guard let self else {
return
}
self.interfaceInteraction?.setupReplyMessage(message.id, todoItem.id, { transition, completed in
c?.dismiss(result: .custom(transition), completion: {
completed()
})
})
})))
}
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Conversation_ContextMenuCopy, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
storeMessageTextInPasteboard(todoItem.text, entities: todoItem.entities)
self.present(UndoOverlayController(presentationData: self.presentationData, content: .copy(text: presentationData.strings.Conversation_TextCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
})))
var isReplyThreadHead = false
if case let .replyThread(replyThreadMessage) = self.presentationInterfaceState.chatLocation {
isReplyThreadHead = message.id == replyThreadMessage.effectiveTopId
}
if message.id.namespace == Namespaces.Message.Cloud, let channel = message.peers[message.id.peerId] as? TelegramChannel, !channel.isMonoForum, !isReplyThreadHead {
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Conversation_ContextMenuCopyLink, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, f in
guard let self else {
return
}
var threadMessageId: MessageId?
if case let .replyThread(replyThreadMessage) = self.presentationInterfaceState.chatLocation {
threadMessageId = replyThreadMessage.effectiveMessageId
}
let _ = (self.context.engine.messages.exportMessageLink(peerId: message.id.peerId, messageId: message.id, isThread: threadMessageId != nil)
|> map { result -> String? in
return result
}
|> deliverOnMainQueue).startStandalone(next: { [weak self] link in
guard let self, let link else {
return
}
UIPasteboard.general.string = link + "?task=\(todoItemId)"
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
var warnAboutPrivate = false
if case .peer = self.presentationInterfaceState.chatLocation {
if channel.addressName == nil {
warnAboutPrivate = true
}
}
Queue.mainQueue().after(0.2, {
if warnAboutPrivate {
self.controllerInteraction?.displayUndo(.linkCopied(title: nil, text: presentationData.strings.Conversation_PrivateMessageLinkCopiedLong))
} else {
self.controllerInteraction?.displayUndo(.linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied))
}
})
})
f(.default)
})))
}
if canEdit {
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Todo_ContextMenu_EditTask, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.interfaceInteraction?.editTodoMessage(message.id, todoItemId, false)
})))
if todo.items.count > 1 {
items.append(.separator)
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Todo_ContextMenu_DeleteTask, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
let updatedItems = todo.items.filter { $0.id != todoItemId }
let updatedTodo = todo.withUpdated(items: updatedItems)
let _ = self.context.engine.messages.requestEditMessage(
messageId: message.id,
text: "",
media: .update(.standalone(media: updatedTodo)),
entities: nil,
inlineStickers: [:]
).start()
})))
}
}
self.canReadHistory.set(false)
var sources: [ContextController.Source] = []
sources.append(
ContextController.Source(
id: AnyHashable(OptionsId.item),
title: self.presentationData.strings.Chat_Todo_ContextMenu_SectionTask,
footer: self.presentationData.strings.Chat_Todo_ContextMenu_SectionsInfo,
source: .extracted(ChatTodoItemContextExtractedContentSource(chatNode: self.chatDisplayNode, contentNode: contentNode)),
items: .single(ContextController.Items(content: .list(items)))
)
)
let messageContentSource = ChatMessageContextExtractedContentSource(chatController: self, chatNode: self.chatDisplayNode, engine: self.context.engine, message: message, selectAll: false, snapshot: true)
sources.append(
ContextController.Source(
id: AnyHashable(OptionsId.message),
title: self.presentationData.strings.Chat_Todo_ContextMenu_SectionList,
source: .extracted(messageContentSource),
items: .single(actions)
)
)
contentNode.onDismiss = { [weak messageContentSource] in
messageContentSource?.snapshotView?.removeFromSuperview()
}
let contextController = ContextController(
presentationData: self.presentationData,
configuration: ContextController.Configuration(
sources: sources,
initialId: AnyHashable(OptionsId.item)
)
)
contextController.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
self.window?.presentInGlobalOverlay(contextController)
})
}
}
final class ChatTodoItemContextExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool = false
let ignoreContentTouches: Bool = false
let blurBackground: Bool = true
private weak var chatNode: ChatControllerNode?
private let contentNode: ContextExtractedContentContainingNode
init(chatNode: ChatControllerNode, contentNode: ContextExtractedContentContainingNode) {
self.chatNode = chatNode
self.contentNode = contentNode
}
func takeView() -> ContextControllerTakeViewInfo? {
guard let chatNode = self.chatNode else {
return nil
}
return ContextControllerTakeViewInfo(containingItem: .node(self.contentNode), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
}
func putBack() -> ContextControllerPutBackViewInfo? {
guard let chatNode = self.chatNode else {
return nil
}
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
}
}
@@ -0,0 +1,148 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import ContextUI
import UndoUI
import AccountContext
import ChatMessageItemView
import ChatMessageItemCommon
import AvatarNode
import ChatControllerInteraction
extension ChatControllerImpl {
func openMentionContextMenu(username: String, peerId: EnginePeer.Id?, params: ChatControllerInteraction.LongTapParams) -> Void {
guard let _ = params.message, let contentNode = params.contentNode else {
return
}
let recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil// anyRecognizer as? TapLongTapOrDoubleTapGestureRecognizer
let gesture: ContextGesture? = nil // anyRecognizer as? ContextGesture
let source: ContextContentSource
// if let location = location {
// source = .location(ChatMessageContextLocationContentSource(controller: self, location: messageNode.view.convert(messageNode.bounds, to: nil).origin.offsetBy(dx: location.x, dy: location.y)))
// } else {
source = .extracted(ChatMessageLinkContextExtractedContentSource(chatNode: self.chatDisplayNode, contentNode: contentNode))
// }
params.progress?.set(.single(true))
let peer: Signal<EnginePeer?, NoError>
if let peerId {
peer = self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
} else {
peer = self.context.engine.peers.resolvePeerByName(name: username, referrer: nil)
|> mapToSignal { value in
switch value {
case .progress:
return .complete()
case let .result(result):
return .single(result)
}
}
}
let _ = (peer
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let self else {
return
}
params.progress?.set(.single(false))
var items: [ContextMenuItem] = []
if let peer {
if case .user = peer {
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Username_SendMessage, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/MessageBubble"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.openPeer(peer: peer, navigation: .chat(textInputState: nil, subject: nil, peekData: nil), fromMessage: nil)
}))
)
} else {
var isGroup = true
if case let .channel(channel) = peer, case .broadcast = channel.info {
isGroup = false
}
let openTitle: String
let openIcon: UIImage?
if isGroup {
openTitle = self.presentationData.strings.Chat_Context_Username_OpenGroup
openIcon = UIImage(bundleImageName: "Chat/Context Menu/Groups")
} else {
openTitle = self.presentationData.strings.Chat_Context_Username_OpenChannel
openIcon = UIImage(bundleImageName: "Chat/Context Menu/Channels")
}
items.append(
.action(ContextMenuActionItem(text: openTitle, icon: { theme in return generateTintedImage(image: openIcon, color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.openPeer(peer: peer, navigation: .chat(textInputState: nil, subject: nil, peekData: nil), fromMessage: nil)
}))
)
}
}
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Username_Copy, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
UIPasteboard.general.string = username
self.present(UndoOverlayController(presentationData: self.presentationData, content: .copy(text: presentationData.strings.Conversation_UsernameCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}))
)
items.append(.separator)
if let peer {
let avatarSize = CGSize(width: 28.0, height: 28.0)
let avatarSignal = peerAvatarCompleteImage(account: self.context.account, peer: peer, size: avatarSize)
let subtitle = NSMutableAttributedString(string: self.presentationData.strings.Chat_Context_Phone_ViewProfile + " >")
if let range = subtitle.string.range(of: ">"), let arrowImage = UIImage(bundleImageName: "Item List/InlineTextRightArrow") {
subtitle.addAttribute(.attachment, value: arrowImage, range: NSRange(range, in: subtitle.string))
subtitle.addAttribute(.baselineOffset, value: 1.0, range: NSRange(range, in: subtitle.string))
}
items.append(
.action(ContextMenuActionItem(text: peer.displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder), textLayout: .secondLineWithAttributedValue(subtitle), icon: { theme in return nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: avatarSignal), iconPosition: .left, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.openPeer(peer: peer, navigation: .info(ChatControllerInteractionNavigateToPeer.InfoParams(ignoreInSavedMessages: true)), fromMessage: nil)
}))
)
} else {
let emptyAction: ((ContextMenuActionItem.Action) -> Void)? = nil
items.append(
.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Username_NotOnTelegram, textLayout: .multiline, textFont: .small, icon: { _ in return nil }, action: emptyAction))
)
}
self.canReadHistory.set(false)
let controller = ContextController(presentationData: self.presentationData, source: source, items: .single(ContextController.Items(content: .list(items))), recognizer: recognizer, gesture: gesture, disableScreenshots: false)
controller.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
self.window?.presentInGlobalOverlay(controller)
})
}
}
@@ -0,0 +1,182 @@
import Foundation
import UIKit
import Postbox
import SwiftSignalKit
import Display
import AsyncDisplayKit
import TelegramCore
import SafariServices
import MobileCoreServices
import Intents
import LegacyComponents
import TelegramPresentationData
import TelegramUIPreferences
import DeviceAccess
import TextFormat
import TelegramBaseController
import AccountContext
import TelegramStringFormatting
import OverlayStatusController
import DeviceLocationManager
import ShareController
import UrlEscaping
import ContextUI
import ComposePollUI
import AlertUI
import PresentationDataUtils
import UndoUI
import TelegramCallsUI
import TelegramNotices
import GameUI
import ScreenCaptureDetection
import GalleryUI
import OpenInExternalAppUI
import LegacyUI
import InstantPageUI
import LocationUI
import BotPaymentsUI
import DeleteChatPeerActionSheetItem
import HashtagSearchUI
import LegacyMediaPickerUI
import Emoji
import PeerAvatarGalleryUI
import PeerInfoUI
import RaiseToListen
import UrlHandling
import AvatarNode
import AppBundle
import LocalizedPeerData
import PhoneNumberFormat
import SettingsUI
import UrlWhitelist
import TelegramIntents
import TooltipUI
import StatisticsUI
import MediaResources
import GalleryData
import ChatInterfaceState
import InviteLinksUI
import Markdown
import TelegramPermissionsUI
import Speak
import TranslateUI
import UniversalMediaPlayer
import WallpaperBackgroundNode
import ChatListUI
import CalendarMessageScreen
import ReactionSelectionNode
import ReactionListContextMenuContent
import AttachmentUI
import AttachmentTextInputPanelNode
import MediaPickerUI
import ChatPresentationInterfaceState
import Pasteboard
import ChatSendMessageActionUI
import ChatTextLinkEditUI
import WebUI
import PremiumUI
import ImageTransparency
import StickerPackPreviewUI
import TextNodeWithEntities
import EntityKeyboard
import ChatTitleView
import EmojiStatusComponent
import ChatTimerScreen
import MediaPasteboardUI
import ChatListHeaderComponent
import ChatControllerInteraction
import FeaturedStickersScreen
import ChatEntityKeyboardInputNode
import StorageUsageScreen
import AvatarEditorScreen
import ChatScheduleTimeController
import ICloudResources
import StoryContainerScreen
import MoreHeaderButton
import VolumeButtons
import ChatAvatarNavigationNode
import ChatContextQuery
import PeerReportScreen
import PeerSelectionController
import SaveToCameraRoll
import ChatMessageDateAndStatusNode
import ReplyAccessoryPanelNode
import TextSelectionNode
import ChatMessagePollBubbleContentNode
import ChatMessageItem
import ChatMessageItemImpl
import ChatMessageItemView
import ChatMessageItemCommon
import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen
import WallpaperGridScreen
import VideoMessageCameraScreen
import TopMessageReactions
import AudioWaveform
import PeerNameColorScreen
import ChatEmptyNode
import ChatMediaInputStickerGridItem
import AdsInfoScreen
extension ChatControllerImpl {
func openViewOnceMediaMessage(_ message: Message) {
if self.screenCaptureManager?.isRecordingActive == true {
let controller = textAlertController(context: self.context, updatedPresentationData: self.updatedPresentationData, title: nil, text: self.presentationData.strings.Chat_PlayOnceMesasge_DisableScreenCapture, actions: [TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_OK, action: {
})])
self.present(controller, in: .window(.root))
return
}
let isIncoming = message.effectivelyIncoming(self.context.account.peerId)
var presentImpl: ((ViewController) -> Void)?
let configuration = ContextController.Configuration(
sources: [
ContextController.Source(
id: 0,
title: "",
source: .extracted(ChatViewOnceMessageContextExtractedContentSource(
context: self.context,
presentationData: self.presentationData,
chatNode: self.chatDisplayNode,
backgroundNode: self.chatBackgroundNode,
engine: self.context.engine,
message: message,
present: { c in
presentImpl?(c)
}
)),
items: .single(ContextController.Items(content: .list([]))),
closeActionTitle: isIncoming ? self.presentationData.strings.Chat_PlayOnceMesasgeCloseAndDelete : self.presentationData.strings.Chat_PlayOnceMesasgeClose,
closeAction: { [weak self] in
if let self {
self.context.sharedContext.mediaManager.setPlaylist(nil, type: .voice, control: .playback(.pause))
}
}
)
], initialId: 0
)
let contextController = ContextController(presentationData: self.presentationData, configuration: configuration)
contextController.getOverlayViews = { [weak self] in
guard let self else {
return []
}
return [self.chatDisplayNode.navigateButtons.view]
}
self.currentContextController = contextController
self.presentInGlobalOverlay(contextController)
presentImpl = { [weak contextController] c in
contextController?.present(c, in: .current)
}
let _ = self.context.sharedContext.openChatMessage(OpenChatMessageParams(context: self.context, chatLocation: nil, chatFilterTag: nil, chatLocationContextHolder: nil, message: message, standalone: false, reverseMessageGalleryOrder: false, navigationController: nil, dismissInput: { }, present: { _, _, _ in }, transitionNode: { _, _, _ in return nil }, addToTransitionSurface: { _ in }, openUrl: { _ in }, openPeer: { _, _ in }, callPeer: { _, _ in }, openConferenceCall: { _ in
}, enqueueMessage: { _ in }, sendSticker: nil, sendEmoji: nil, setupTemporaryHiddenMedia: { _, _, _ in }, chatAvatarHiddenMedia: { _, _ in }, playlistLocation: .singleMessage(message.id)))
}
}
@@ -0,0 +1,718 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramCore
import ChatPresentationInterfaceState
import ChatControllerInteraction
import WebUI
import AttachmentUI
import AccountContext
import TelegramNotices
import PresentationDataUtils
import UndoUI
import UrlHandling
import TelegramPresentationData
import ChatInterfaceState
func openWebAppImpl(
context: AccountContext,
parentController: ViewController,
updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?,
botPeer: EnginePeer,
chatPeer: EnginePeer?,
threadId: Int64?,
buttonText: String,
url: String,
simple: Bool,
source: ChatOpenWebViewSource,
skipTermsOfService: Bool,
payload: String?,
verifyAgeCompletion: ((Int) -> Void)?
) {
if context.isFrozen {
parentController.push(context.sharedContext.makeAccountFreezeInfoScreen(context: context))
return
}
let presentationData: PresentationData
if let parentController = parentController as? ChatControllerImpl {
presentationData = parentController.presentationData
} else {
presentationData = context.sharedContext.currentPresentationData.with({ $0 })
}
var skipTermsOfService = skipTermsOfService
if let whiteListedBots = context.currentAppConfiguration.with({ $0 }).data?["whitelisted_bots"] as? [Double] {
let botId = botPeer.id.id._internalGetInt64Value()
for bot in whiteListedBots {
if Int64(bot) == botId {
skipTermsOfService = true
}
}
}
let botName: String
let botAddress: String
let botVerified: Bool
if case let .inline(bot) = source {
botName = bot.compactDisplayTitle
botAddress = bot.addressName ?? ""
botVerified = bot.isVerified
} else {
botName = botPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
botAddress = botPeer.addressName ?? ""
botVerified = botPeer.isVerified
}
if source == .generic {
if let parentController = parentController as? ChatControllerImpl {
parentController.updateChatPresentationInterfaceState(animated: true, interactive: true, {
return $0.updatedTitlePanelContext {
if !$0.contains(where: {
switch $0 {
case .requestInProgress:
return true
default:
return false
}
}) {
var updatedContexts = $0
updatedContexts.append(.requestInProgress)
return updatedContexts.sorted()
}
return $0
}
})
}
}
let updateProgress = { [weak parentController] in
Queue.mainQueue().async {
if let parentController = parentController as? ChatControllerImpl {
parentController.updateChatPresentationInterfaceState(animated: true, interactive: true, {
return $0.updatedTitlePanelContext {
if let index = $0.firstIndex(where: {
switch $0 {
case .requestInProgress:
return true
default:
return false
}
}) {
var updatedContexts = $0
updatedContexts.remove(at: index)
return updatedContexts
}
return $0
}
})
}
}
}
var botPeer = botPeer
if case let .inline(bot) = source {
botPeer = bot
}
let _ = combineLatest(queue: Queue.mainQueue(),
context.engine.data.get(TelegramEngine.EngineData.Item.Peer.BotAppSettings(id: botPeer.id)),
ApplicationSpecificNotice.getBotGameNotice(accountManager: context.sharedContext.accountManager, peerId: botPeer.id),
context.engine.messages.attachMenuBots(),
context.engine.messages.getAttachMenuBot(botId: botPeer.id, cached: true)
|> map(Optional.init)
|> `catch` { _ -> Signal<AttachMenuBot?, NoError> in
return .single(nil)
}
).start(next: { appSettings, noticed, attachMenuBots, attachMenuBot in
let openWebView: (Bool) -> Void = { [weak parentController] justInstalled in
guard let parentController else {
return
}
if source == .menu {
if let parentController = parentController as? ChatControllerImpl {
parentController.updateChatPresentationInterfaceState(interactive: false) { state in
return state.updatedForceInputCommandsHidden(true)
}
}
if let navigationController = parentController.navigationController as? NavigationController, let minimizedContainer = navigationController.minimizedContainer {
for controller in minimizedContainer.controllers {
if let controller = controller as? AttachmentController, let mainController = controller.mainController as? WebAppController, mainController.botId == botPeer.id && mainController.source == .menu {
navigationController.maximizeViewController(controller, animated: true)
return
}
}
}
var fullSize = false
var isFullscreen = false
if isTelegramMeLink(url), let internalUrl = parseFullInternalUrl(sharedContext: context.sharedContext, context: context, url: url), case .peer(_, .appStart) = internalUrl {
if url.contains("mode=fullscreen") {
isFullscreen = true
fullSize = true
} else {
fullSize = !url.contains("mode=compact")
}
}
var hasWebApp = false
if case let .user(user) = botPeer, let botInfo = user.botInfo, botInfo.flags.contains(.hasWebApp) {
hasWebApp = true
}
var presentImpl: ((ViewController, Any?) -> Void)?
let params = WebAppParameters(source: .menu, peerId: chatPeer?.id ?? botPeer.id, botId: botPeer.id, botName: botName, botVerified: botVerified, botAddress: botPeer.addressName ?? "", appName: hasWebApp ? "" : nil, url: url, queryId: nil, payload: nil, buttonText: buttonText, keepAliveSignal: nil, forceHasSettings: false, fullSize: fullSize, isFullscreen: isFullscreen, appSettings: appSettings)
let controller = standaloneWebAppController(context: context, updatedPresentationData: updatedPresentationData, params: params, threadId: threadId, openUrl: { [weak parentController] url, concealed, forceUpdate, commit in
ChatControllerImpl.botOpenUrl(context: context, peerId: chatPeer?.id ?? botPeer.id, controller: parentController as? ChatControllerImpl, url: url, concealed: concealed, forceUpdate: forceUpdate, present: { c, a in
presentImpl?(c, a)
}, commit: commit)
}, requestSwitchInline: { [weak parentController] query, chatTypes, completion in
ChatControllerImpl.botRequestSwitchInline(context: context, controller: parentController as? ChatControllerImpl, peerId: chatPeer?.id ?? botPeer.id, botAddress: botAddress, query: query, chatTypes: chatTypes, completion: completion)
}, getInputContainerNode: { [weak parentController] in
if let parentController = parentController as? ChatControllerImpl, let layout = parentController.validLayout, case .compact = layout.metrics.widthClass {
return (parentController.chatDisplayNode.getWindowInputAccessoryHeight(), parentController.chatDisplayNode.inputPanelContainerNode, {
guard let menuTransition = parentController.chatDisplayNode.textInputPanelNode?.makeAttachmentMenuTransition(accessoryPanelNode: nil) else {
return nil
}
return AttachmentController.InputPanelTransition(
inputNode: menuTransition.inputNode,
accessoryPanelNode: menuTransition.accessoryPanelNode,
menuButtonNode: menuTransition.menuButtonNode,
menuButtonBackgroundView: menuTransition.menuButtonBackgroundView,
menuIconNode: menuTransition.menuIconNode,
menuTextNode: menuTransition.menuTextNode,
prepareForDismiss: menuTransition.prepareForDismiss
)
})
} else {
return nil
}
}, completion: { [weak parentController] in
if let parentController = parentController as? ChatControllerImpl {
parentController.chatDisplayNode.historyNode.scrollToEndOfHistory()
}
}, willDismiss: { [weak parentController] in
if let parentController = parentController as? ChatControllerImpl {
parentController.interfaceInteraction?.updateShowWebView { _ in
return false
}
}
}, didDismiss: { [weak parentController] in
if let parentController = parentController as? ChatControllerImpl {
parentController.updateChatPresentationInterfaceState(interactive: false) { state in
return state.updatedForceInputCommandsHidden(false)
}
}
}, getNavigationController: { [weak parentController] in
var navigationController: NavigationController?
if let parentController = parentController as? ChatControllerImpl {
navigationController = parentController.effectiveNavigationController
}
return navigationController ?? (context.sharedContext.mainWindow?.viewController as? NavigationController)
})
controller.navigationPresentation = .flatModal
parentController.push(controller)
presentImpl = { [weak controller] c, a in
controller?.present(c, in: .window(.root), with: a)
}
} else if simple {
var isInline = false
var botId = botPeer.id
var botName = botName
var botAddress = botPeer.addressName ?? ""
var botVerified = botPeer.isVerified
if case let .inline(bot) = source {
isInline = true
botId = bot.id
botName = bot.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
botAddress = bot.addressName ?? ""
botVerified = bot.isVerified
}
let messageActionCallbackDisposable: MetaDisposable
if let parentController = parentController as? ChatControllerImpl {
messageActionCallbackDisposable = parentController.messageActionCallbackDisposable
} else {
messageActionCallbackDisposable = MetaDisposable()
}
let webViewSignal: Signal<RequestWebViewResult, RequestWebViewError>
let webViewSource: RequestSimpleWebViewSource
if let payload {
webViewSource = .inline(startParam: payload)
} else {
webViewSource = .generic
}
if url.isEmpty {
webViewSignal = context.engine.messages.requestMainWebView(peerId: chatPeer?.id ?? botId, botId: botId, source: webViewSource, themeParams: generateWebAppThemeParams(presentationData.theme))
} else {
webViewSignal = context.engine.messages.requestSimpleWebView(botId: botId, url: url, source: webViewSource, themeParams: generateWebAppThemeParams(presentationData.theme))
}
messageActionCallbackDisposable.set(((webViewSignal
|> afterDisposed {
updateProgress()
})
|> deliverOnMainQueue).start(next: { [weak parentController] result in
guard let parentController else {
return
}
var presentImpl: ((ViewController, Any?) -> Void)?
let source: WebAppParameters.Source
if isInline {
source = .inline
} else {
source = url.isEmpty ? .generic : .simple
}
let params = WebAppParameters(source: source, peerId: chatPeer?.id ?? botId, botId: botId, botName: botName, botVerified: botVerified, botAddress: botPeer.addressName ?? "", appName: "", url: result.url, queryId: nil, payload: payload, buttonText: buttonText, keepAliveSignal: nil, forceHasSettings: false, fullSize: result.flags.contains(.fullSize), isFullscreen: result.flags.contains(.fullScreen), appSettings: appSettings)
let controller = standaloneWebAppController(context: context, updatedPresentationData: updatedPresentationData, params: params, threadId: threadId, openUrl: { [weak parentController] url, concealed, forceUpdate, commit in
ChatControllerImpl.botOpenUrl(context: context, peerId: chatPeer?.id ?? botId, controller: parentController as? ChatControllerImpl, url: url, concealed: concealed, forceUpdate: forceUpdate, present: { c, a in
presentImpl?(c, a)
}, commit: commit)
}, requestSwitchInline: { [weak parentController] query, chatTypes, completion in
ChatControllerImpl.botRequestSwitchInline(context: context, controller: parentController as? ChatControllerImpl, peerId: chatPeer?.id ?? botId, botAddress: botAddress, query: query, chatTypes: chatTypes, completion: completion)
}, getNavigationController: { [weak parentController] in
var navigationController: NavigationController?
if let parentController = parentController as? ChatControllerImpl {
navigationController = parentController.effectiveNavigationController
}
return navigationController ?? (context.sharedContext.mainWindow?.viewController as? NavigationController)
}, verifyAgeCompletion: verifyAgeCompletion)
controller.navigationPresentation = .flatModal
parentController.push(controller)
presentImpl = { [weak controller] c, a in
controller?.present(c, in: .window(.root), with: a)
}
}, error: { [weak parentController] error in
if let parentController {
parentController.present(textAlertController(context: context, updatedPresentationData: updatedPresentationData, title: nil, text: presentationData.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {
})]), in: .window(.root))
}
}))
} else {
let messageActionCallbackDisposable: MetaDisposable
if let parentController = parentController as? ChatControllerImpl {
messageActionCallbackDisposable = parentController.messageActionCallbackDisposable
} else {
messageActionCallbackDisposable = MetaDisposable()
}
messageActionCallbackDisposable.set(((context.engine.messages.requestWebView(peerId: chatPeer?.id ?? botPeer.id, botId: botPeer.id, url: !url.isEmpty ? url : nil, payload: nil, themeParams: generateWebAppThemeParams(presentationData.theme), fromMenu: false, replyToMessageId: nil, threadId: threadId)
|> afterDisposed {
updateProgress()
})
|> deliverOnMainQueue).startStandalone(next: { [weak parentController] result in
guard let parentController else {
return
}
var hasWebApp = false
if case let .user(user) = botPeer, let botInfo = user.botInfo, botInfo.flags.contains(.hasWebApp) {
hasWebApp = true
}
var presentImpl: ((ViewController, Any?) -> Void)?
let params = WebAppParameters(source: .button, peerId: chatPeer?.id ?? botPeer.id, botId: botPeer.id, botName: botName, botVerified: botVerified, botAddress: botPeer.addressName ?? "", appName: hasWebApp ? "" : nil, url: result.url, queryId: result.queryId, payload: nil, buttonText: buttonText, keepAliveSignal: result.keepAliveSignal, forceHasSettings: false, fullSize: result.flags.contains(.fullSize), isFullscreen: result.flags.contains(.fullScreen), appSettings: appSettings)
let controller = standaloneWebAppController(context: context, updatedPresentationData: updatedPresentationData, params: params, threadId: threadId, openUrl: { [weak parentController] url, concealed, forceUpdate, commit in
ChatControllerImpl.botOpenUrl(context: context, peerId: chatPeer?.id ?? botPeer.id, controller: parentController as? ChatControllerImpl, url: url, concealed: concealed, forceUpdate: forceUpdate, present: { c, a in
presentImpl?(c, a)
}, commit: commit)
}, completion: { [weak parentController] in
if let parentController = parentController as? ChatControllerImpl {
parentController.chatDisplayNode.historyNode.scrollToEndOfHistory()
}
}, getNavigationController: { [weak parentController] in
var navigationController: NavigationController?
if let parentController = parentController as? ChatControllerImpl {
navigationController = parentController.effectiveNavigationController
}
return navigationController ?? (context.sharedContext.mainWindow?.viewController as? NavigationController)
})
controller.navigationPresentation = .flatModal
parentController.push(controller)
presentImpl = { [weak controller] c, a in
controller?.present(c, in: .window(.root), with: a)
}
if justInstalled {
let content: UndoOverlayContent = .succeed(text: presentationData.strings.WebApp_ShortcutsSettingsAdded(botPeer.compactDisplayTitle).string, timeout: 5.0, customUndoText: nil)
controller.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, position: .top, action: { _ in return false }), in: .current)
}
}, error: { [weak parentController] error in
if let parentController {
parentController.present(textAlertController(context: context, updatedPresentationData: updatedPresentationData, title: nil, text: presentationData.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {
})]), in: .window(.root))
}
}))
}
}
var isAttachMenuBotInstalled: Bool?
if let _ = attachMenuBot {
if let _ = attachMenuBots.first(where: { $0.peer.id == botPeer.id && !$0.flags.contains(.notActivated) }) {
isAttachMenuBotInstalled = true
} else {
isAttachMenuBotInstalled = false
}
}
if !noticed || attachMenuBot?.flags.contains(.notActivated) == true || isAttachMenuBotInstalled == false {
if let isAttachMenuBotInstalled, let attachMenuBot {
if !isAttachMenuBotInstalled {
let controller = webAppTermsAlertController(context: context, updatedPresentationData: updatedPresentationData, bot: attachMenuBot, completion: { allowWrite in
let _ = ApplicationSpecificNotice.setBotGameNotice(accountManager: context.sharedContext.accountManager, peerId: botPeer.id).startStandalone()
let _ = (context.engine.messages.addBotToAttachMenu(botId: botPeer.id, allowWrite: allowWrite)
|> deliverOnMainQueue).startStandalone(error: { _ in
}, completed: {
openWebView(true)
})
})
parentController.present(controller, in: .window(.root))
} else {
openWebView(false)
}
} else {
if skipTermsOfService {
openWebView(false)
} else {
let controller = webAppLaunchConfirmationController(context: context, updatedPresentationData: updatedPresentationData, peer: botPeer, completion: { _ in
let _ = ApplicationSpecificNotice.setBotGameNotice(accountManager: context.sharedContext.accountManager, peerId: botPeer.id).startStandalone()
openWebView(false)
}, showMore: nil, openTerms: {
if let navigationController = parentController.navigationController as? NavigationController {
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: presentationData.strings.WebApp_LaunchTermsConfirmation_URL, forceExternal: false, presentationData: presentationData, navigationController: navigationController, dismissInput: {})
}
})
parentController.present(controller, in: .window(.root))
}
}
} else {
openWebView(false)
}
})
}
public extension ChatControllerImpl {
func openWebApp(buttonText: String, url: String, simple: Bool, source: ChatOpenWebViewSource) {
guard let peer = self.presentationInterfaceState.renderedPeer?.peer else {
return
}
self.chatDisplayNode.dismissInput()
self.context.sharedContext.openWebApp(
context: self.context,
parentController: self,
updatedPresentationData: self.updatedPresentationData,
botPeer: EnginePeer(peer),
chatPeer: EnginePeer(peer),
threadId: self.chatLocation.threadId,
buttonText: buttonText,
url: url,
simple: simple,
source: source,
skipTermsOfService: false,
payload: nil,
verifyAgeCompletion: nil
)
}
fileprivate static func botRequestSwitchInline(context: AccountContext, controller: ChatControllerImpl?, peerId: EnginePeer.Id, botAddress: String, query: String, chatTypes: [ReplyMarkupButtonRequestPeerType]?, completion: @escaping () -> Void) -> Void {
let activateSwitchInline: (EnginePeer?) -> Void = { selectedPeer in
var chatController: ChatControllerImpl?
if let current = controller {
chatController = current
} else if let navigationController = context.sharedContext.mainWindow?.viewController as? NavigationController {
for controller in navigationController.viewControllers.reversed() {
if let controller = controller as? ChatControllerImpl {
chatController = controller
break
}
}
}
let inputString = "@\(botAddress) \(query)"
if let chatController {
chatController.controllerInteraction?.activateSwitchInline(selectedPeer?.id ?? peerId, inputString, nil)
} else if let selectedPeer, let navigationController = context.sharedContext.mainWindow?.viewController as? NavigationController {
let textInputState = ChatTextInputState(inputText: NSAttributedString(string: inputString))
let _ = (ChatInterfaceState.update(engine: context.engine, peerId: selectedPeer.id, threadId: nil, { currentState in
return currentState.withUpdatedComposeInputState(textInputState)
})
|> deliverOnMainQueue).startStandalone(completed: { [weak navigationController] in
guard let navigationController else {
return
}
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(selectedPeer), subject: nil, updateTextInputState: textInputState, peekData: nil))
})
}
}
if let chatTypes {
let peerController = context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: context, filter: [.excludeRecent, .doNotSearchMessages], requestPeerType: chatTypes, hasContactSelector: false, hasCreation: false))
peerController.peerSelected = { [weak peerController] peer, _ in
completion()
peerController?.dismiss()
activateSwitchInline(peer)
}
if let controller {
controller.push(peerController)
} else {
((context.sharedContext.mainWindow?.viewController as? TelegramRootControllerInterface)?.viewControllers.last as? ViewController)?.push(peerController)
}
} else {
activateSwitchInline(nil)
}
}
private static func botOpenPeer(context: AccountContext, peerId: EnginePeer.Id, navigation: ChatControllerInteractionNavigateToPeer, navigationController: NavigationController) {
let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|> deliverOnMainQueue).startStandalone(next: { peer in
guard let peer else {
return
}
switch navigation {
case .default:
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), keepStack: .always))
case let .chat(_, subject, peekData):
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), subject: subject, keepStack: .always, peekData: peekData))
case .info:
if peer.restrictionText(platform: "ios", contentSettings: context.currentContentSettings.with { $0 }) == nil {
if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) {
navigationController.pushViewController(infoController)
}
}
case let .withBotStartPayload(startPayload):
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), botStart: startPayload))
case let .withAttachBot(attachBotStart):
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), attachBotStart: attachBotStart))
case let .withBotApp(botAppStart):
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), botAppStart: botAppStart, keepStack: .always))
}
})
}
fileprivate static func botOpenUrl(context: AccountContext, peerId: EnginePeer.Id, controller: ChatControllerImpl?, url: String, concealed: Bool, forceUpdate: Bool, present: @escaping (ViewController, Any?) -> Void, commit: @escaping () -> Void = {}) {
if let controller {
controller.openUrl(url, concealed: concealed, forceExternal: true, commit: commit)
} else {
let _ = openUserGeneratedUrl(context: context, peerId: peerId, url: url, concealed: concealed, present: { c in
present(c, nil)
}, openResolved: { result in
var navigationController: NavigationController?
if let main = context.sharedContext.mainWindow?.viewController as? NavigationController {
navigationController = main
}
if case let .peer(peer, navigation) = result, case let .withBotApp(botApp) = navigation, let botPeer = peer.flatMap(EnginePeer.init), let parentController = navigationController?.viewControllers.last as? ViewController {
self.presentBotApp(context: context, parentController: parentController, botApp: botApp.botApp, botPeer: botPeer, payload: botApp.payload, mode: botApp.mode)
} else {
context.sharedContext.openResolvedUrl(result, context: context, urlContext: .generic, navigationController: navigationController, forceExternal: false, forceUpdate: forceUpdate, openPeer: { peer, navigation in
if let navigationController {
ChatControllerImpl.botOpenPeer(context: context, peerId: peer.id, navigation: navigation, navigationController: navigationController)
}
commit()
}, sendFile: nil, sendSticker: nil, sendEmoji: nil, requestMessageActionUrlAuth: nil, joinVoiceChat: { peerId, invite, call in
}, present: { c, a in
present(c, a)
}, dismissInput: {
context.sharedContext.mainWindow?.viewController?.view.endEditing(false)
}, contentContext: nil, progress: nil, completion: nil)
}
})
}
}
func presentBotApp(botApp: BotApp?, botPeer: EnginePeer, payload: String?, mode: ResolvedStartAppMode, concealed: Bool = false, commit: @escaping () -> Void = {}) {
ChatControllerImpl.presentBotApp(context: self.context, parentController: self, botApp: botApp, botPeer: botPeer, payload: payload, mode: mode, concealed: concealed, commit: commit)
}
fileprivate static func presentBotApp(context: AccountContext, parentController: ViewController, botApp: BotApp?, botPeer: EnginePeer, payload: String?, mode: ResolvedStartAppMode, concealed: Bool = false, commit: @escaping () -> Void = {}) {
let chatController = parentController as? ChatControllerImpl
let peerId: EnginePeer.Id
let threadId = chatController?.chatLocation.threadId
if let chatPeerId = chatController?.chatLocation.peerId {
peerId = chatPeerId
} else {
peerId = botPeer.id
}
var skipTermsOfService = false
if let whiteListedBots = context.currentAppConfiguration.with({ $0 }).data?["whitelisted_bots"] as? [Double] {
let botId = botPeer.id.id._internalGetInt64Value()
for bot in whiteListedBots {
if Int64(bot) == botId {
skipTermsOfService = true
}
}
}
chatController?.attachmentController?.dismiss(animated: true, completion: nil)
let updatedPresentationData = chatController?.updatedPresentationData
let presentationData = updatedPresentationData?.0 ?? context.sharedContext.currentPresentationData.with { $0 }
if let botApp {
let openBotApp: (Bool, Bool, BotAppSettings?) -> Void = { [weak parentController, weak chatController] allowWrite, justInstalled, appSettings in
commit()
chatController?.updateChatPresentationInterfaceState(animated: true, interactive: true, {
return $0.updatedTitlePanelContext {
if !$0.contains(where: {
switch $0 {
case .requestInProgress:
return true
default:
return false
}
}) {
var updatedContexts = $0
updatedContexts.append(.requestInProgress)
return updatedContexts.sorted()
}
return $0
}
})
let updateProgress = { [weak chatController] in
Queue.mainQueue().async {
if let chatController {
chatController.updateChatPresentationInterfaceState(animated: true, interactive: true, {
return $0.updatedTitlePanelContext {
if let index = $0.firstIndex(where: {
switch $0 {
case .requestInProgress:
return true
default:
return false
}
}) {
var updatedContexts = $0
updatedContexts.remove(at: index)
return updatedContexts
}
return $0
}
})
}
}
}
let botAddress = botPeer.addressName ?? ""
let _ = ((context.engine.messages.requestAppWebView(peerId: peerId, appReference: .id(id: botApp.id, accessHash: botApp.accessHash), payload: payload, themeParams: generateWebAppThemeParams(presentationData.theme), compact: mode == .compact, fullscreen: mode == .fullscreen, allowWrite: allowWrite)
|> afterDisposed {
updateProgress()
})
|> deliverOnMainQueue).startStandalone(next: { [weak parentController, weak chatController] result in
let params = WebAppParameters(source: .generic, peerId: peerId, botId: botPeer.id, botName: botApp.title, botVerified: botPeer.isVerified, botAddress: botPeer.addressName ?? "", appName: botApp.shortName, url: result.url, queryId: 0, payload: payload, buttonText: "", keepAliveSignal: nil, forceHasSettings: botApp.flags.contains(.hasSettings), fullSize: result.flags.contains(.fullSize), isFullscreen: result.flags.contains(.fullScreen), appSettings: appSettings)
var presentImpl: ((ViewController, Any?) -> Void)?
let controller = standaloneWebAppController(context: context, updatedPresentationData: updatedPresentationData, params: params, threadId: threadId, openUrl: { url, concealed, forceUpdate, commit in
ChatControllerImpl.botOpenUrl(context: context, peerId: peerId, controller: chatController, url: url, concealed: concealed, forceUpdate: forceUpdate, present: { c, a in
presentImpl?(c, a)
}, commit: commit)
}, requestSwitchInline: { query, chatTypes, completion in
ChatControllerImpl.botRequestSwitchInline(context: context, controller: chatController, peerId: peerId, botAddress: botAddress, query: query, chatTypes: chatTypes, completion: completion)
}, completion: {
chatController?.chatDisplayNode.historyNode.scrollToEndOfHistory()
}, getNavigationController: {
if let navigationController = parentController?.navigationController as? NavigationController {
return navigationController
} else {
return context.sharedContext.mainWindow?.viewController as? NavigationController
}
})
controller.navigationPresentation = .flatModal
parentController?.push(controller)
presentImpl = { [weak controller] c, a in
controller?.present(c, in: .window(.root), with: a)
}
if justInstalled {
let content: UndoOverlayContent = .succeed(text: presentationData.strings.WebApp_ShortcutsSettingsAdded(botPeer.compactDisplayTitle).string, timeout: 5.0, customUndoText: nil)
controller.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, position: .top, action: { _ in return false }), in: .current)
}
}, error: { [weak parentController] error in
parentController?.present(textAlertController(context: context, updatedPresentationData: updatedPresentationData, title: nil, text: presentationData.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {
})]), in: .window(.root))
})
}
let _ = combineLatest(
queue: Queue.mainQueue(),
ApplicationSpecificNotice.getBotGameNotice(accountManager: context.sharedContext.accountManager, peerId: botPeer.id),
context.engine.messages.attachMenuBots(),
context.engine.messages.getAttachMenuBot(botId: botPeer.id, cached: true)
|> map(Optional.init)
|> `catch` { _ -> Signal<AttachMenuBot?, NoError> in
return .single(nil)
},
context.engine.data.get(TelegramEngine.EngineData.Item.Peer.BotAppSettings(id: botPeer.id))
).startStandalone(next: { [weak parentController, weak chatController] noticed, attachMenuBots, attachMenuBot, appSettings in
var isAttachMenuBotInstalled: Bool?
if let _ = attachMenuBot {
if let _ = attachMenuBots.first(where: { $0.peer.id == botPeer.id && !$0.flags.contains(.notActivated) }) {
isAttachMenuBotInstalled = true
} else {
isAttachMenuBotInstalled = false
}
}
if !noticed || botApp.flags.contains(.notActivated) || isAttachMenuBotInstalled == false {
if let isAttachMenuBotInstalled, let attachMenuBot {
if !isAttachMenuBotInstalled {
let controller = webAppTermsAlertController(context: context, updatedPresentationData: updatedPresentationData, bot: attachMenuBot, completion: { allowWrite in
let _ = ApplicationSpecificNotice.setBotGameNotice(accountManager: context.sharedContext.accountManager, peerId: botPeer.id).startStandalone()
let _ = (context.engine.messages.addBotToAttachMenu(botId: botPeer.id, allowWrite: allowWrite)
|> deliverOnMainQueue).startStandalone(error: { _ in
}, completed: {
openBotApp(allowWrite, true, appSettings)
})
})
parentController?.present(controller, in: .window(.root))
} else {
openBotApp(false, false, appSettings)
}
} else {
if skipTermsOfService {
openBotApp(true, false, appSettings)
} else {
let controller = webAppLaunchConfirmationController(context: context, updatedPresentationData: updatedPresentationData, peer: botPeer, requestWriteAccess: botApp.flags.contains(.notActivated) && botApp.flags.contains(.requiresWriteAccess), completion: { allowWrite in
let _ = ApplicationSpecificNotice.setBotGameNotice(accountManager: context.sharedContext.accountManager, peerId: botPeer.id).startStandalone()
openBotApp(allowWrite, false, appSettings)
}, showMore: chatController == nil ? nil : { [weak chatController] in
if let chatController {
chatController.openResolved(result: .peer(botPeer._asPeer(), .info(nil)), sourceMessageId: nil)
}
}, openTerms: {
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: presentationData.strings.WebApp_LaunchTermsConfirmation_URL, forceExternal: false, presentationData: presentationData, navigationController: parentController?.navigationController as? NavigationController, dismissInput: {})
})
parentController?.present(controller, in: .window(.root))
}
}
} else {
openBotApp(false, false, appSettings)
}
})
} else {
context.sharedContext.openWebApp(
context: context,
parentController: parentController,
updatedPresentationData: updatedPresentationData,
botPeer: botPeer,
chatPeer: nil,
threadId: nil,
buttonText: "",
url: "",
simple: true,
source: .generic,
skipTermsOfService: false,
payload: payload,
verifyAgeCompletion: nil
)
}
}
}
@@ -0,0 +1,140 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import ContextUI
import UndoUI
import AccountContext
import ChatControllerInteraction
import AnimatedTextComponent
import ChatMessagePaymentAlertController
import TelegramPresentationData
import TelegramNotices
extension ChatControllerImpl {
func presentPaidMessageAlertIfNeeded(count: Int32 = 1, forceDark: Bool = false, alwaysAsk: Bool = false, completion: @escaping (Bool) -> Void) {
guard let peer = self.presentationInterfaceState.renderedPeer?.peer.flatMap(EnginePeer.init) else {
completion(false)
return
}
guard let renderedPeer = self.presentationInterfaceState.renderedPeer.flatMap(EngineRenderedPeer.init) else {
return
}
if let sendPaidMessageStars = self.presentationInterfaceState.sendPaidMessageStars, self.presentationInterfaceState.interfaceState.editMessage == nil {
let totalAmount = sendPaidMessageStars.value * Int64(count)
let _ = (ApplicationSpecificNotice.dismissedPaidMessageWarningNamespace(accountManager: self.context.sharedContext.accountManager, peerId: peer.id)
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] dismissedAmount in
guard let self, let starsContext = self.context.starsContext else {
return
}
if !alwaysAsk, let dismissedAmount, dismissedAmount == sendPaidMessageStars.value, let currentState = starsContext.currentState, currentState.balance.value > totalAmount {
if count < 3 && totalAmount < 100 {
completion(false)
} else {
completion(true)
self.displayPaidMessageUndo(count: count, amount: sendPaidMessageStars)
}
} else {
var presentationData = self.presentationData
if forceDark {
presentationData = presentationData.withUpdated(theme: defaultDarkColorPresentationTheme)
}
var peer = peer
var renderedPeer = renderedPeer
if let peerDiscussionId = self.presentationInterfaceState.peerDiscussionId, let channel = self.contentData?.state.peerView?.peers[peerDiscussionId] {
peer = EnginePeer(channel)
renderedPeer = EngineRenderedPeer(peer: peer)
}
let controller = chatMessagePaymentAlertController(
context: self.context,
presentationData: presentationData,
updatedPresentationData: nil,
peers: [renderedPeer],
count: count,
amount: sendPaidMessageStars,
totalAmount: nil,
hasCheck: !alwaysAsk,
navigationController: self.navigationController as? NavigationController,
completion: { [weak self] dontAskAgain in
guard let self else {
return
}
if dontAskAgain {
let _ = ApplicationSpecificNotice.setDismissedPaidMessageWarningNamespace(accountManager: self.context.sharedContext.accountManager, peerId: peer.id, amount: sendPaidMessageStars.value).start()
}
if let currentState = starsContext.currentState, currentState.balance.value < totalAmount {
let _ = (self.context.engine.payments.starsTopUpOptions()
|> take(1)
|> deliverOnMainQueue).startStandalone(next: { [weak self] options in
guard let self else {
return
}
let controller = self.context.sharedContext.makeStarsPurchaseScreen(context: self.context, starsContext: starsContext, options: options, purpose: .sendMessage(peerId: peer.id, requiredStars: totalAmount), targetPeerId: nil, customTheme: nil, completion: { stars in
starsContext.add(balance: StarsAmount(value: stars, nanos: 0))
let _ = (starsContext.onUpdate
|> deliverOnMainQueue).start(next: {
completion(false)
})
})
self.push(controller)
})
} else {
completion(false)
}
}
)
self.present(controller, in: .window(.root))
}
})
} else {
completion(false)
}
}
func displayPaidMessageUndo(count: Int32, amount: StarsAmount) {
guard let peerId = self.chatLocation.peerId else {
return
}
if let current = self.currentPaidMessageUndoController {
self.currentPaidMessageUndoController = nil
current.dismiss()
self.context.engine.messages.forceSendPostponedPaidMessage(peerId: peerId)
}
let title = self.presentationData.strings.Chat_PaidMessage_Sent_Title(count)
let text = self.presentationData.strings.Chat_PaidMessage_Sent_Text(self.presentationData.strings.Chat_PaidMessage_Sent_Text_Stars(Int32(clamping: amount.value * Int64(count)))).string
let textItems: [AnimatedTextComponent.Item] = [
AnimatedTextComponent.Item(id: 0, content: .text(text))
]
let controller = UndoOverlayController(presentationData: self.presentationData, content: .starsSent(context: self.context, title: title, text: textItems, hasUndo: true), elevatedLayout: false, position: .top, action: { [weak self] action in
guard let self else {
return false
}
if case .undo = action {
var messageIds: [MessageId] = []
self.chatDisplayNode.historyNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ChatMessageItemNodeProtocol {
for message in itemNode.messages() {
if message.id.namespace == Namespaces.Message.Local {
messageIds.append(message.id)
}
}
}
}
let _ = self.context.engine.messages.deleteMessagesInteractively(messageIds: messageIds, type: .forLocalPeer).startStandalone()
}
return false
})
self.currentPaidMessageUndoController = controller
self.present(controller, in: .current)
}
}
@@ -0,0 +1,262 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramUIPreferences
import AccountContext
import MediaPickerUI
import MediaPasteboardUI
import LegacyMediaPickerUI
import MediaEditor
import ChatEntityKeyboardInputNode
extension ChatControllerImpl {
func displayPasteMenu(_ subjects: [MediaPickerScreenImpl.Subject.Media]) {
let _ = (self.context.sharedContext.accountManager.transaction { transaction -> GeneratedMediaStoreSettings in
let entry = transaction.getSharedData(ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings)?.get(GeneratedMediaStoreSettings.self)
return entry ?? GeneratedMediaStoreSettings.defaultSettings
}
|> deliverOnMainQueue).startStandalone(next: { [weak self] settings in
if let strongSelf = self, let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer {
var enableMultiselection = true
if strongSelf.presentationInterfaceState.interfaceState.postSuggestionState != nil {
enableMultiselection = false
}
strongSelf.chatDisplayNode.dismissInput()
let controller = mediaPasteboardScreen(
context: strongSelf.context,
updatedPresentationData: strongSelf.updatedPresentationData,
peer: EnginePeer(peer),
subjects: subjects,
presentMediaPicker: { [weak self] subject, saveEditedPhotos, bannedSendPhotos, bannedSendVideos, present in
if let strongSelf = self {
strongSelf.presentMediaPicker(subject: subject, saveEditedPhotos: saveEditedPhotos, bannedSendPhotos: bannedSendPhotos, bannedSendVideos: bannedSendVideos, enableMultiselection: enableMultiselection, present: present, updateMediaPickerContext: { _ in }, completion: { [weak self] fromGallery, signals, silentPosting, scheduleTime, parameters, getAnimatedTransitionSource, completion in
self?.enqueueMediaMessages(fromGallery: fromGallery, signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime, parameters: parameters, getAnimatedTransitionSource: getAnimatedTransitionSource, completion: completion)
})
}
},
getSourceRect: nil,
makeEntityInputView: { [weak self] in
guard let self else {
return nil
}
return EntityInputView(context: self.context, isDark: false, areCustomEmojiEnabled: self.presentationInterfaceState.customEmojiAvailable)
}
)
controller.navigationPresentation = .flatModal
strongSelf.push(controller)
}
})
}
func enqueueGifData(_ data: Data) {
self.enqueueMediaMessageDisposable.set((legacyEnqueueGifMessage(account: self.context.account, data: data) |> deliverOnMainQueue).startStrict(next: { [weak self] message in
if let strongSelf = self {
strongSelf.presentPaidMessageAlertIfNeeded(completion: { [weak self] postpone in
guard let strongSelf = self else {
return
}
let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject
strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil) }
})
}
}, nil)
strongSelf.sendMessages([message].map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) })
})
}
}))
}
func enqueueVideoData(_ data: Data) {
self.enqueueMediaMessageDisposable.set((legacyEnqueueGifMessage(account: self.context.account, data: data) |> deliverOnMainQueue).startStrict(next: { [weak self] message in
if let strongSelf = self {
strongSelf.presentPaidMessageAlertIfNeeded(completion: { [weak self] postpone in
guard let strongSelf = self else {
return
}
let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject
strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil) }
})
}
}, nil)
strongSelf.sendMessages([message].map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) })
})
}
}))
}
func enqueueStickerImage(_ image: UIImage, isMemoji: Bool) {
let size = image.size.aspectFitted(CGSize(width: 512.0, height: 512.0))
self.enqueueMediaMessageDisposable.set((convertToWebP(image: image, targetSize: size, targetBoundingSize: size, quality: 0.9) |> deliverOnMainQueue).startStrict(next: { [weak self] data in
if let strongSelf = self, !data.isEmpty {
let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
strongSelf.context.account.postbox.mediaBox.storeResourceData(resource.id, data: data)
var fileAttributes: [TelegramMediaFileAttribute] = []
fileAttributes.append(.FileName(fileName: "sticker.webp"))
fileAttributes.append(.Sticker(displayText: "", packReference: nil, maskData: nil))
fileAttributes.append(.ImageSize(size: PixelDimensions(size)))
let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "image/webp", size: Int64(data.count), attributes: fileAttributes, alternativeRepresentations: [])
let message = EnqueueMessage.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: media), threadId: strongSelf.chatLocation.threadId, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])
strongSelf.presentPaidMessageAlertIfNeeded(completion: { [weak self] postpone in
guard let strongSelf = self else {
return
}
let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject
strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil) }
})
}
}, nil)
strongSelf.sendMessages([message].map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) }, postpone: postpone)
})
}
}))
}
func enqueueStickerFile(_ file: TelegramMediaFile) {
let message = EnqueueMessage.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: self.chatLocation.threadId, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])
self.presentPaidMessageAlertIfNeeded(completion: { [weak self] postpone in
guard let self else {
return
}
let replyMessageSubject = self.presentationInterfaceState.interfaceState.replyMessageSubject
self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil) }
})
}
}, nil)
self.sendMessages([message].map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) })
Queue.mainQueue().after(3.0) {
if let message = self.chatDisplayNode.historyNode.lastVisbleMesssage(), let file = message.media.first(where: { $0 is TelegramMediaFile }) as? TelegramMediaFile, file.isSticker {
self.context.engine.stickers.addRecentlyUsedSticker(fileReference: .message(message: MessageReference(message), media: file))
}
}
})
}
func enqueueAnimatedStickerData(_ data: Data) {
guard let animatedImage = UIImage.animatedImageFromData(data: data), let thumbnailImage = animatedImage.images.first else {
return
}
let dimensions = PixelDimensions(width: 1080, height: 1920)
let image = generateImage(dimensions.cgSize, opaque: false, scale: 1.0, rotatedContext: { size, context in
context.clear(CGRect(origin: .zero, size: size))
})!
let blackImage = generateImage(dimensions.cgSize, opaque: true, scale: 1.0, rotatedContext: { size, context in
context.setFillColor(UIColor.black.cgColor)
context.fill(CGRect(origin: .zero, size: size))
})!
let stickerEntity = DrawingStickerEntity(content: .animatedImage(data, thumbnailImage))
stickerEntity.referenceDrawingSize = dimensions.cgSize
stickerEntity.position = CGPoint(x: dimensions.cgSize.width / 2.0, y: dimensions.cgSize.height / 2.0)
stickerEntity.scale = 3.5
let entities: [CodableDrawingEntity] = [
.sticker(stickerEntity)
]
let values = MediaEditorValues(
peerId: self.context.account.peerId,
originalDimensions: dimensions,
cropOffset: .zero,
cropRect: nil,
cropScale: 1.0,
cropRotation: 1.0,
cropMirroring: false,
cropOrientation: .up,
gradientColors: [.clear, .clear],
videoTrimRange: nil,
videoIsMuted: false,
videoIsFullHd: false,
videoIsMirrored: false,
videoVolume: nil,
additionalVideoPath: nil,
additionalVideoIsDual: false,
additionalVideoPosition: nil,
additionalVideoScale: nil,
additionalVideoRotation: nil,
additionalVideoPositionChanges: [],
additionalVideoTrimRange: nil,
additionalVideoOffset: nil,
additionalVideoVolume: nil,
collage: [],
nightTheme: false,
drawing: nil,
maskDrawing: blackImage,
entities: entities,
toolValues: [:],
audioTrack: nil,
audioTrackTrimRange: nil,
audioTrackOffset: nil,
audioTrackVolume: nil,
audioTrackSamples: nil,
collageTrackSamples: nil,
coverImageTimestamp: nil,
coverDimensions: nil,
qualityPreset: nil
)
let configuration = recommendedVideoExportConfiguration(values: values, duration: animatedImage.duration, frameRate: 30.0, isSticker: true)
let path = NSTemporaryDirectory() + "\(Int64.random(in: Int64.min ... Int64.max)).webm"
let videoExport = MediaEditorVideoExport(
postbox: self.context.account.postbox,
subject: .image(image: image),
configuration: configuration,
outputPath: path
)
let _ = (videoExport.status
|> deliverOnMainQueue).startStandalone(next: { [weak self] status in
guard let self else {
return
}
switch status {
case .completed:
var fileAttributes: [TelegramMediaFileAttribute] = []
fileAttributes.append(.FileName(fileName: "sticker.webm"))
fileAttributes.append(.Sticker(displayText: "", packReference: nil, maskData: nil))
fileAttributes.append(.Video(duration: animatedImage.duration, size: PixelDimensions(width: 512, height: 512), flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil))
let previewRepresentations: [TelegramMediaImageRepresentation] = []
let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
self.context.account.postbox.mediaBox.copyResourceData(resource.id, fromTempPath: path)
let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/webm", size: 0, attributes: fileAttributes, alternativeRepresentations: [])
self.enqueueStickerFile(file)
default:
break
}
})
}
}
@@ -0,0 +1,22 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramUIPreferences
import AccountContext
import ChatMessageItemView
extension ChatControllerImpl {
func playMessageEffect(message: Message) {
var messageItemNode: ChatMessageItemView?
self.chatDisplayNode.historyNode.forEachVisibleMessageItemNode { itemNode in
if let item = itemNode.item, item.message.id == message.id {
messageItemNode = itemNode
}
}
messageItemNode?.playMessageEffect(force: true)
}
}
@@ -0,0 +1,61 @@
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import ContextUI
import QuickShareScreen
extension ChatControllerImpl {
func displayQuickShare(id: EngineMessage.Id, node: ASDisplayNode, gesture: ContextGesture) {
let controller = QuickShareScreen(
context: self.context,
sourceNode: node,
gesture: gesture,
completion: { [weak self] peer, sourceFrame in
guard let self else {
return
}
self.window?.forEachController({ controller in
if let controller = controller as? QuickShareToastScreen {
controller.dismissWithCommitAction()
}
})
let toastScreen = QuickShareToastScreen(
context: self.context,
peer: peer,
sourceFrame: sourceFrame,
action: { [weak self] action in
guard let self else {
return
}
switch action {
case .info:
self.openPeer(peer: peer, navigation: .chat(textInputState: nil, subject: nil, peekData: nil), fromMessage: nil)
case .commit:
let enqueueMessage = StandaloneSendEnqueueMessage(
content: .forward(forward: StandaloneSendEnqueueMessage.Forward(
sourceId: id,
threadId: nil
)),
replyToMessageId: nil
)
let _ = (standaloneSendEnqueueMessages(
accountPeerId: self.context.account.peerId,
postbox: self.context.account.postbox,
network: self.context.account.network,
stateManager: self.context.account.stateManager,
auxiliaryMethods: self.context.account.auxiliaryMethods,
peerId: peer.id,
threadId: nil,
messages: [enqueueMessage]
)).startStandalone()
}
}
)
self.present(toastScreen, in: .window(.root))
}
)
self.presentInGlobalOverlay(controller)
}
}
@@ -0,0 +1,264 @@
import Foundation
import UIKit
import Postbox
import SwiftSignalKit
import Display
import AsyncDisplayKit
import TelegramCore
import SafariServices
import MobileCoreServices
import Intents
import LegacyComponents
import TelegramPresentationData
import TelegramUIPreferences
import DeviceAccess
import TextFormat
import TelegramBaseController
import AccountContext
import TelegramStringFormatting
import OverlayStatusController
import DeviceLocationManager
import ShareController
import UrlEscaping
import ContextUI
import ComposePollUI
import AlertUI
import PresentationDataUtils
import UndoUI
import TelegramCallsUI
import TelegramNotices
import GameUI
import ScreenCaptureDetection
import GalleryUI
import OpenInExternalAppUI
import LegacyUI
import InstantPageUI
import LocationUI
import BotPaymentsUI
import DeleteChatPeerActionSheetItem
import HashtagSearchUI
import LegacyMediaPickerUI
import Emoji
import PeerAvatarGalleryUI
import PeerInfoUI
import RaiseToListen
import UrlHandling
import AvatarNode
import AppBundle
import LocalizedPeerData
import PhoneNumberFormat
import SettingsUI
import UrlWhitelist
import TelegramIntents
import TooltipUI
import StatisticsUI
import MediaResources
import GalleryData
import ChatInterfaceState
import InviteLinksUI
import Markdown
import TelegramPermissionsUI
import Speak
import TranslateUI
import UniversalMediaPlayer
import WallpaperBackgroundNode
import ChatListUI
import CalendarMessageScreen
import ReactionSelectionNode
import ReactionListContextMenuContent
import AttachmentUI
import AttachmentTextInputPanelNode
import MediaPickerUI
import ChatPresentationInterfaceState
import Pasteboard
import ChatSendMessageActionUI
import ChatTextLinkEditUI
import WebUI
import PremiumUI
import ImageTransparency
import StickerPackPreviewUI
import TextNodeWithEntities
import EntityKeyboard
import ChatTitleView
import EmojiStatusComponent
import ChatTimerScreen
import MediaPasteboardUI
import ChatListHeaderComponent
import ChatControllerInteraction
import FeaturedStickersScreen
import ChatEntityKeyboardInputNode
import StorageUsageScreen
import AvatarEditorScreen
import ChatScheduleTimeController
import ICloudResources
import StoryContainerScreen
import MoreHeaderButton
import VolumeButtons
import ChatAvatarNavigationNode
import ChatContextQuery
import PeerReportScreen
import PeerSelectionController
import SaveToCameraRoll
import ChatMessageDateAndStatusNode
import ReplyAccessoryPanelNode
import TextSelectionNode
import ChatMessagePollBubbleContentNode
import ChatMessageItem
import ChatMessageItemImpl
import ChatMessageItemView
import ChatMessageItemCommon
import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen
import WallpaperGridScreen
import VideoMessageCameraScreen
import TopMessageReactions
import AudioWaveform
import PeerNameColorScreen
import ChatEmptyNode
import ChatMediaInputStickerGridItem
import AdsInfoScreen
extension ChatControllerImpl {
func unblockPeer() {
guard case let .peer(peerId) = self.chatLocation else {
return
}
let unblockingPeer = self.unblockingPeer
unblockingPeer.set(true)
var restartBot = false
if let user = self.presentationInterfaceState.renderedPeer?.peer as? TelegramUser, user.botInfo != nil {
restartBot = true
}
self.editMessageDisposable.set((self.context.engine.privacy.requestUpdatePeerIsBlocked(peerId: peerId, isBlocked: false)
|> afterDisposed({ [weak self] in
Queue.mainQueue().async {
unblockingPeer.set(false)
if let strongSelf = self, restartBot {
strongSelf.startBot(strongSelf.presentationInterfaceState.botStartPayload)
}
}
})).startStrict())
}
func reportPeer() {
guard let renderedPeer = self.presentationInterfaceState.renderedPeer, let peer = renderedPeer.chatMainPeer, let chatPeer = renderedPeer.peer else {
return
}
self.chatDisplayNode.dismissInput()
if let peer = peer as? TelegramChannel, let username = peer.addressName, !username.isEmpty {
let actionSheet = ActionSheetController(presentationData: self.presentationData)
var items: [ActionSheetItem] = []
items.append(ActionSheetButtonItem(title: self.presentationData.strings.Conversation_ReportSpamAndLeave, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
if let strongSelf = self {
strongSelf.deleteChat(reportChatSpam: true)
}
}))
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
self.present(actionSheet, in: .window(.root))
} else if let _ = peer as? TelegramUser {
let presentationData = self.presentationData
let controller = ActionSheetController(presentationData: presentationData)
let dismissAction: () -> Void = { [weak controller] in
controller?.dismissAnimated()
}
var reportSpam = true
var deleteChat = true
var items: [ActionSheetItem] = []
if !peer.isDeleted {
items.append(ActionSheetTextItem(title: presentationData.strings.UserInfo_BlockConfirmationTitle(EnginePeer(peer).compactDisplayTitle).string))
}
items.append(contentsOf: [
ActionSheetCheckboxItem(title: presentationData.strings.Conversation_Moderate_Report, label: "", value: reportSpam, action: { [weak controller] checkValue in
reportSpam = checkValue
controller?.updateItem(groupIndex: 0, itemIndex: 1, { item in
if let item = item as? ActionSheetCheckboxItem {
return ActionSheetCheckboxItem(title: item.title, label: item.label, value: !item.value, action: item.action)
}
return item
})
}),
ActionSheetCheckboxItem(title: presentationData.strings.ReportSpam_DeleteThisChat, label: "", value: deleteChat, action: { [weak controller] checkValue in
deleteChat = checkValue
controller?.updateItem(groupIndex: 0, itemIndex: 2, { item in
if let item = item as? ActionSheetCheckboxItem {
return ActionSheetCheckboxItem(title: item.title, label: item.label, value: !item.value, action: item.action)
}
return item
})
}),
ActionSheetButtonItem(title: presentationData.strings.UserInfo_BlockActionTitle(EnginePeer(peer).compactDisplayTitle).string, color: .destructive, action: { [weak self] in
dismissAction()
guard let strongSelf = self else {
return
}
let _ = strongSelf.context.engine.privacy.requestUpdatePeerIsBlocked(peerId: peer.id, isBlocked: true).startStandalone()
if let _ = chatPeer as? TelegramSecretChat {
let _ = strongSelf.context.engine.peers.terminateSecretChat(peerId: chatPeer.id, requestRemoteHistoryRemoval: true).startStandalone()
}
if deleteChat {
let _ = strongSelf.context.engine.peers.removePeerChat(peerId: chatPeer.id, reportChatSpam: reportSpam).startStandalone()
strongSelf.effectiveNavigationController?.filterController(strongSelf, animated: true)
} else if reportSpam {
let _ = strongSelf.context.engine.peers.reportPeer(peerId: peer.id, reason: .spam, message: "").startStandalone()
}
})
] as [ActionSheetItem])
controller.setItemGroups([
ActionSheetItemGroup(items: items),
ActionSheetItemGroup(items: [ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, action: { dismissAction() })])
])
self.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
} else {
let title: String
var infoString: String?
if let _ = peer as? TelegramGroup {
title = self.presentationData.strings.Conversation_ReportSpamAndLeave
infoString = self.presentationData.strings.Conversation_ReportSpamGroupConfirmation
} else if let channel = peer as? TelegramChannel {
title = self.presentationData.strings.Conversation_ReportSpamAndLeave
if case .group = channel.info {
infoString = self.presentationData.strings.Conversation_ReportSpamGroupConfirmation
} else {
infoString = self.presentationData.strings.Conversation_ReportSpamChannelConfirmation
}
} else {
title = self.presentationData.strings.Conversation_ReportSpam
infoString = self.presentationData.strings.Conversation_ReportSpamConfirmation
}
let actionSheet = ActionSheetController(presentationData: self.presentationData)
var items: [ActionSheetItem] = []
if let infoString = infoString {
items.append(ActionSheetTextItem(title: infoString))
}
items.append(ActionSheetButtonItem(title: title, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
if let strongSelf = self {
strongSelf.deleteChat(reportChatSpam: true)
}
}))
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
self.present(actionSheet, in: .window(.root))
}
}
}
@@ -0,0 +1,366 @@
import Foundation
import UIKit
import Postbox
import SwiftSignalKit
import Display
import AsyncDisplayKit
import TelegramCore
import SafariServices
import MobileCoreServices
import Intents
import LegacyComponents
import TelegramPresentationData
import TelegramUIPreferences
import DeviceAccess
import TextFormat
import TelegramBaseController
import AccountContext
import TelegramStringFormatting
import OverlayStatusController
import DeviceLocationManager
import ShareController
import UrlEscaping
import ContextUI
import ComposePollUI
import AlertUI
import PresentationDataUtils
import UndoUI
import TelegramCallsUI
import TelegramNotices
import GameUI
import ScreenCaptureDetection
import GalleryUI
import OpenInExternalAppUI
import LegacyUI
import InstantPageUI
import LocationUI
import BotPaymentsUI
import DeleteChatPeerActionSheetItem
import HashtagSearchUI
import LegacyMediaPickerUI
import Emoji
import PeerAvatarGalleryUI
import PeerInfoUI
import RaiseToListen
import UrlHandling
import AvatarNode
import AppBundle
import LocalizedPeerData
import PhoneNumberFormat
import SettingsUI
import UrlWhitelist
import TelegramIntents
import TooltipUI
import StatisticsUI
import MediaResources
import GalleryData
import ChatInterfaceState
import InviteLinksUI
import Markdown
import TelegramPermissionsUI
import Speak
import TranslateUI
import UniversalMediaPlayer
import WallpaperBackgroundNode
import ChatListUI
import CalendarMessageScreen
import ReactionSelectionNode
import ReactionListContextMenuContent
import AttachmentUI
import AttachmentTextInputPanelNode
import MediaPickerUI
import ChatPresentationInterfaceState
import Pasteboard
import ChatSendMessageActionUI
import ChatTextLinkEditUI
import WebUI
import PremiumUI
import ImageTransparency
import StickerPackPreviewUI
import TextNodeWithEntities
import EntityKeyboard
import ChatTitleView
import EmojiStatusComponent
import ChatTimerScreen
import MediaPasteboardUI
import ChatListHeaderComponent
import ChatControllerInteraction
import FeaturedStickersScreen
import ChatEntityKeyboardInputNode
import StorageUsageScreen
import AvatarEditorScreen
import ChatScheduleTimeController
import ICloudResources
import StoryContainerScreen
import MoreHeaderButton
import VolumeButtons
import ChatAvatarNavigationNode
import ChatContextQuery
import PeerReportScreen
import PeerSelectionController
import SaveToCameraRoll
import ChatMessageDateAndStatusNode
import ReplyAccessoryPanelNode
import TextSelectionNode
import ChatMessagePollBubbleContentNode
import ChatMessageItem
import ChatMessageItemImpl
import ChatMessageItemView
import ChatMessageItemCommon
import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen
import WallpaperGridScreen
import VideoMessageCameraScreen
import TopMessageReactions
import AudioWaveform
import PeerNameColorScreen
import ChatEmptyNode
import ChatMediaInputStickerGridItem
import AdsInfoScreen
import Photos
import ChatThemeScreen
extension ChatControllerImpl {
public func presentThemeSelection() {
guard self.themeScreen == nil else {
return
}
let context = self.context
let peerId = self.chatLocation.peerId
self.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in
var updated = state
updated = updated.updatedInputMode({ _ in
return .none
})
updated = updated.updatedShowCommands(false)
return updated
})
let animatedEmojiStickers = context.engine.stickers.loadedStickerPack(reference: .animatedEmoji, forceActualized: false)
|> map { animatedEmoji -> [String: [StickerPackItem]] in
var animatedEmojiStickers: [String: [StickerPackItem]] = [:]
switch animatedEmoji {
case let .result(_, items, _):
for item in items {
if let emoji = item.getStringRepresentationsOfIndexKeys().first {
animatedEmojiStickers[emoji.basicEmoji.0] = [item]
let strippedEmoji = emoji.basicEmoji.0.strippedEmoji
if animatedEmojiStickers[strippedEmoji] == nil {
animatedEmojiStickers[strippedEmoji] = [item]
}
}
}
default:
break
}
return animatedEmojiStickers
}
let _ = (combineLatest(queue: Queue.mainQueue(), self.chatThemePromise.get(), animatedEmojiStickers)
|> take(1)).startStandalone(next: { [weak self] chatTheme, animatedEmojiStickers in
guard let strongSelf = self, let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer else {
return
}
var canResetWallpaper = false
if let cachedUserData = strongSelf.contentData?.state.peerView?.cachedData as? CachedUserData {
canResetWallpaper = cachedUserData.wallpaper != nil
}
let controller = ChatThemeScreen(
context: context,
updatedPresentationData: strongSelf.updatedPresentationData,
animatedEmojiStickers: animatedEmojiStickers,
initiallySelectedTheme: chatTheme,
peerName: strongSelf.presentationInterfaceState.renderedPeer?.chatMainPeer.flatMap(EnginePeer.init)?.compactDisplayTitle ?? "",
canResetWallpaper: canResetWallpaper,
previewTheme: { [weak self] chatTheme, dark in
if let strongSelf = self {
strongSelf.presentCrossfadeSnapshot()
strongSelf.chatThemeAndDarkAppearancePreviewPromise.set(.single((chatTheme, dark)))
}
},
changeWallpaper: { [weak self] in
guard let self, let peerId else {
return
}
if let themeController = self.themeScreen {
self.themeScreen = nil
themeController.dimTapped()
}
let dismissControllers = { [weak self] in
if let self, let navigationController = self.navigationController as? NavigationController {
let controllers = navigationController.viewControllers.filter({ controller in
if controller is WallpaperGalleryController || controller is AttachmentController {
return false
}
return true
})
navigationController.setViewControllers(controllers, animated: true)
}
}
var openWallpaperPickerImpl: ((Bool) -> Void)?
let openWallpaperPicker = { [weak self] animateAppearance in
guard let self else {
return
}
let controller = wallpaperMediaPickerController(
context: context,
updatedPresentationData: self.updatedPresentationData,
peer: EnginePeer(peer),
animateAppearance: animateAppearance,
completion: { [weak self] _, result in
guard let self, let asset = result as? PHAsset else {
return
}
let controller = WallpaperGalleryController(context: context, source: .asset(asset), mode: .peer(EnginePeer(peer), false))
controller.navigationPresentation = .modal
controller.apply = { wallpaper, options, editedImage, cropRect, brightness, forBoth in
uploadCustomPeerWallpaper(context: context, wallpaper: wallpaper, mode: options, editedImage: editedImage, cropRect: cropRect, brightness: brightness, peerId: peerId, forBoth: forBoth, completion: {
Queue.mainQueue().after(0.3, {
dismissControllers()
})
})
}
self.push(controller)
},
openColors: { [weak self] in
guard let self else {
return
}
let controller = standaloneColorPickerController(context: context, peer: EnginePeer(peer), push: { [weak self] controller in
if let self {
self.push(controller)
}
}, openGallery: {
openWallpaperPickerImpl?(false)
})
controller.navigationPresentation = .flatModal
self.push(controller)
}
)
controller.navigationPresentation = .flatModal
self.push(controller)
}
openWallpaperPickerImpl = openWallpaperPicker
openWallpaperPicker(true)
},
resetWallpaper: { [weak self] in
guard let self, let peerId else {
return
}
let _ = self.context.engine.themes.setChatWallpaper(peerId: peerId, wallpaper: nil, forBoth: false).startStandalone()
},
completion: { [weak self] chatTheme in
guard let self, let peerId else {
return
}
if canResetWallpaper && chatTheme != nil {
let _ = context.engine.themes.setChatWallpaper(peerId: peerId, wallpaper: nil, forBoth: true).startStandalone()
}
strongSelf.chatThemeAndDarkAppearancePreviewPromise.set(.single((chatTheme ?? .emoticon(""), nil)))
let _ = context.engine.themes.setChatTheme(peerId: peerId, chatTheme: chatTheme ?? .emoticon("")).startStandalone(completed: { [weak self] in
if let self {
self.chatThemeAndDarkAppearancePreviewPromise.set(.single((nil, nil)))
}
})
}
)
controller.navigationPresentation = .flatModal
controller.passthroughHitTestImpl = { [weak self] _ in
if let strongSelf = self {
return strongSelf.chatDisplayNode.historyNode.view
} else {
return nil
}
}
controller.dismissed = { [weak self] in
if let strongSelf = self {
strongSelf.chatDisplayNode.historyNode.tapped = nil
}
}
strongSelf.chatDisplayNode.historyNode.tapped = { [weak controller] in
controller?.dimTapped()
}
strongSelf.push(controller)
strongSelf.themeScreen = controller
})
}
func presentEmojiList(references: [StickerPackReference], previewIconFile: TelegramMediaFile? = nil) {
guard let packReference = references.first else {
return
}
self.chatDisplayNode.dismissTextInput()
var previewIconFile: TelegramMediaFile? = previewIconFile
if let file = previewIconFile, let peerId = self.chatLocation.peerId, !file.isValidForDisplay(chatPeerId: peerId) {
previewIconFile = nil
}
let presentationData = self.presentationData
let controller = StickerPackScreen(context: self.context, updatedPresentationData: self.updatedPresentationData, mainStickerPack: packReference, stickerPacks: Array(references), previewIconFile: previewIconFile, parentNavigationController: self.effectiveNavigationController, sendEmoji: canSendMessagesToChat(self.presentationInterfaceState) ? { [weak self] text, attribute in
if let strongSelf = self {
strongSelf.controllerInteraction?.sendEmoji(text, attribute, false)
}
} : nil, actionPerformed: { [weak self] actions in
guard let strongSelf = self else {
return
}
let context = strongSelf.context
if actions.count > 1, let first = actions.first {
if case .add = first.2 {
strongSelf.presentInGlobalOverlay(UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.EmojiPackActionInfo_AddedTitle, text: presentationData.strings.EmojiPackActionInfo_MultipleAddedText(Int32(actions.count)), undo: false, info: first.0, topItem: first.1.first, context: context), elevatedLayout: true, animateInAsReplacement: false, action: { _ in
return true
}))
} else if actions.allSatisfy({
if case .remove = $0.2 {
return true
} else {
return false
}
}) {
let isEmoji = actions[0].0.id.namespace == Namespaces.ItemCollection.CloudEmojiPacks
strongSelf.presentInGlobalOverlay(UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: isEmoji ? presentationData.strings.EmojiPackActionInfo_RemovedTitle : presentationData.strings.StickerPackActionInfo_RemovedTitle, text: isEmoji ? presentationData.strings.EmojiPackActionInfo_MultipleRemovedText(Int32(actions.count)) : presentationData.strings.StickerPackActionInfo_MultipleRemovedText(Int32(actions.count)), undo: true, info: actions[0].0, topItem: actions[0].1.first, context: context), elevatedLayout: true, animateInAsReplacement: false, action: { action in
if case .undo = action {
var itemsAndIndices: [(StickerPackCollectionInfo, [StickerPackItem], Int)] = actions.compactMap { action -> (StickerPackCollectionInfo, [StickerPackItem], Int)? in
if case let .remove(index) = action.2 {
return (action.0, action.1, index)
} else {
return nil
}
}
itemsAndIndices.sort(by: { $0.2 < $1.2 })
for (info, items, index) in itemsAndIndices.reversed() {
let _ = context.engine.stickers.addStickerPackInteractively(info: info, items: items, positionInList: index).startStandalone()
}
}
return true
}))
}
} else if let (info, items, action) = actions.first {
let isEmoji = info.id.namespace == Namespaces.ItemCollection.CloudEmojiPacks
switch action {
case .add:
strongSelf.presentInGlobalOverlay(UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: isEmoji ? presentationData.strings.EmojiPackActionInfo_AddedTitle : presentationData.strings.StickerPackActionInfo_AddedTitle, text: isEmoji ? presentationData.strings.EmojiPackActionInfo_AddedText(info.title).string : presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: context), elevatedLayout: true, animateInAsReplacement: false, action: { _ in
return true
}))
case let .remove(positionInList):
strongSelf.presentInGlobalOverlay(UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: isEmoji ? presentationData.strings.EmojiPackActionInfo_RemovedTitle : presentationData.strings.StickerPackActionInfo_RemovedTitle, text: isEmoji ? presentationData.strings.EmojiPackActionInfo_RemovedText(info.title).string : presentationData.strings.StickerPackActionInfo_RemovedText(info.title).string, undo: true, info: info, topItem: items.first, context: context), elevatedLayout: true, animateInAsReplacement: false, action: { action in
if case .undo = action {
let _ = context.engine.stickers.addStickerPackInteractively(info: info, items: items, positionInList: positionInList).startStandalone()
}
return true
}))
}
}
})
self.present(controller, in: .window(.root))
}
}
@@ -0,0 +1,100 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import UndoUI
import AccountContext
import ChatControllerInteraction
extension ChatControllerImpl {
func displayPostedScheduledMessagesToast(ids: [EngineMessage.Id]) {
let timestamp = CFAbsoluteTimeGetCurrent()
if self.lastPostedScheduledMessagesToastTimestamp + 0.4 >= timestamp {
return
}
self.lastPostedScheduledMessagesToastTimestamp = timestamp
guard case .scheduledMessages = self.presentationInterfaceState.subject else {
return
}
let _ = (self.context.engine.data.get(
EngineDataList(ids.map(TelegramEngine.EngineData.Item.Messages.Message.init(id:)))
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] messages in
guard let self else {
return
}
let messages = messages.compactMap { $0 }
var found: (message: EngineMessage, file: TelegramMediaFile)?
outer: for message in messages {
for media in message.media {
if let file = media as? TelegramMediaFile, file.isVideo {
found = (message, file)
break outer
}
}
}
guard let (message, file) = found else {
return
}
guard case let .loaded(isEmpty, _) = self.chatDisplayNode.historyNode.currentHistoryState else {
return
}
if isEmpty {
if let navigationController = self.navigationController as? NavigationController, let topController = navigationController.viewControllers.first(where: { c in
if let c = c as? ChatController, c.chatLocation == self.chatLocation {
return true
}
return false
}) as? ChatControllerImpl {
topController.controllerInteraction?.presentControllerInCurrent(UndoOverlayController(
presentationData: self.presentationData,
content: .media(
context: self.context,
file: .message(message: MessageReference(message._asMessage()), media: file),
title: nil,
text: self.presentationData.strings.Chat_ToastVideoPublished_Title,
undoText: nil,
customAction: nil
),
elevatedLayout: false,
position: .top,
animateInAsReplacement: false,
action: { _ in false }
), nil)
self.dismiss()
}
} else {
self.controllerInteraction?.presentControllerInCurrent(UndoOverlayController(
presentationData: self.presentationData,
content: .media(
context: self.context,
file: .message(message: MessageReference(message._asMessage()), media: file),
title: nil,
text: self.presentationData.strings.Chat_ToastVideoPublished_Title,
undoText: self.presentationData.strings.Chat_ToastVideoPublished_Action,
customAction: { [weak self] in
guard let self else {
return
}
self.dismiss()
}
),
elevatedLayout: false,
position: .top,
animateInAsReplacement: false,
action: { _ in false }
), nil)
}
})
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,300 @@
import Foundation
import UIKit
import AsyncDisplayKit
import ContextUI
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramNotices
import ChatSendMessageActionUI
import AccountContext
import TopMessageReactions
import ReactionSelectionNode
import ChatControllerInteraction
import ChatSendAudioMessageContextPreview
extension ChatSendMessageEffect {
convenience init(_ effect: ChatSendMessageActionSheetController.SendParameters.Effect) {
self.init(id: effect.id)
}
}
func chatMessageDisplaySendMessageOptions(selfController: ChatControllerImpl, node: ASDisplayNode, gesture: ContextGesture) {
guard let peerId = selfController.chatLocation.peerId, let textInputView = selfController.chatDisplayNode.textInputView(), let layout = selfController.validLayout else {
return
}
let previousSupportedOrientations = selfController.supportedOrientations
if layout.size.width > layout.size.height {
selfController.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .landscape)
} else {
selfController.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
}
let _ = ApplicationSpecificNotice.incrementChatMessageOptionsTip(accountManager: selfController.context.sharedContext.accountManager, count: 4).startStandalone()
var hasEntityKeyboard = false
if case .media = selfController.presentationInterfaceState.inputMode {
hasEntityKeyboard = true
}
let effectItems: Signal<[ReactionItem]?, NoError>
if peerId != selfController.context.account.peerId && peerId.namespace == Namespaces.Peer.CloudUser {
effectItems = effectMessageReactions(context: selfController.context)
|> map(Optional.init)
} else {
effectItems = .single(nil)
}
let availableMessageEffects = selfController.context.availableMessageEffects |> take(1)
let hasPremium = selfController.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: selfController.context.account.peerId))
|> map { peer -> Bool in
guard case let .user(user) = peer else {
return false
}
return user.isPremium
}
let editMessages: Signal<[EngineMessage], NoError>
if let editMessage = selfController.presentationInterfaceState.interfaceState.editMessage {
editMessages = selfController.context.engine.data.get(
TelegramEngine.EngineData.Item.Messages.MessageGroup(id: editMessage.messageId)
)
} else {
editMessages = .single([])
}
var currentMessageEffect: ChatSendMessageActionSheetControllerSendParameters.Effect?
if selfController.presentationInterfaceState.interfaceState.editMessage == nil {
if let sendMessageEffect = selfController.presentationInterfaceState.interfaceState.sendMessageEffect {
currentMessageEffect = ChatSendMessageActionSheetControllerSendParameters.Effect(id: sendMessageEffect)
}
}
let _ = (combineLatest(
selfController.context.account.viewTracker.peerView(peerId) |> take(1),
effectItems,
availableMessageEffects,
hasPremium,
editMessages,
ChatSendMessageContextScreen.initialData(context: selfController.context, currentMessageEffectId: currentMessageEffect?.id)
)
|> deliverOnMainQueue).startStandalone(next: { [weak selfController] peerView, effectItems, availableMessageEffects, hasPremium, editMessages, initialData in
guard let selfController, let peer = peerViewMainPeer(peerView) else {
return
}
if let editMessage = selfController.presentationInterfaceState.interfaceState.editMessage {
if editMessages.isEmpty {
return
}
var mediaPreview: ChatSendMessageContextScreenMediaPreview?
if editMessages.contains(where: { message in
return message.media.contains(where: { media in
if media is TelegramMediaImage {
return true
} else if let file = media as? TelegramMediaFile, file.isVideo {
return true
} else if media is TelegramMediaPaidContent {
return true
}
return false
})
}) {
mediaPreview = ChatSendGroupMediaMessageContextPreview(
context: selfController.context,
presentationData: selfController.presentationData,
wallpaperBackgroundNode: selfController.chatDisplayNode.backgroundNode,
messages: editMessages
)
}
let mediaCaptionIsAbove: Bool
if let value = editMessage.mediaCaptionIsAbove {
mediaCaptionIsAbove = value
} else {
mediaCaptionIsAbove = editMessages.contains(where: {
$0.attributes.contains(where: {
$0 is InvertMediaMessageAttribute
})
})
}
let controller = makeChatSendMessageActionSheetController(
initialData: initialData,
context: selfController.context,
updatedPresentationData: selfController.updatedPresentationData,
peerId: selfController.presentationInterfaceState.chatLocation.peerId,
params: .editMessage(SendMessageActionSheetControllerParams.EditMessage(
messages: editMessages,
mediaPreview: mediaPreview,
mediaCaptionIsAbove: (mediaCaptionIsAbove, { [weak selfController] updatedMediaCaptionIsAbove in
guard let selfController else {
return
}
selfController.updateChatPresentationInterfaceState(animated: false, interactive: false, { state in
return state.updatedInterfaceState { interfaceState in
guard var editMessage = interfaceState.editMessage else {
return interfaceState
}
editMessage.mediaCaptionIsAbove = updatedMediaCaptionIsAbove
return interfaceState.withUpdatedEditMessage(editMessage)
}
})
})
)),
hasEntityKeyboard: hasEntityKeyboard,
gesture: gesture,
sourceSendButton: node,
textInputView: textInputView,
emojiViewProvider: selfController.chatDisplayNode.textInputPanelNode?.emojiViewProvider,
wallpaperBackgroundNode: selfController.chatDisplayNode.backgroundNode,
completion: { [weak selfController] in
guard let selfController else {
return
}
selfController.supportedOrientations = previousSupportedOrientations
},
sendMessage: { [weak selfController] mode, parameters in
guard let selfController else {
return
}
selfController.interfaceInteraction?.editMessage()
},
schedule: { _ in
},
editPrice: { _ in
}, openPremiumPaywall: { [weak selfController] c in
guard let selfController else {
return
}
selfController.push(c)
},
reactionItems: nil,
availableMessageEffects: nil,
isPremium: hasPremium
)
selfController.sendMessageActionsController = controller
if layout.isNonExclusive {
selfController.present(controller, in: .window(.root))
} else {
selfController.presentInGlobalOverlay(controller, with: nil)
}
} else {
var sendWhenOnlineAvailable = false
if let presence = peerView.peerPresences[peer.id] as? TelegramUserPresence, case let .present(until) = presence.status {
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
if currentTime > until {
sendWhenOnlineAvailable = true
}
}
if peer.id.namespace == Namespaces.Peer.CloudUser && peer.id.id._internalGetInt64Value() == 777000 {
sendWhenOnlineAvailable = false
}
if sendWhenOnlineAvailable {
let _ = ApplicationSpecificNotice.incrementSendWhenOnlineTip(accountManager: selfController.context.sharedContext.accountManager, count: 4).startStandalone()
}
var mediaPreview: ChatSendMessageContextScreenMediaPreview?
if let videoRecorderValue = selfController.videoRecorderValue {
mediaPreview = videoRecorderValue.makeSendMessageContextPreview()
}
if let mediaDraftState = selfController.presentationInterfaceState.interfaceState.mediaDraftState {
if case let .audio(audio) = mediaDraftState {
mediaPreview = ChatSendAudioMessageContextPreview(
context: selfController.context,
presentationData: selfController.presentationData,
wallpaperBackgroundNode: selfController.chatDisplayNode.backgroundNode,
waveform: audio.waveform
)
}
}
let controller = makeChatSendMessageActionSheetController(
initialData: initialData,
context: selfController.context,
updatedPresentationData: selfController.updatedPresentationData,
peerId: selfController.presentationInterfaceState.chatLocation.peerId,
params: .sendMessage(SendMessageActionSheetControllerParams.SendMessage(
isScheduledMessages: false,
mediaPreview: mediaPreview,
mediaCaptionIsAbove: nil,
messageEffect: (currentMessageEffect, { [weak selfController] updatedEffect in
guard let selfController else {
return
}
selfController.updateChatPresentationInterfaceState(transition: .immediate, interactive: true, { presentationInterfaceState in
return presentationInterfaceState.updatedInterfaceState { interfaceState in
return interfaceState.withUpdatedSendMessageEffect(updatedEffect?.id)
}
})
}),
attachment: false,
canSendWhenOnline: sendWhenOnlineAvailable,
forwardMessageIds: selfController.presentationInterfaceState.interfaceState.forwardMessageIds ?? [],
canMakePaidContent: false,
currentPrice: nil,
hasTimers: false,
sendPaidMessageStars: selfController.presentationInterfaceState.sendPaidMessageStars,
isMonoforum: selfController.presentationInterfaceState.renderedPeer?.peer?.isMonoForum ?? false
)),
hasEntityKeyboard: hasEntityKeyboard,
gesture: gesture,
sourceSendButton: node,
textInputView: textInputView,
emojiViewProvider: selfController.chatDisplayNode.textInputPanelNode?.emojiViewProvider,
wallpaperBackgroundNode: selfController.chatDisplayNode.backgroundNode,
completion: { [weak selfController] in
guard let selfController else {
return
}
selfController.supportedOrientations = previousSupportedOrientations
},
sendMessage: { [weak selfController] mode, parameters in
guard let selfController else {
return
}
switch mode {
case .generic:
selfController.controllerInteraction?.sendCurrentMessage(false, parameters?.effect.flatMap(ChatSendMessageEffect.init))
case .silently:
selfController.controllerInteraction?.sendCurrentMessage(true, parameters?.effect.flatMap(ChatSendMessageEffect.init))
case .whenOnline:
selfController.chatDisplayNode.sendCurrentMessage(scheduleTime: scheduleWhenOnlineTimestamp, messageEffect: parameters?.effect.flatMap(ChatSendMessageEffect.init)) { [weak selfController] in
guard let selfController else {
return
}
selfController.updateChatPresentationInterfaceState(animated: true, interactive: false, saveInterfaceState: selfController.presentationInterfaceState.subject != .scheduledMessages, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil).withUpdatedPostSuggestionState(nil).withUpdatedForwardMessageIds(nil).withUpdatedForwardOptionsState(nil).withUpdatedComposeInputState(ChatTextInputState(inputText: NSAttributedString(string: ""))) }
})
selfController.openScheduledMessages()
}
}
},
schedule: { [weak selfController] params in
guard let selfController else {
return
}
selfController.controllerInteraction?.scheduleCurrentMessage(params)
}, editPrice: { _ in
}, openPremiumPaywall: { [weak selfController] c in
guard let selfController else {
return
}
selfController.push(c)
},
reactionItems: (!textInputView.text.isEmpty || mediaPreview != nil) ? effectItems : nil,
availableMessageEffects: availableMessageEffects,
isPremium: hasPremium
)
selfController.sendMessageActionsController = controller
if layout.isNonExclusive {
selfController.present(controller, in: .window(.root))
} else {
selfController.presentInGlobalOverlay(controller, with: nil)
}
}
})
}
@@ -0,0 +1,37 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AccountContext
func peerMessageSelectedReactions(context: AccountContext, message: Message) -> Signal<(reactions: Set<MessageReaction.Reaction>, files: Set<MediaId>), NoError> {
return context.engine.stickers.availableReactions()
|> take(1)
|> map { availableReactions -> (reactions: Set<MessageReaction.Reaction>, files: Set<MediaId>) in
var result = Set<MediaId>()
var reactions = Set<MessageReaction.Reaction>()
if let effectiveReactions = message.effectiveReactions(isTags: message.areReactionsTags(accountPeerId: context.account.peerId)) {
for reaction in effectiveReactions {
if !reaction.isSelected {
continue
}
if case .stars = reaction.value {
continue
}
reactions.insert(reaction.value)
switch reaction.value {
case .builtin, .stars:
if let availableReaction = availableReactions?.reactions.first(where: { $0.value == reaction.value }) {
result.insert(availableReaction.selectAnimation.fileId)
}
case let .custom(fileId):
result.insert(MediaId(namespace: Namespaces.Media.CloudFile, id: fileId))
}
}
}
return (reactions, result)
}
}
@@ -0,0 +1,609 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import AccountContext
import ChatPresentationInterfaceState
import ChatInterfaceState
import TelegramNotices
import PresentationDataUtils
import TelegramCallsUI
import AttachmentUI
import WebUI
func updateChatPresentationInterfaceStateImpl(
selfController: ChatControllerImpl,
transition: ContainedViewLayoutTransition,
interactive: Bool,
saveInterfaceState: Bool,
_ f: (ChatPresentationInterfaceState) -> ChatPresentationInterfaceState,
completion externalCompletion: @escaping (ContainedViewLayoutTransition) -> Void
) {
var transition = transition
if !selfController.didAppear {
transition = .immediate
}
var completion = externalCompletion
var temporaryChatPresentationInterfaceState = f(selfController.presentationInterfaceState)
if selfController.presentationInterfaceState.keyboardButtonsMessage?.visibleButtonKeyboardMarkup != temporaryChatPresentationInterfaceState.keyboardButtonsMessage?.visibleButtonKeyboardMarkup || selfController.presentationInterfaceState.keyboardButtonsMessage?.id != temporaryChatPresentationInterfaceState.keyboardButtonsMessage?.id {
if let keyboardButtonsMessage = temporaryChatPresentationInterfaceState.keyboardButtonsMessage, let keyboardMarkup = keyboardButtonsMessage.visibleButtonKeyboardMarkup {
if selfController.presentationInterfaceState.interfaceState.editMessage == nil && selfController.presentationInterfaceState.interfaceState.composeInputState.inputText.length == 0 && keyboardButtonsMessage.id != temporaryChatPresentationInterfaceState.interfaceState.messageActionsState.closedButtonKeyboardMessageId && keyboardButtonsMessage.id != temporaryChatPresentationInterfaceState.interfaceState.messageActionsState.dismissedButtonKeyboardMessageId && temporaryChatPresentationInterfaceState.botStartPayload == nil {
temporaryChatPresentationInterfaceState = temporaryChatPresentationInterfaceState.updatedInputMode({ _ in
return .inputButtons(persistent: keyboardMarkup.flags.contains(.persistent))
})
}
if case let .peer(peerId) = selfController.chatLocation, peerId.namespace == Namespaces.Peer.CloudChannel || peerId.namespace == Namespaces.Peer.CloudGroup {
if temporaryChatPresentationInterfaceState.interfaceState.replyMessageSubject == nil && temporaryChatPresentationInterfaceState.interfaceState.messageActionsState.processedSetupReplyMessageId != keyboardButtonsMessage.id {
temporaryChatPresentationInterfaceState = temporaryChatPresentationInterfaceState.updatedInterfaceState({
$0.withUpdatedReplyMessageSubject(ChatInterfaceState.ReplyMessageSubject(
messageId: keyboardButtonsMessage.id,
quote: nil,
todoItemId: nil
)).withUpdatedMessageActionsState({ value in
var value = value
value.processedSetupReplyMessageId = keyboardButtonsMessage.id
return value
}) })
}
}
} else {
temporaryChatPresentationInterfaceState = temporaryChatPresentationInterfaceState.updatedInputMode({ mode in
if case .inputButtons = mode {
return .text
} else {
return mode
}
})
}
}
if let keyboardButtonsMessage = temporaryChatPresentationInterfaceState.keyboardButtonsMessage, keyboardButtonsMessage.requestsSetupReply {
if temporaryChatPresentationInterfaceState.interfaceState.replyMessageSubject == nil && temporaryChatPresentationInterfaceState.interfaceState.messageActionsState.processedSetupReplyMessageId != keyboardButtonsMessage.id {
temporaryChatPresentationInterfaceState = temporaryChatPresentationInterfaceState.updatedInterfaceState({ $0.withUpdatedReplyMessageSubject(ChatInterfaceState.ReplyMessageSubject(
messageId: keyboardButtonsMessage.id,
quote: nil,
todoItemId: nil
)).withUpdatedMessageActionsState({ value in
var value = value
value.processedSetupReplyMessageId = keyboardButtonsMessage.id
return value
}) })
}
}
let inputTextPanelState = inputTextPanelStateForChatPresentationInterfaceState(temporaryChatPresentationInterfaceState, context: selfController.context)
var updatedChatPresentationInterfaceState = temporaryChatPresentationInterfaceState.updatedInputTextPanelState({ _ in return inputTextPanelState })
let contextQueryUpdates = contextQueryResultStateForChatInterfacePresentationState(updatedChatPresentationInterfaceState, context: selfController.context, currentQueryStates: &selfController.contextQueryStates, requestBotLocationStatus: { [weak selfController] peerId in
guard let selfController else {
return
}
let _ = (ApplicationSpecificNotice.updateInlineBotLocationRequestState(accountManager: selfController.context.sharedContext.accountManager, peerId: peerId, timestamp: Int32(Date().timeIntervalSince1970 + 10 * 60))
|> deliverOnMainQueue).startStandalone(next: { [weak selfController] value in
guard let selfController, value else {
return
}
selfController.present(textAlertController(context: selfController.context, updatedPresentationData: selfController.updatedPresentationData, title: nil, text: selfController.presentationData.strings.Conversation_ShareInlineBotLocationConfirmation, actions: [TextAlertAction(type: .defaultAction, title: selfController.presentationData.strings.Common_Cancel, action: {
}), TextAlertAction(type: .defaultAction, title: selfController.presentationData.strings.Common_OK, action: { [weak selfController] in
guard let selfController else {
return
}
let _ = ApplicationSpecificNotice.setInlineBotLocationRequest(accountManager: selfController.context.sharedContext.accountManager, peerId: peerId, value: 0).startStandalone()
})]), in: .window(.root))
})
})
for (kind, update) in contextQueryUpdates {
switch update {
case .remove:
if let (_, disposable) = selfController.contextQueryStates[kind] {
disposable.dispose()
selfController.contextQueryStates.removeValue(forKey: kind)
updatedChatPresentationInterfaceState = updatedChatPresentationInterfaceState.updatedInputQueryResult(queryKind: kind, { _ in
return nil
})
}
if case .contextRequest = kind {
selfController.performingInlineSearch.set(false)
}
case let .update(query, signal):
let currentQueryAndDisposable = selfController.contextQueryStates[kind]
currentQueryAndDisposable?.1.dispose()
var inScope = true
var inScopeResult: ((ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?)?
selfController.contextQueryStates[kind] = (query, (signal
|> deliverOnMainQueue).startStrict(next: { [weak selfController] result in
guard let selfController else {
return
}
if Thread.isMainThread && inScope {
inScope = false
inScopeResult = result
} else {
selfController.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInputQueryResult(queryKind: kind, { previousResult in
return result(previousResult)
})
})
}
}, error: { [weak selfController] error in
guard let selfController else {
return
}
if case .contextRequest = kind {
selfController.performingInlineSearch.set(false)
}
switch error {
case .generic:
break
case let .inlineBotLocationRequest(peerId):
selfController.present(textAlertController(context: selfController.context, updatedPresentationData: selfController.updatedPresentationData, title: nil, text: selfController.presentationData.strings.Conversation_ShareInlineBotLocationConfirmation, actions: [TextAlertAction(type: .defaultAction, title: selfController.presentationData.strings.Common_Cancel, action: { [weak selfController] in
guard let selfController else {
return
}
let _ = ApplicationSpecificNotice.setInlineBotLocationRequest(accountManager: selfController.context.sharedContext.accountManager, peerId: peerId, value: Int32(Date().timeIntervalSince1970 + 10 * 60)).startStandalone()
}), TextAlertAction(type: .defaultAction, title: selfController.presentationData.strings.Common_OK, action: { [weak selfController] in
guard let selfController else {
return
}
let _ = ApplicationSpecificNotice.setInlineBotLocationRequest(accountManager: selfController.context.sharedContext.accountManager, peerId: peerId, value: 0).startStandalone()
})]), in: .window(.root))
}
}, completed: { [weak selfController] in
guard let selfController else {
return
}
if case .contextRequest = kind {
selfController.performingInlineSearch.set(false)
}
}))
inScope = false
if let inScopeResult = inScopeResult {
updatedChatPresentationInterfaceState = updatedChatPresentationInterfaceState.updatedInputQueryResult(queryKind: kind, { previousResult in
return inScopeResult(previousResult)
})
} else {
if case .contextRequest = kind {
selfController.performingInlineSearch.set(true)
}
}
if case let .peer(peerId) = selfController.chatLocation, peerId.namespace == Namespaces.Peer.SecretChat {
if case .contextRequest = query {
let _ = (ApplicationSpecificNotice.getSecretChatInlineBotUsage(accountManager: selfController.context.sharedContext.accountManager)
|> deliverOnMainQueue).startStandalone(next: { [weak selfController] value in
guard let selfController, !value else {
return
}
let _ = ApplicationSpecificNotice.setSecretChatInlineBotUsage(accountManager: selfController.context.sharedContext.accountManager).startStandalone()
selfController.present(textAlertController(context: selfController.context, updatedPresentationData: selfController.updatedPresentationData, title: nil, text: selfController.presentationData.strings.Conversation_SecretChatContextBotAlert, actions: [TextAlertAction(type: .defaultAction, title: selfController.presentationData.strings.Common_OK, action: {})]), in: .window(.root))
})
}
}
}
}
var isBot = false
if let peer = updatedChatPresentationInterfaceState.renderedPeer?.peer as? TelegramUser, peer.botInfo != nil {
isBot = true
} else {
isBot = false
}
selfController.chatDisplayNode.historyNode.chatHasBots = updatedChatPresentationInterfaceState.hasBots || isBot
if let (updatedSearchQuerySuggestionState, updatedSearchQuerySuggestionSignal) = searchQuerySuggestionResultStateForChatInterfacePresentationState(updatedChatPresentationInterfaceState, context: selfController.context, currentQuery: selfController.searchQuerySuggestionState?.0) {
selfController.searchQuerySuggestionState?.1.dispose()
var inScope = true
var inScopeResult: ((ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?)?
selfController.searchQuerySuggestionState = (updatedSearchQuerySuggestionState, (updatedSearchQuerySuggestionSignal |> deliverOnMainQueue).startStrict(next: { [weak selfController] result in
guard let selfController else {
return
}
if Thread.isMainThread && inScope {
inScope = false
inScopeResult = result
} else {
selfController.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedSearchQuerySuggestionResult { previousResult in
return result(previousResult)
}
})
}
}))
inScope = false
if let inScopeResult = inScopeResult {
updatedChatPresentationInterfaceState = updatedChatPresentationInterfaceState.updatedSearchQuerySuggestionResult { previousResult in
return inScopeResult(previousResult)
}
}
}
var canHaveUrlPreview = true
if case let .customChatContents(customChatContents) = updatedChatPresentationInterfaceState.subject {
switch customChatContents.kind {
case .hashTagSearch:
break
case .quickReplyMessageInput:
break
case .businessLinkSetup:
canHaveUrlPreview = false
}
}
if canHaveUrlPreview, let (updatedUrlPreviewState, updatedUrlPreviewSignal) = urlPreviewStateForInputText(updatedChatPresentationInterfaceState.interfaceState.composeInputState.inputText, context: selfController.context, currentQuery: selfController.urlPreviewQueryState?.0, forPeerId: selfController.chatLocation.peerId) {
selfController.urlPreviewQueryState?.1.dispose()
var inScope = true
var inScopeResult: ((TelegramMediaWebpage?) -> (TelegramMediaWebpage, String)?)?
let linkPreviews: Signal<Bool, NoError>
if case let .peer(peerId) = selfController.chatLocation, peerId.namespace == Namespaces.Peer.SecretChat {
linkPreviews = interactiveChatLinkPreviewsEnabled(accountManager: selfController.context.sharedContext.accountManager, displayAlert: { [weak selfController] f in
guard let selfController else {
return
}
selfController.present(textAlertController(context: selfController.context, updatedPresentationData: selfController.updatedPresentationData, title: nil, text: selfController.presentationData.strings.Conversation_SecretLinkPreviewAlert, actions: [
TextAlertAction(type: .defaultAction, title: selfController.presentationData.strings.Common_Yes, action: {
f.f(true)
}), TextAlertAction(type: .genericAction, title: selfController.presentationData.strings.Common_No, action: {
f.f(false)
})]), in: .window(.root))
})
} else {
var bannedEmbedLinks = false
if let channel = selfController.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.hasBannedPermission(.banEmbedLinks) != nil {
bannedEmbedLinks = true
} else if let group = selfController.presentationInterfaceState.renderedPeer?.peer as? TelegramGroup, group.hasBannedPermission(.banEmbedLinks) {
bannedEmbedLinks = true
}
if bannedEmbedLinks {
linkPreviews = .single(false)
} else {
linkPreviews = .single(true)
}
}
let filteredPreviewSignal = linkPreviews
|> take(1)
|> mapToSignal { value -> Signal<(TelegramMediaWebpage?) -> (TelegramMediaWebpage, String)?, NoError> in
if value {
return updatedUrlPreviewSignal
} else {
return .single({ _ in return nil })
}
}
selfController.urlPreviewQueryState = (updatedUrlPreviewState, (filteredPreviewSignal |> deliverOnMainQueue).startStrict(next: { [weak selfController] result in
guard let selfController else {
return
}
if Thread.isMainThread && inScope {
inScope = false
inScopeResult = result
} else {
selfController.updateChatPresentationInterfaceState(animated: true, interactive: false, {
if let (webpage, webpageUrl) = result($0.urlPreview?.webPage) {
let updatedPreview = ChatPresentationInterfaceState.UrlPreview(
url: webpageUrl,
webPage: webpage,
positionBelowText: $0.urlPreview?.positionBelowText ?? true,
largeMedia: $0.urlPreview?.largeMedia
)
return $0.updatedUrlPreview(updatedPreview)
} else {
return $0.updatedUrlPreview(nil)
}
})
}
}))
inScope = false
if let inScopeResult = inScopeResult {
if let (webpage, webpageUrl) = inScopeResult(updatedChatPresentationInterfaceState.urlPreview?.webPage) {
let updatedPreview = ChatPresentationInterfaceState.UrlPreview(
url: webpageUrl,
webPage: webpage,
positionBelowText: updatedChatPresentationInterfaceState.urlPreview?.positionBelowText ?? true,
largeMedia: updatedChatPresentationInterfaceState.urlPreview?.largeMedia
)
updatedChatPresentationInterfaceState = updatedChatPresentationInterfaceState.updatedUrlPreview(updatedPreview)
} else {
updatedChatPresentationInterfaceState = updatedChatPresentationInterfaceState.updatedUrlPreview(nil)
}
}
}
let isEditingMedia: Bool = updatedChatPresentationInterfaceState.editMessageState?.content != .plaintext
let editingUrlPreviewText: NSAttributedString? = isEditingMedia ? nil : updatedChatPresentationInterfaceState.interfaceState.editMessage?.inputState.inputText
if let (updatedEditingUrlPreviewState, updatedEditingUrlPreviewSignal) = urlPreviewStateForInputText(editingUrlPreviewText, context: selfController.context, currentQuery: selfController.editingUrlPreviewQueryState?.0, forPeerId: selfController.chatLocation.peerId) {
selfController.editingUrlPreviewQueryState?.1.dispose()
var inScope = true
var inScopeResult: ((TelegramMediaWebpage?) -> (TelegramMediaWebpage, String)?)?
selfController.editingUrlPreviewQueryState = (updatedEditingUrlPreviewState, (updatedEditingUrlPreviewSignal |> deliverOnMainQueue).startStrict(next: { [weak selfController] result in
guard let selfController else {
return
}
if Thread.isMainThread && inScope {
inScope = false
inScopeResult = result
} else {
selfController.updateChatPresentationInterfaceState(animated: true, interactive: false, {
if let (webpage, webpageUrl) = result($0.editingUrlPreview?.webPage) {
let updatedPreview = ChatPresentationInterfaceState.UrlPreview(
url: webpageUrl,
webPage: webpage,
positionBelowText: $0.editingUrlPreview?.positionBelowText ?? true,
largeMedia: $0.editingUrlPreview?.largeMedia
)
return $0.updatedEditingUrlPreview(updatedPreview)
} else {
return $0.updatedEditingUrlPreview(nil)
}
})
}
}))
inScope = false
if let inScopeResult = inScopeResult {
if let (webpage, webpageUrl) = inScopeResult(updatedChatPresentationInterfaceState.editingUrlPreview?.webPage) {
let updatedPreview = ChatPresentationInterfaceState.UrlPreview(
url: webpageUrl,
webPage: webpage,
positionBelowText: updatedChatPresentationInterfaceState.editingUrlPreview?.positionBelowText ?? true,
largeMedia: updatedChatPresentationInterfaceState.editingUrlPreview?.largeMedia
)
updatedChatPresentationInterfaceState = updatedChatPresentationInterfaceState.updatedEditingUrlPreview(updatedPreview)
} else {
updatedChatPresentationInterfaceState = updatedChatPresentationInterfaceState.updatedEditingUrlPreview(nil)
}
}
}
if let replyMessageId = updatedChatPresentationInterfaceState.interfaceState.replyMessageSubject?.messageId {
if selfController.replyMessageState?.0 != replyMessageId {
selfController.replyMessageState?.1.dispose()
updatedChatPresentationInterfaceState = updatedChatPresentationInterfaceState.updatedReplyMessage(nil)
let disposable = MetaDisposable()
selfController.replyMessageState = (replyMessageId, disposable)
disposable.set((selfController.context.engine.data.subscribe(TelegramEngine.EngineData.Item.Messages.Message(id: replyMessageId))
|> deliverOnMainQueue).start(next: { [weak selfController] message in
guard let selfController else {
return
}
if message != selfController.presentationInterfaceState.replyMessage.flatMap(EngineMessage.init) {
selfController.updateChatPresentationInterfaceState(interactive: false, { presentationInterfaceState in
return presentationInterfaceState.updatedReplyMessage(message?._asMessage())
})
}
}))
}
} else {
if let replyMessageState = selfController.replyMessageState {
selfController.replyMessageState = nil
replyMessageState.1.dispose()
updatedChatPresentationInterfaceState = updatedChatPresentationInterfaceState.updatedReplyMessage(nil)
}
}
if let updated = selfController.updateSearch(updatedChatPresentationInterfaceState) {
updatedChatPresentationInterfaceState = updated
}
let recordingActivityValue: ChatRecordingActivity
if let mediaRecordingState = updatedChatPresentationInterfaceState.inputTextPanelState.mediaRecordingState {
switch mediaRecordingState {
case .audio:
recordingActivityValue = .voice
case .video(ChatVideoRecordingStatus.recording, _):
recordingActivityValue = .instantVideo
default:
recordingActivityValue = .none
}
} else {
recordingActivityValue = .none
}
if recordingActivityValue != selfController.recordingActivityValue {
selfController.recordingActivityValue = recordingActivityValue
selfController.recordingActivityPromise.set(recordingActivityValue)
}
if (selfController.presentationInterfaceState.interfaceState.selectionState == nil) != (updatedChatPresentationInterfaceState.interfaceState.selectionState == nil) {
selfController.isSelectingMessagesUpdated?(updatedChatPresentationInterfaceState.interfaceState.selectionState != nil)
selfController.updateNextChannelToReadVisibility()
}
if updatedChatPresentationInterfaceState.displayHistoryFilterAsList {
var canDisplayAsList = false
if updatedChatPresentationInterfaceState.search != nil {
if updatedChatPresentationInterfaceState.search?.resultsState != nil {
canDisplayAsList = true
}
if updatedChatPresentationInterfaceState.historyFilter != nil {
canDisplayAsList = true
}
if case .peer(selfController.context.account.peerId) = updatedChatPresentationInterfaceState.chatLocation {
canDisplayAsList = true
}
}
if selfController.alwaysShowSearchResultsAsList {
canDisplayAsList = true
}
if !canDisplayAsList {
updatedChatPresentationInterfaceState = updatedChatPresentationInterfaceState.updatedDisplayHistoryFilterAsList(false)
}
}
selfController.presentationInterfaceState = updatedChatPresentationInterfaceState
selfController.updateSlowmodeStatus()
switch updatedChatPresentationInterfaceState.inputMode {
case .media:
break
default:
selfController.chatDisplayNode.collapseInput()
}
selfController.tempHideAccessoryPanels = selfController.presentationInterfaceState.search != nil
if selfController.isNodeLoaded {
selfController.chatDisplayNode.updateChatPresentationInterfaceState(updatedChatPresentationInterfaceState, transition: transition, interactive: interactive, completion: completion)
} else {
completion(.immediate)
}
let updatedServiceTasks = serviceTasksForChatPresentationIntefaceState(context: selfController.context, chatPresentationInterfaceState: updatedChatPresentationInterfaceState, updateState: { [weak selfController] f in
guard let selfController else {
return
}
selfController.updateChatPresentationInterfaceState(animated: false, interactive: false, f)
//selfController.chatDisplayNode.updateChatPresentationInterfaceState(f(selfController.chatDisplayNode.chatPresentationInterfaceState), transition: transition, interactive: false, completion: { _ in })
})
for (id, begin) in updatedServiceTasks {
if selfController.stateServiceTasks[id] == nil {
selfController.stateServiceTasks[id] = begin()
}
}
var removedServiceTaskIds: [AnyHashable] = []
for (id, _) in selfController.stateServiceTasks {
if updatedServiceTasks[id] == nil {
removedServiceTaskIds.append(id)
}
}
for id in removedServiceTaskIds {
selfController.stateServiceTasks.removeValue(forKey: id)?.dispose()
}
if let button = leftNavigationButtonForChatInterfaceState(updatedChatPresentationInterfaceState, subject: selfController.subject, strings: updatedChatPresentationInterfaceState.strings, currentButton: selfController.leftNavigationButton, target: selfController, selector: #selector(selfController.leftNavigationButtonAction)) {
if selfController.leftNavigationButton != button {
var animated = transition.isAnimated
if let currentButton = selfController.leftNavigationButton?.action, currentButton == button.action {
animated = false
}
animated = false
selfController.navigationItem.setLeftBarButton(button.buttonItem, animated: animated && selfController.currentChatSwitchDirection == nil)
selfController.leftNavigationButton = button
}
} else if let _ = selfController.leftNavigationButton {
selfController.navigationItem.setLeftBarButton(nil, animated: transition.isAnimated && selfController.currentChatSwitchDirection == nil)
selfController.leftNavigationButton = nil
}
var buttonsAnimated = transition.isAnimated
if let button = rightNavigationButtonForChatInterfaceState(context: selfController.context, presentationInterfaceState: updatedChatPresentationInterfaceState, strings: updatedChatPresentationInterfaceState.strings, currentButton: selfController.rightNavigationButton, target: selfController, selector: #selector(selfController.rightNavigationButtonAction), chatInfoNavigationButton: selfController.chatInfoNavigationButton, moreInfoNavigationButton: selfController.moreInfoNavigationButton) {
if selfController.rightNavigationButton != button {
if let currentButton = selfController.rightNavigationButton?.action, currentButton == button.action {
buttonsAnimated = false
}
selfController.rightNavigationButton = button
}
} else if let _ = selfController.rightNavigationButton {
selfController.rightNavigationButton = nil
}
if let button = secondaryRightNavigationButtonForChatInterfaceState(context: selfController.context, presentationInterfaceState: updatedChatPresentationInterfaceState, strings: updatedChatPresentationInterfaceState.strings, currentButton: selfController.secondaryRightNavigationButton, target: selfController, selector: #selector(selfController.secondaryRightNavigationButtonAction), chatInfoNavigationButton: selfController.chatInfoNavigationButton, moreInfoNavigationButton: selfController.moreInfoNavigationButton) {
if selfController.secondaryRightNavigationButton != button {
if let currentButton = selfController.secondaryRightNavigationButton?.action, currentButton == button.action {
buttonsAnimated = false
}
if case .replyThread = selfController.chatLocation {
buttonsAnimated = false
}
selfController.secondaryRightNavigationButton = button
}
} else if let _ = selfController.secondaryRightNavigationButton {
selfController.secondaryRightNavigationButton = nil
}
var rightBarButtons: [UIBarButtonItem] = []
if let rightNavigationButton = selfController.rightNavigationButton {
rightBarButtons.append(rightNavigationButton.buttonItem)
}
if let secondaryRightNavigationButton = selfController.secondaryRightNavigationButton {
rightBarButtons.append(secondaryRightNavigationButton.buttonItem)
}
var rightBarButtonsUpdated = false
let currentRightBarButtons = selfController.navigationItem.rightBarButtonItems ?? []
if rightBarButtons.count != currentRightBarButtons.count {
rightBarButtonsUpdated = true
} else {
for i in 0 ..< rightBarButtons.count {
if rightBarButtons[i] !== currentRightBarButtons[i] {
rightBarButtonsUpdated = true
break
}
}
}
if rightBarButtonsUpdated {
selfController.navigationItem.setRightBarButtonItems(rightBarButtons, animated: buttonsAnimated)
}
if let controllerInteraction = selfController.controllerInteraction {
if updatedChatPresentationInterfaceState.interfaceState.selectionState != controllerInteraction.selectionState {
controllerInteraction.selectionState = updatedChatPresentationInterfaceState.interfaceState.selectionState
let isBlackout = controllerInteraction.selectionState != nil
let previousCompletion = completion
completion = { [weak selfController] transition in
previousCompletion(transition)
guard let selfController else {
return
}
(selfController.navigationController as? NavigationController)?.updateMasterDetailsBlackout(isBlackout ? .master : nil, transition: transition)
}
selfController.updateItemNodesSelectionStates(animated: transition.isAnimated)
}
}
if saveInterfaceState {
selfController.saveInterfaceState(includeScrollState: false)
}
if let navigationController = selfController.navigationController as? NavigationController, isTopmostChatController(selfController) {
var voiceChatOverlayController: VoiceChatOverlayController?
for controller in navigationController.globalOverlayControllers {
if let controller = controller as? VoiceChatOverlayController {
voiceChatOverlayController = controller
break
}
}
if let controller = voiceChatOverlayController {
controller.updateVisibility()
}
}
selfController.presentationInterfaceStatePromise.set(selfController.presentationInterfaceState)
if case .tag = selfController.chatDisplayNode.historyNode.tag {
} else {
if let historyFilter = selfController.presentationInterfaceState.historyFilter, historyFilter.isActive {
selfController.chatDisplayNode.historyNode.updateTag(tag: .customTag(historyFilter.customTag, nil))
} else {
selfController.chatDisplayNode.historyNode.updateTag(tag: nil)
}
}
selfController.updateDownButtonVisibility()
if selfController.presentationInterfaceState.hasBirthdayToday {
selfController.displayBirthdayTooltip()
}
if case .standard(.embedded) = selfController.presentationInterfaceState.mode, let controllerInteraction = selfController.controllerInteraction, let interfaceInteraction = selfController.interfaceInteraction {
if let titleAccessoryPanelNode = titlePanelForChatPresentationInterfaceState(selfController.presentationInterfaceState, context: selfController.context, currentPanel: selfController.customNavigationPanelNode as? ChatTitleAccessoryPanelNode, controllerInteraction: controllerInteraction, interfaceInteraction: interfaceInteraction, force: true) {
selfController.customNavigationPanelNode = titleAccessoryPanelNode as? ChatControllerCustomNavigationPanelNode
} else {
selfController.customNavigationPanelNode = nil
}
}
selfController.stateUpdated?(transition)
}
@@ -0,0 +1,523 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import StickerResources
import PhotoResources
import TelegramStringFormatting
import AnimatedCountLabelNode
import AnimatedNavigationStripeNode
import ContextUI
import RadialStatusNode
import TextFormat
import ChatPresentationInterfaceState
import TextNodeWithEntities
import AnimationCache
import MultiAnimationRenderer
import TranslateUI
import ChatControllerInteraction
private enum PinnedMessageAnimation {
case slideToTop
case slideToBottom
}
final class ChatAdPanelNode: ASDisplayNode {
private let context: AccountContext
private(set) var message: Message?
var controllerInteraction: ChatControllerInteraction?
private let tapButton: HighlightTrackingButtonNode
private let contextContainer: ContextControllerSourceNode
private let clippingContainer: ASDisplayNode
private let contentContainer: ASDisplayNode
private let contentTextContainer: ASDisplayNode
private let adNode: TextNode
private let titleNode: TextNode
private let textNode: TextNodeWithEntities
private let removeButtonNode: HighlightTrackingButtonNode
private let removeBackgroundNode: ASImageNode
private let removeTextNode: ImmediateTextNode
private let closeButton: HighlightableButtonNode
private let imageNode: TransformImageNode
private let imageNodeContainer: ASDisplayNode
private let separatorNode: ASDisplayNode
private var currentLayout: (CGFloat, CGFloat, CGFloat)?
private var currentMessage: Message?
private var previousMediaReference: AnyMediaReference?
private let fetchDisposable = MetaDisposable()
private let animationCache: AnimationCache?
private let animationRenderer: MultiAnimationRenderer?
init(context: AccountContext, animationCache: AnimationCache?, animationRenderer: MultiAnimationRenderer?) {
self.context = context
self.animationCache = animationCache
self.animationRenderer = animationRenderer
self.tapButton = HighlightTrackingButtonNode()
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.contextContainer = ContextControllerSourceNode()
self.clippingContainer = ASDisplayNode()
self.clippingContainer.clipsToBounds = true
self.contentContainer = ASDisplayNode()
self.contentTextContainer = ASDisplayNode()
self.adNode = TextNode()
self.adNode.displaysAsynchronously = false
self.adNode.isUserInteractionEnabled = false
self.removeButtonNode = HighlightTrackingButtonNode()
self.removeBackgroundNode = ASImageNode()
self.removeTextNode = ImmediateTextNode()
self.removeTextNode.displaysAsynchronously = false
self.removeTextNode.isUserInteractionEnabled = false
self.titleNode = TextNode()
self.titleNode.displaysAsynchronously = false
self.titleNode.isUserInteractionEnabled = false
self.textNode = TextNodeWithEntities()
self.textNode.textNode.displaysAsynchronously = false
self.textNode.textNode.isUserInteractionEnabled = false
self.imageNode = TransformImageNode()
self.imageNode.contentAnimations = [.subsequentUpdates]
self.imageNodeContainer = ASDisplayNode()
self.closeButton = HighlightableButtonNode()
self.closeButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0)
self.closeButton.displaysAsynchronously = false
super.init()
self.addSubnode(self.contextContainer)
self.contextContainer.addSubnode(self.clippingContainer)
self.clippingContainer.addSubnode(self.contentContainer)
self.contentTextContainer.addSubnode(self.titleNode)
self.contentTextContainer.addSubnode(self.adNode)
self.contentTextContainer.addSubnode(self.textNode.textNode)
self.contentContainer.addSubnode(self.contentTextContainer)
self.imageNodeContainer.addSubnode(self.imageNode)
self.contentContainer.addSubnode(self.imageNodeContainer)
self.tapButton.addTarget(self, action: #selector(self.tapped), forControlEvents: [.touchUpInside])
self.tapButton.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.adNode.layer.removeAnimation(forKey: "opacity")
strongSelf.adNode.alpha = 0.4
strongSelf.titleNode.layer.removeAnimation(forKey: "opacity")
strongSelf.titleNode.alpha = 0.4
strongSelf.textNode.textNode.layer.removeAnimation(forKey: "opacity")
strongSelf.textNode.textNode.alpha = 0.4
strongSelf.imageNode.layer.removeAnimation(forKey: "opacity")
strongSelf.imageNode.alpha = 0.4
strongSelf.removeTextNode.layer.removeAnimation(forKey: "opacity")
strongSelf.removeTextNode.alpha = 0.4
strongSelf.removeBackgroundNode.layer.removeAnimation(forKey: "opacity")
strongSelf.removeBackgroundNode.alpha = 0.4
} else {
strongSelf.adNode.alpha = 1.0
strongSelf.adNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
strongSelf.titleNode.alpha = 1.0
strongSelf.titleNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
strongSelf.textNode.textNode.alpha = 1.0
strongSelf.textNode.textNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
strongSelf.imageNode.alpha = 1.0
strongSelf.imageNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
strongSelf.removeTextNode.alpha = 1.0
strongSelf.removeTextNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
strongSelf.removeBackgroundNode.alpha = 1.0
strongSelf.removeBackgroundNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.contextContainer.addSubnode(self.tapButton)
self.contextContainer.addSubnode(self.removeBackgroundNode)
self.contextContainer.addSubnode(self.removeTextNode)
self.contextContainer.addSubnode(self.removeButtonNode)
self.addSubnode(self.separatorNode)
self.removeButtonNode.addTarget(self, action: #selector(self.removePressed), forControlEvents: [.touchUpInside])
self.removeButtonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.removeTextNode.layer.removeAnimation(forKey: "opacity")
strongSelf.removeTextNode.alpha = 0.4
strongSelf.removeBackgroundNode.layer.removeAnimation(forKey: "opacity")
strongSelf.removeBackgroundNode.alpha = 0.4
} else {
strongSelf.removeTextNode.alpha = 1.0
strongSelf.removeTextNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
strongSelf.removeBackgroundNode.alpha = 1.0
strongSelf.removeBackgroundNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.contextContainer.activated = { [weak self] gesture, _ in
guard let self, let message = self.message else {
return
}
self.controllerInteraction?.adContextAction(message, self.contextContainer, gesture)
}
self.closeButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: [.touchUpInside])
self.addSubnode(self.closeButton)
}
deinit {
self.fetchDisposable.dispose()
}
private var theme: PresentationTheme?
@objc private func closePressed() {
if self.context.isPremium, let adAttribute = self.message?.adAttribute {
self.controllerInteraction?.removeAd(adAttribute.opaqueId)
} else {
self.controllerInteraction?.openNoAdsDemo()
}
}
func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> CGFloat {
self.message = interfaceState.adMessage
if self.theme !== interfaceState.theme {
self.theme = interfaceState.theme
self.separatorNode.backgroundColor = interfaceState.theme.rootController.navigationBar.separatorColor
self.removeBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 15.0, color: interfaceState.theme.chat.inputPanel.panelControlAccentColor.withMultipliedAlpha(0.1))
self.removeTextNode.attributedText = NSAttributedString(string: interfaceState.strings.Chat_BotAd_WhatIsThis, font: Font.regular(11.0), textColor: interfaceState.theme.chat.inputPanel.panelControlAccentColor)
self.closeButton.setImage(PresentationResourcesChat.chatInputPanelCloseIconImage(interfaceState.theme), for: [])
}
self.contextContainer.isGestureEnabled = false
let panelHeight: CGFloat
var hasCloseButton = true
if let message = interfaceState.adMessage {
panelHeight = self.enqueueTransition(width: width, leftInset: leftInset, rightInset: rightInset, transition: .immediate, animation: nil, message: message, theme: interfaceState.theme, strings: interfaceState.strings, nameDisplayOrder: interfaceState.nameDisplayOrder, dateTimeFormat: interfaceState.dateTimeFormat, accountPeerId: self.context.account.peerId, firstTime: false, isReplyThread: false, translateToLanguage: nil)
hasCloseButton = message.media.isEmpty
} else {
panelHeight = 50.0
}
self.contextContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight))
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)))
self.tapButton.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight))
self.clippingContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight))
self.contentContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight))
let contentRightInset: CGFloat = 14.0 + rightInset
let closeButtonSize = self.closeButton.measure(CGSize(width: 100.0, height: 100.0))
self.closeButton.frame = CGRect(origin: CGPoint(x: width - contentRightInset - closeButtonSize.width, y: floorToScreenPixels((panelHeight - closeButtonSize.height) / 2.0)), size: closeButtonSize)
self.closeButton.isHidden = !hasCloseButton
self.currentLayout = (width, leftInset, rightInset)
return panelHeight
}
private func enqueueTransition(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, animation: PinnedMessageAnimation?, message: Message, theme: PresentationTheme, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, dateTimeFormat: PresentationDateTimeFormat, accountPeerId: PeerId, firstTime: Bool, isReplyThread: Bool, translateToLanguage: String?) -> CGFloat {
var animationTransition: ContainedViewLayoutTransition = .immediate
if let animation = animation {
animationTransition = .animated(duration: 0.2, curve: .easeInOut)
if let copyView = self.textNode.textNode.view.snapshotView(afterScreenUpdates: false) {
let offset: CGFloat
switch animation {
case .slideToTop:
offset = -10.0
case .slideToBottom:
offset = 10.0
}
copyView.frame = self.textNode.textNode.frame
self.textNode.textNode.view.superview?.addSubview(copyView)
copyView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: offset), duration: 0.2, removeOnCompletion: false, additive: true)
copyView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak copyView] _ in
copyView?.removeFromSuperview()
})
self.textNode.textNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -offset), to: CGPoint(), duration: 0.2, additive: true)
self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
let makeAdLayout = TextNode.asyncLayout(self.adNode)
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let makeTextLayout = TextNodeWithEntities.asyncLayout(self.textNode)
let imageNodeLayout = self.imageNode.asyncLayout()
let previousMediaReference = self.previousMediaReference
let context = self.context
let contentLeftInset: CGFloat = leftInset + 18.0
let contentRightInset: CGFloat = rightInset + 9.0
var textRightInset: CGFloat = 0.0
var updatedMediaReference: AnyMediaReference?
var imageDimensions: CGSize?
if !message.containsSecretMedia {
for media in message.media {
if let image = media as? TelegramMediaImage {
updatedMediaReference = .message(message: MessageReference(message), media: image)
if let representation = largestRepresentationForPhoto(image) {
imageDimensions = representation.dimensions.cgSize
}
break
} else if let file = media as? TelegramMediaFile {
updatedMediaReference = .message(message: MessageReference(message), media: file)
if !file.isInstantVideo && !file.isSticker, let representation = largestImageRepresentation(file.previewRepresentations) {
imageDimensions = representation.dimensions.cgSize
} else if file.isAnimated, let dimensions = file.dimensions {
imageDimensions = dimensions.cgSize
}
break
} else if let paidContent = media as? TelegramMediaPaidContent, let firstMedia = paidContent.extendedMedia.first {
switch firstMedia {
case let .preview(dimensions, immediateThumbnailData, _):
let thumbnailMedia = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: [], immediateThumbnailData: immediateThumbnailData, reference: nil, partialReference: nil, flags: [])
if let dimensions {
imageDimensions = dimensions.cgSize
}
updatedMediaReference = .standalone(media: thumbnailMedia)
case let .full(fullMedia):
updatedMediaReference = .message(message: MessageReference(message), media: fullMedia)
if let image = fullMedia as? TelegramMediaImage {
if let representation = largestRepresentationForPhoto(image) {
imageDimensions = representation.dimensions.cgSize
}
break
} else if let file = fullMedia as? TelegramMediaFile {
if let dimensions = file.dimensions {
imageDimensions = dimensions.cgSize
}
break
}
}
}
}
}
let imageBoundingSize = CGSize(width: 48.0, height: 48.0)
var applyImage: (() -> Void)?
if let imageDimensions {
applyImage = imageNodeLayout(TransformImageArguments(corners: ImageCorners(radius: 3.0), imageSize: imageDimensions.aspectFilled(imageBoundingSize), boundingSize: imageBoundingSize, intrinsicInsets: UIEdgeInsets()))
textRightInset += imageBoundingSize.width + 18.0
} else {
textRightInset = 27.0
}
var mediaUpdated = false
if let updatedMediaReference = updatedMediaReference, let previousMediaReference = previousMediaReference {
mediaUpdated = !updatedMediaReference.media.isEqual(to: previousMediaReference.media)
} else if (updatedMediaReference != nil) != (previousMediaReference != nil) {
mediaUpdated = true
}
var updateImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
var updatedFetchMediaSignal: Signal<FetchResourceSourceType, FetchResourceError>?
if mediaUpdated {
if let updatedMediaReference = updatedMediaReference, imageDimensions != nil {
if let imageReference = updatedMediaReference.concrete(TelegramMediaImage.self) {
if imageReference.media.representations.isEmpty {
updateImageSignal = chatSecretPhoto(account: context.account, userLocation: .peer(message.id.peerId), photoReference: imageReference, ignoreFullSize: true, synchronousLoad: true)
} else {
updateImageSignal = chatMessagePhotoThumbnail(account: context.account, userLocation: .peer(message.id.peerId), photoReference: imageReference, blurred: false)
}
} else if let fileReference = updatedMediaReference.concrete(TelegramMediaFile.self) {
if fileReference.media.isAnimatedSticker {
let dimensions = fileReference.media.dimensions ?? PixelDimensions(width: 512, height: 512)
updateImageSignal = chatMessageAnimatedSticker(postbox: context.account.postbox, userLocation: .peer(message.id.peerId), file: fileReference.media, small: false, size: dimensions.cgSize.aspectFitted(CGSize(width: 160.0, height: 160.0)))
updatedFetchMediaSignal = fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .peer(message.id.peerId), userContentType: MediaResourceUserContentType(file: fileReference.media), reference: fileReference.resourceReference(fileReference.media.resource))
} else if fileReference.media.isVideo || fileReference.media.isAnimated {
updateImageSignal = chatMessageVideoThumbnail(account: context.account, userLocation: .peer(message.id.peerId), fileReference: fileReference, blurred: false)
} else if let iconImageRepresentation = smallestImageRepresentation(fileReference.media.previewRepresentations) {
updateImageSignal = chatWebpageSnippetFile(account: context.account, userLocation: .peer(message.id.peerId), mediaReference: fileReference.abstract, representation: iconImageRepresentation)
}
}
} else {
updateImageSignal = .single({ _ in return nil })
}
}
let (adLayout, adApply) = makeAdLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: strings.Chat_BotAd_Title, font: Font.semibold(14.0), textColor: theme.chat.inputPanel.panelControlAccentColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: width, height: .greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: .zero))
let titleConstrainedSize = CGSize(width: width - contentLeftInset - contentRightInset - textRightInset - adLayout.size.width - 90.0, height: CGFloat.greatestFiniteMagnitude)
let textConstrainedSize = CGSize(width: width - contentLeftInset - contentRightInset - textRightInset, height: CGFloat.greatestFiniteMagnitude)
var titleText: String = ""
if let author = message.author {
titleText = EnginePeer(author).compactDisplayTitle
}
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: titleText, font: Font.semibold(14.0), textColor: theme.chat.inputPanel.primaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: titleConstrainedSize, alignment: .natural, cutout: nil, insets: .zero))
let (textString, _, isText) = descriptionStringForMessage(contentSettings: context.currentContentSettings.with { $0 }, message: EngineMessage(message), strings: strings, nameDisplayOrder: nameDisplayOrder, dateTimeFormat: dateTimeFormat, accountPeerId: accountPeerId)
let messageText: NSAttributedString
let textFont = Font.regular(14.0)
if isText {
var text = message.text
var messageEntities = message.textEntitiesAttribute?.entities ?? []
if let translateToLanguage = translateToLanguage, !text.isEmpty {
for attribute in message.attributes {
if let attribute = attribute as? TranslationMessageAttribute, !attribute.text.isEmpty, attribute.toLang == translateToLanguage {
text = attribute.text
messageEntities = attribute.entities
break
}
}
}
let entities = messageEntities.filter { entity in
switch entity.type {
case .CustomEmoji:
return true
default:
return false
}
}
let textColor = theme.chat.inputPanel.primaryTextColor
if entities.count > 0 {
messageText = stringWithAppliedEntities(trimToLineCount(text, lineCount: 1), entities: entities, baseColor: textColor, linkColor: textColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: textFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: message)
} else {
messageText = NSAttributedString(string: foldLineBreaks(text), font: textFont, textColor: textColor)
}
} else {
messageText = NSAttributedString(string: foldLineBreaks(textString.string), font: textFont, textColor: message.media.isEmpty || message.media.first is TelegramMediaWebpage ? theme.chat.inputPanel.primaryTextColor : theme.chat.inputPanel.secondaryTextColor)
}
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: messageText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: .zero))
var panelHeight: CGFloat = 0.0
if let _ = imageDimensions {
panelHeight = 9.0 + imageBoundingSize.height + 9.0
}
var textHeight: CGFloat
var titleOnSeparateLine = false
if textLayout.numberOfLines == 1 || contentLeftInset + adLayout.size.width + 2.0 + titleLayout.size.width > width - contentRightInset - textRightInset {
textHeight = adLayout.size.height + titleLayout.size.height + textLayout.size.height + 15.0
titleOnSeparateLine = true
} else {
textHeight = titleLayout.size.height + textLayout.size.height + 15.0
}
panelHeight = max(panelHeight, textHeight)
Queue.mainQueue().async {
let _ = adApply()
let _ = titleApply()
var textArguments: TextNodeWithEntities.Arguments?
if let cache = self.animationCache, let renderer = self.animationRenderer {
textArguments = TextNodeWithEntities.Arguments(
context: self.context,
cache: cache,
renderer: renderer,
placeholderColor: theme.list.mediaPlaceholderColor,
attemptSynchronous: false
)
}
let _ = textApply(textArguments)
self.previousMediaReference = updatedMediaReference
let textContainerFrame = CGRect(origin: CGPoint(x: contentLeftInset, y: 0.0), size: CGSize(width: width, height: panelHeight))
animationTransition.updateFrameAdditive(node: self.contentTextContainer, frame: textContainerFrame)
let removeTextSize = self.removeTextNode.updateLayout(CGSize(width: width, height: .greatestFiniteMagnitude))
if titleOnSeparateLine {
self.adNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 9.0), size: adLayout.size)
self.titleNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 26.0), size: titleLayout.size)
self.textNode.textNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 43.0), size: textLayout.size)
self.removeTextNode.frame = CGRect(origin: CGPoint(x: contentLeftInset + adLayout.size.width + 8.0, y: 11.0 - UIScreenPixel), size: removeTextSize)
self.removeBackgroundNode.frame = self.removeTextNode.frame.insetBy(dx: -5.0, dy: -1.0)
self.removeButtonNode.frame = self.removeBackgroundNode.frame
} else {
self.adNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 9.0), size: adLayout.size)
self.titleNode.frame = CGRect(origin: CGPoint(x: adLayout.size.width + 2.0, y: 9.0), size: titleLayout.size)
self.textNode.textNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 26.0), size: textLayout.size)
self.removeTextNode.frame = CGRect(origin: CGPoint(x: contentLeftInset + adLayout.size.width + 2.0 + titleLayout.size.width + 8.0, y: 11.0 - UIScreenPixel), size: removeTextSize)
self.removeBackgroundNode.frame = self.removeTextNode.frame.insetBy(dx: -5.0, dy: -1.0)
self.removeButtonNode.frame = self.removeBackgroundNode.frame
}
self.textNode.visibilityRect = CGRect.infinite
self.imageNodeContainer.frame = CGRect(origin: CGPoint(x: width - contentRightInset - imageBoundingSize.width, y: 9.0), size: imageBoundingSize)
self.imageNode.frame = CGRect(origin: CGPoint(), size: imageBoundingSize)
if let applyImage = applyImage {
applyImage()
animationTransition.updateSublayerTransformScale(node: self.imageNodeContainer, scale: 1.0)
animationTransition.updateAlpha(node: self.imageNodeContainer, alpha: 1.0, beginWithCurrentState: true)
} else {
animationTransition.updateSublayerTransformScale(node: self.imageNodeContainer, scale: 0.1)
animationTransition.updateAlpha(node: self.imageNodeContainer, alpha: 0.0, beginWithCurrentState: true)
}
if let updateImageSignal = updateImageSignal {
self.imageNode.setSignal(updateImageSignal)
}
if let updatedFetchMediaSignal = updatedFetchMediaSignal {
self.fetchDisposable.set(updatedFetchMediaSignal.startStrict())
}
}
return panelHeight
}
@objc func tapped() {
guard let message = self.message else {
return
}
self.controllerInteraction?.activateAdAction(message.id, nil, false, false)
}
@objc func removePressed() {
guard let message = self.message else {
return
}
self.controllerInteraction?.adContextAction(message, self.contextContainer, nil)
}
}
@@ -0,0 +1,302 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import AppBundle
import AvatarNode
import CheckNode
import Markdown
private let textFont = Font.regular(13.0)
private let boldTextFont = Font.semibold(13.0)
private func formattedText(_ text: String, color: UIColor, textAlignment: NSTextAlignment = .natural) -> NSAttributedString {
return parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: color), bold: MarkdownAttributeSet(font: boldTextFont, textColor: color), link: MarkdownAttributeSet(font: textFont, textColor: color), linkAttribute: { _ in return nil}), textAlignment: textAlignment)
}
private final class ChatAgeRestrictionAlertContentNode: AlertContentNode {
private let strings: PresentationStrings
private let title: String
private let text: String
private let titleNode: ImmediateTextNode
private let textNode: ASTextNode
private let alwaysCheckNode: InteractiveCheckNode
private let alwaysLabelNode: ASTextNode
private let actionNodesSeparator: ASDisplayNode
private let actionNodes: [TextAlertContentActionNode]
private let actionVerticalSeparators: [ASDisplayNode]
private var validLayout: CGSize?
override var dismissOnOutsideTap: Bool {
return self.isUserInteractionEnabled
}
var alwaysShow: Bool = false {
didSet {
self.alwaysCheckNode.setSelected(self.alwaysShow, animated: true)
}
}
init(context: AccountContext, theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, title: String, text: String, actions: [TextAlertAction]) {
self.strings = strings
self.title = title
self.text = text
self.titleNode = ImmediateTextNode()
self.titleNode.displaysAsynchronously = false
self.titleNode.maximumNumberOfLines = 1
self.titleNode.textAlignment = .center
self.textNode = ASTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.maximumNumberOfLines = 0
self.textNode.lineSpacing = 0.1
self.alwaysCheckNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: theme.accentColor, strokeColor: theme.contrastColor, borderColor: theme.controlBorderColor, overlayBorder: false, hasInset: false, hasShadow: false))
self.alwaysLabelNode = ASTextNode()
self.alwaysLabelNode.maximumNumberOfLines = 2
self.alwaysLabelNode.isUserInteractionEnabled = true
self.actionNodesSeparator = ASDisplayNode()
self.actionNodesSeparator.isLayerBacked = true
self.actionNodes = actions.map { action -> TextAlertContentActionNode in
return TextAlertContentActionNode(theme: theme, action: action)
}
var actionVerticalSeparators: [ASDisplayNode] = []
if actions.count > 1 {
for _ in 0 ..< actions.count - 1 {
let separatorNode = ASDisplayNode()
separatorNode.isLayerBacked = true
actionVerticalSeparators.append(separatorNode)
}
}
self.actionVerticalSeparators = actionVerticalSeparators
super.init()
self.addSubnode(self.titleNode)
self.addSubnode(self.textNode)
self.addSubnode(self.alwaysCheckNode)
self.addSubnode(self.alwaysLabelNode)
self.addSubnode(self.actionNodesSeparator)
for actionNode in self.actionNodes {
self.addSubnode(actionNode)
}
for separatorNode in self.actionVerticalSeparators {
self.addSubnode(separatorNode)
}
self.alwaysCheckNode.valueChanged = { [weak self] value in
if let strongSelf = self {
strongSelf.alwaysShow = !strongSelf.alwaysShow
}
}
self.updateTheme(theme)
}
override func didLoad() {
super.didLoad()
self.alwaysLabelNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.alwaysTap(_:))))
}
@objc private func alwaysTap(_ gestureRecognizer: UITapGestureRecognizer) {
if self.alwaysCheckNode.isUserInteractionEnabled {
self.alwaysShow = !self.alwaysShow
}
}
override func updateTheme(_ theme: AlertControllerTheme) {
self.titleNode.attributedText = NSAttributedString(string: self.title, font: Font.semibold(17.0), textColor: theme.primaryColor, paragraphAlignment: .center)
self.textNode.attributedText = NSAttributedString(string: self.text, font: Font.regular(13.0), textColor: theme.primaryColor, paragraphAlignment: .center)
self.alwaysLabelNode.attributedText = formattedText(self.strings.SensitiveContent_ShowAlways, color: theme.primaryColor)
self.actionNodesSeparator.backgroundColor = theme.separatorColor
for actionNode in self.actionNodes {
actionNode.updateTheme(theme)
}
for separatorNode in self.actionVerticalSeparators {
separatorNode.backgroundColor = theme.separatorColor
}
if let size = self.validLayout {
_ = self.updateLayout(size: size, transition: .immediate)
}
}
override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
var size = size
size.width = min(size.width, 270.0)
self.validLayout = size
var origin: CGPoint = CGPoint(x: 0.0, y: 17.0)
let titleSize = self.titleNode.updateLayout(CGSize(width: size.width - 32.0, height: size.height))
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: origin.y), size: titleSize))
origin.y += titleSize.height + 6.0
var entriesHeight: CGFloat = 0.0
let textSize = self.textNode.measure(CGSize(width: size.width - 32.0, height: size.height))
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: origin.y), size: textSize))
origin.y += textSize.height
if self.alwaysLabelNode.supernode != nil {
origin.y += 21.0
entriesHeight += 21.0
let checkSize = CGSize(width: 22.0, height: 22.0)
let condensedSize = CGSize(width: size.width - 76.0, height: size.height)
let alwaysSize = self.alwaysLabelNode.measure(condensedSize)
let totalWidth = checkSize.width + alwaysSize.width + 12.0
let originX = floorToScreenPixels((size.width - totalWidth) / 2.0)
transition.updateFrame(node: self.alwaysLabelNode, frame: CGRect(origin: CGPoint(x: originX + checkSize.width + 12.0, y: origin.y), size: alwaysSize))
transition.updateFrame(node: self.alwaysCheckNode, frame: CGRect(origin: CGPoint(x: originX, y: origin.y - 2.0), size: checkSize))
origin.y += alwaysSize.height
entriesHeight += alwaysSize.height
}
let actionButtonHeight: CGFloat = 44.0
var minActionsWidth: CGFloat = 0.0
let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count))
let actionTitleInsets: CGFloat = 8.0
var effectiveActionLayout = TextAlertContentActionLayout.vertical
for actionNode in self.actionNodes {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight))
if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 {
effectiveActionLayout = .vertical
}
switch effectiveActionLayout {
case .horizontal:
minActionsWidth += actionTitleSize.width + actionTitleInsets
case .vertical:
minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets)
}
}
let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 18.0, right: 18.0)
let contentWidth = max(size.width, minActionsWidth)
var actionsHeight: CGFloat = 0.0
switch effectiveActionLayout {
case .horizontal:
actionsHeight = actionButtonHeight
case .vertical:
actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count)
}
let resultSize = CGSize(width: contentWidth, height: titleSize.height + textSize.height + entriesHeight + actionsHeight + 8.0 + insets.top + insets.bottom)
transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
var actionOffset: CGFloat = 0.0
let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count))
var separatorIndex = -1
var nodeIndex = 0
for actionNode in self.actionNodes {
if separatorIndex >= 0 {
let separatorNode = self.actionVerticalSeparators[separatorIndex]
switch effectiveActionLayout {
case .horizontal:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel)))
case .vertical:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
}
}
separatorIndex += 1
let currentActionWidth: CGFloat
switch effectiveActionLayout {
case .horizontal:
if nodeIndex == self.actionNodes.count - 1 {
currentActionWidth = resultSize.width - actionOffset
} else {
currentActionWidth = actionWidth
}
case .vertical:
currentActionWidth = resultSize.width
}
let actionNodeFrame: CGRect
switch effectiveActionLayout {
case .horizontal:
actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += currentActionWidth
case .vertical:
actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += actionButtonHeight
}
transition.updateFrame(node: actionNode, frame: actionNodeFrame)
nodeIndex += 1
}
return resultSize
}
}
public func chatAgeRestrictionAlertController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?, parentController: ViewController, completion: @escaping (Bool) -> Void) -> AlertController {
let theme = defaultDarkColorPresentationTheme
let presentationData: PresentationData
if let updatedPresentationData {
presentationData = updatedPresentationData.initial
} else {
presentationData = context.sharedContext.currentPresentationData.with { $0 }
}
let strings = presentationData.strings
var dismissImpl: ((Bool) -> Void)?
var getContentNodeImpl: (() -> ChatAgeRestrictionAlertContentNode?)?
let actions: [TextAlertAction] = [TextAlertAction(type: .defaultAction, title: strings.SensitiveContent_ViewAnyway, action: {
if let alwaysShow = getContentNodeImpl?()?.alwaysShow {
completion(alwaysShow)
} else {
completion(false)
}
dismissImpl?(true)
}), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {
dismissImpl?(true)
})]
let title = strings.SensitiveContent_Title
let text = strings.SensitiveContent_Text
let contentNode = ChatAgeRestrictionAlertContentNode(context: context, theme: AlertControllerTheme(presentationData: presentationData), ptheme: theme, strings: strings, title: title, text: text, actions: actions)
getContentNodeImpl = { [weak contentNode] in
return contentNode
}
let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode)
dismissImpl = { [weak controller] animated in
if animated {
controller?.dismissAnimated()
} else {
controller?.dismiss()
}
}
return controller
}
@@ -0,0 +1,239 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramPresentationData
import ChatPresentationInterfaceState
import ComponentFlow
import AvatarNode
import MultilineTextComponent
import PlainButtonComponent
import ComponentDisplayAdapters
import AccountContext
import TelegramCore
import SwiftSignalKit
import UndoUI
import ShareController
private final class ChatBusinessLinkTitlePanelComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let insets: UIEdgeInsets
let copyAction: () -> Void
let shareAction: () -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
insets: UIEdgeInsets,
copyAction: @escaping () -> Void,
shareAction: @escaping () -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.insets = insets
self.copyAction = copyAction
self.shareAction = shareAction
}
static func ==(lhs: ChatBusinessLinkTitlePanelComponent, rhs: ChatBusinessLinkTitlePanelComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings != rhs.strings {
return false
}
if lhs.insets != rhs.insets {
return false
}
return true
}
final class View: UIView {
private let copyButton = ComponentView<Empty>()
private let shareButton = ComponentView<Empty>()
private var component: ChatBusinessLinkTitlePanelComponent?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: ChatBusinessLinkTitlePanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
let size = CGSize(width: availableSize.width, height: 40.0)
let copyButtonSize = self.copyButton.update(
transition: transition,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.strings.GroupInfo_InviteLink_CopyLink, font: Font.regular(17.0), textColor: component.theme.rootController.navigationBar.accentTextColor))
)),
effectAlignment: .center,
minSize: CGSize(width: floor(availableSize.width * 0.5), height: size.height),
contentInsets: UIEdgeInsets(),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.copyAction()
},
animateAlpha: true,
animateScale: false,
animateContents: false
)),
environment: {},
containerSize: availableSize
)
if let copyButtonView = self.copyButton.view {
if copyButtonView.superview == nil {
self.addSubview(copyButtonView)
}
transition.setFrame(view: copyButtonView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: copyButtonSize))
}
let shareButtonSize = self.shareButton.update(
transition: transition,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.strings.GroupInfo_InviteLink_ShareLink, font: Font.regular(17.0), textColor: component.theme.rootController.navigationBar.accentTextColor))
)),
effectAlignment: .center,
minSize: CGSize(width: floor(availableSize.width * 0.5), height: size.height),
contentInsets: UIEdgeInsets(),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.shareAction()
},
animateAlpha: true,
animateScale: false,
animateContents: false
)),
environment: {},
containerSize: availableSize
)
if let shareButtonView = self.shareButton.view {
if shareButtonView.superview == nil {
self.addSubview(shareButtonView)
}
transition.setFrame(view: shareButtonView, frame: CGRect(origin: CGPoint(x: floor(availableSize.width * 0.5), y: 0.0), size: shareButtonSize))
}
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
final class ChatBusinessLinkTitlePanelNode: ChatTitleAccessoryPanelNode {
private let context: AccountContext
private let separatorNode: ASDisplayNode
private let content = ComponentView<Empty>()
private var theme: PresentationTheme?
private var link: TelegramBusinessChatLinks.Link?
init(context: AccountContext) {
self.context = context
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
super.init()
self.addSubnode(self.separatorNode)
}
private func copyAction() {
guard let link = self.link, let interfaceInteraction = self.interfaceInteraction else {
return
}
UIPasteboard.general.string = link.url
let presentationData = self.context.sharedContext.currentPresentationData.with({ $0 })
let controller = UndoOverlayController(presentationData: presentationData, content: .copy(text: presentationData.strings.GroupInfo_InviteLink_CopyAlert_Success), elevatedLayout: false, position: .top, animateInAsReplacement: false, action: { _ in
return false
})
interfaceInteraction.presentControllerInCurrent(controller, nil)
}
private func shareAction() {
guard let link = self.link, let interfaceInteraction = self.interfaceInteraction else {
return
}
interfaceInteraction.presentController(ShareController(context: self.context, subject: .url(link.url), showInChat: nil, externalShare: false, immediateExternalShare: false), nil)
}
override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> LayoutResult {
switch interfaceState.subject {
case let .customChatContents(customChatContents):
switch customChatContents.kind {
case .quickReplyMessageInput:
break
case let .businessLinkSetup(link):
self.link = link
case .hashTagSearch:
break
}
default:
break
}
if interfaceState.theme !== self.theme {
self.theme = interfaceState.theme
self.separatorNode.backgroundColor = interfaceState.theme.rootController.navigationBar.separatorColor
}
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)))
let contentSize = self.content.update(
transition: ComponentTransition(transition),
component: AnyComponent(ChatBusinessLinkTitlePanelComponent(
context: self.context,
theme: interfaceState.theme,
strings: interfaceState.strings,
insets: UIEdgeInsets(top: 0.0, left: leftInset, bottom: 0.0, right: rightInset),
copyAction: { [weak self] in
self?.copyAction()
},
shareAction: { [weak self] in
self?.shareAction()
}
)),
environment: {},
containerSize: CGSize(width: width, height: 1000.0)
)
if let contentView = self.content.view {
if contentView.superview == nil {
self.view.addSubview(contentView)
}
transition.updateFrame(view: contentView, frame: CGRect(origin: CGPoint(), size: contentSize))
}
return LayoutResult(backgroundHeight: contentSize.height, insetHeight: contentSize.height, hitTestSlop: 0.0)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,752 @@
import Foundation
import TelegramPresentationData
import AccountContext
import Postbox
import TelegramCore
import SwiftSignalKit
import Display
import TelegramPresentationData
import PresentationDataUtils
import UndoUI
import AdminUserActionsSheet
import ContextUI
import TelegramStringFormatting
import StorageUsageScreen
import SettingsUI
import DeleteChatPeerActionSheetItem
import OverlayStatusController
fileprivate struct InitialBannedRights {
var value: TelegramChatBannedRights?
}
extension ChatControllerImpl {
fileprivate func applyAdminUserActionsResult(messageIds: Set<MessageId>, result: AdminUserActionsSheet.ChatResult, initialUserBannedRights: [EnginePeer.Id: InitialBannedRights]) {
guard let messagesPeerId = self.chatLocation.peerId else {
return
}
guard let banLocationPeerId = self.presentationInterfaceState.renderedPeer?.chatOrMonoforumMainPeer?.id else {
return
}
var title: String? = messageIds.count == 1 ? self.presentationData.strings.Chat_AdminAction_ToastMessagesDeletedTitleSingle : self.presentationData.strings.Chat_AdminAction_ToastMessagesDeletedTitleMultiple
if !result.deleteAllFromPeers.isEmpty {
title = self.presentationData.strings.Chat_AdminAction_ToastMessagesDeletedTitleMultiple
}
var text: String = ""
var undoRights: [EnginePeer.Id: InitialBannedRights] = [:]
if !result.reportSpamPeers.isEmpty {
if !text.isEmpty {
text.append("\n")
}
text.append(self.presentationData.strings.Chat_AdminAction_ToastReportedSpamText(Int32(result.reportSpamPeers.count)))
}
if !result.banPeers.isEmpty {
if !text.isEmpty {
text.append("\n")
}
text.append(self.presentationData.strings.Chat_AdminAction_ToastBannedText(Int32(result.banPeers.count)))
for id in result.banPeers {
if let value = initialUserBannedRights[id] {
undoRights[id] = value
}
}
}
if !result.updateBannedRights.isEmpty {
if !text.isEmpty {
text.append("\n")
}
text.append(self.presentationData.strings.Chat_AdminAction_ToastRestrictedText(Int32(result.updateBannedRights.count)))
for (id, _) in result.updateBannedRights {
if let value = initialUserBannedRights[id] {
undoRights[id] = value
}
}
}
do {
let _ = self.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone()
for authorId in result.deleteAllFromPeers {
let _ = self.context.engine.messages.deleteAllMessagesWithAuthor(peerId: messagesPeerId, authorId: authorId, namespace: Namespaces.Message.Cloud).startStandalone()
let _ = self.context.engine.messages.clearAuthorHistory(peerId: messagesPeerId, memberId: authorId).startStandalone()
}
for authorId in result.reportSpamPeers {
let _ = self.context.engine.peers.reportPeer(peerId: authorId, reason: .spam, message: "").startStandalone()
}
for authorId in result.banPeers {
let _ = self.context.engine.peers.removePeerMember(peerId: banLocationPeerId, memberId: authorId).startStandalone()
}
for (authorId, rights) in result.updateBannedRights {
let _ = self.context.engine.peers.updateChannelMemberBannedRights(peerId: banLocationPeerId, memberId: authorId, rights: rights).startStandalone()
}
}
if text.isEmpty {
text = messageIds.count == 1 ? self.presentationData.strings.Chat_AdminAction_ToastMessagesDeletedTextSingle : self.presentationData.strings.Chat_AdminAction_ToastMessagesDeletedTextMultiple
if !result.deleteAllFromPeers.isEmpty {
text = self.presentationData.strings.Chat_AdminAction_ToastMessagesDeletedTextMultiple
}
title = nil
}
self.present(
UndoOverlayController(
presentationData: self.presentationData,
content: undoRights.isEmpty ? .actionSucceeded(title: title, text: text, cancel: nil, destructive: false) : .removedChat(context: self.context, title: NSAttributedString(string: title ?? text), text: title == nil ? nil : text),
elevatedLayout: false,
action: { [weak self] action in
guard let self else {
return true
}
switch action {
case .commit:
break
case .undo:
for (authorId, rights) in initialUserBannedRights {
let _ = self.context.engine.peers.updateChannelMemberBannedRights(peerId: banLocationPeerId, memberId: authorId, rights: rights.value).startStandalone()
}
default:
break
}
return true
}
),
in: .current
)
self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } })
}
func presentMultiBanMessageOptions(accountPeerId: PeerId, authors: [Peer], messageIds: Set<MessageId>, options: ChatAvailableMessageActionOptions) {
guard let peerId = self.chatLocation.peerId else {
return
}
var deleteAllMessageCount: Signal<Int?, NoError> = .single(nil)
if authors.count == 1 {
deleteAllMessageCount = self.context.engine.messages.searchMessages(location: .peer(peerId: peerId, fromId: authors[0].id, tags: nil, reactions: nil, threadId: self.chatLocation.threadId, minDate: nil, maxDate: nil), query: "", state: nil)
|> map { result, _ -> Int? in
return Int(result.totalCount)
}
}
var signal = combineLatest(authors.map { author in
self.context.engine.peers.fetchChannelParticipant(peerId: peerId, participantId: author.id)
|> map { result -> (Peer, ChannelParticipant?) in
return (author, result)
}
})
let disposables = MetaDisposable()
self.navigationActionDisposable.set(disposables)
var cancelImpl: (() -> Void)?
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let progressSignal = Signal<Never, NoError> { [weak self] subscriber in
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
cancelImpl?()
}))
self?.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
}
|> runOn(Queue.mainQueue())
|> delay(0.3, queue: Queue.mainQueue())
let progressDisposable = progressSignal.startStrict()
signal = signal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
cancelImpl = {
disposables.set(nil)
}
disposables.set((combineLatest(signal, deleteAllMessageCount)
|> deliverOnMainQueue).startStrict(next: { [weak self] authorsAndParticipants, deleteAllMessageCount in
guard let self else {
return
}
let _ = (self.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] chatPeer in
guard let self, let chatPeer else {
return
}
var renderedParticipants: [RenderedChannelParticipant] = []
var initialUserBannedRights: [EnginePeer.Id: InitialBannedRights] = [:]
for (author, maybeParticipant) in authorsAndParticipants {
let participant: ChannelParticipant
if let maybeParticipant {
participant = maybeParticipant
} else {
participant = .member(id: author.id, invitedAt: 0, adminInfo: nil, banInfo: ChannelParticipantBannedInfo(
rights: TelegramChatBannedRights(
flags: [.banReadMessages],
untilDate: Int32.max
),
restrictedBy: self.context.account.peerId,
timestamp: 0,
isMember: false
), rank: nil, subscriptionUntilDate: nil)
}
let peer = author
renderedParticipants.append(RenderedChannelParticipant(
participant: participant,
peer: peer
))
switch participant {
case .creator:
break
case let .member(_, _, _, banInfo, _, _):
if let banInfo {
initialUserBannedRights[participant.peerId] = InitialBannedRights(value: banInfo.rights)
} else {
initialUserBannedRights[participant.peerId] = InitialBannedRights(value: nil)
}
}
}
self.push(AdminUserActionsSheet(
context: self.context,
chatPeer: chatPeer,
peers: renderedParticipants,
mode: .chat(
messageCount: messageIds.count,
deleteAllMessageCount: deleteAllMessageCount,
completion: { [weak self] result in
guard let self else {
return
}
self.applyAdminUserActionsResult(messageIds: messageIds, result: result, initialUserBannedRights: initialUserBannedRights)
}
)
))
})
}))
}
func presentBanMessageOptions(accountPeerId: PeerId, author: Peer, messageIds: Set<MessageId>, options: ChatAvailableMessageActionOptions) {
guard let peerId = self.chatLocation.peerId else {
return
}
var signal = self.context.engine.peers.fetchChannelParticipant(peerId: peerId, participantId: author.id)
let disposables = MetaDisposable()
self.navigationActionDisposable.set(disposables)
var cancelImpl: (() -> Void)?
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let progressSignal = Signal<Never, NoError> { [weak self] subscriber in
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
cancelImpl?()
}))
self?.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
}
|> runOn(Queue.mainQueue())
|> delay(0.3, queue: Queue.mainQueue())
let progressDisposable = progressSignal.startStrict()
signal = signal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
cancelImpl = {
disposables.set(nil)
}
var deleteAllMessageCount: Signal<Int?, NoError> = .single(nil)
do {
deleteAllMessageCount = self.context.engine.messages.getSearchMessageCount(location: .peer(peerId: peerId, fromId: author.id, tags: nil, reactions: nil, threadId: self.chatLocation.threadId, minDate: nil, maxDate: nil), query: "")
|> map { result -> Int? in
return result
}
}
disposables.set((combineLatest(signal, deleteAllMessageCount)
|> deliverOnMainQueue).startStrict(next: { [weak self] maybeParticipant, deleteAllMessageCount in
guard let self else {
return
}
let participant: ChannelParticipant
if let maybeParticipant {
participant = maybeParticipant
} else {
participant = .member(id: author.id, invitedAt: 0, adminInfo: nil, banInfo: ChannelParticipantBannedInfo(
rights: TelegramChatBannedRights(
flags: [.banReadMessages],
untilDate: Int32.max
),
restrictedBy: self.context.account.peerId,
timestamp: 0,
isMember: false
), rank: nil, subscriptionUntilDate: nil)
}
let _ = (self.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId),
TelegramEngine.EngineData.Item.Peer.Peer(id: author.id)
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] chatPeer, authorPeer in
guard let self, let chatPeer else {
return
}
guard let authorPeer else {
return
}
var initialUserBannedRights: [EnginePeer.Id: InitialBannedRights] = [:]
switch participant {
case .creator:
break
case let .member(_, _, _, banInfo, _, _):
if let banInfo {
initialUserBannedRights[participant.peerId] = InitialBannedRights(value: banInfo.rights)
} else {
initialUserBannedRights[participant.peerId] = InitialBannedRights(value: nil)
}
}
self.push(AdminUserActionsSheet(
context: self.context,
chatPeer: chatPeer,
peers: [RenderedChannelParticipant(
participant: participant,
peer: authorPeer._asPeer()
)],
mode: .chat(
messageCount: messageIds.count,
deleteAllMessageCount: deleteAllMessageCount,
completion: { [weak self] result in
guard let self else {
return
}
self.applyAdminUserActionsResult(messageIds: messageIds, result: result, initialUserBannedRights: initialUserBannedRights)
}
)
))
})
}))
}
func beginDeleteMessagesWithUndo(messageIds: Set<MessageId>, type: InteractiveMessagesDeletionType) {
var deleteImmediately = false
if case .forEveryone = type {
deleteImmediately = true
} else if case .scheduledMessages = self.presentationInterfaceState.subject {
deleteImmediately = true
} else if case .peer(self.context.account.peerId) = self.chatLocation {
deleteImmediately = true
}
if deleteImmediately {
let _ = self.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: type).startStandalone()
return
}
self.chatDisplayNode.historyNode.ignoreMessageIds = Set(messageIds)
let undoTitle = self.presentationData.strings.Chat_MessagesDeletedToast_Text(Int32(messageIds.count))
self.present(UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: self.context, title: NSAttributedString(string: undoTitle), text: nil), elevatedLayout: false, position: .top, action: { [weak self] value in
guard let self else {
return false
}
if value == .commit {
let _ = self.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: type).startStandalone()
return true
} else if value == .undo {
self.chatDisplayNode.historyNode.ignoreMessageIds = Set()
return true
}
return false
}), in: .current)
}
func presentDeleteMessageOptions(messageIds: Set<MessageId>, options: ChatAvailableMessageActionOptions, contextController: ContextControllerProtocol?, completion: @escaping (ContextMenuActionResult) -> Void) {
let _ = (self.context.engine.data.get(
EngineDataMap(messageIds.map(TelegramEngine.EngineData.Item.Messages.Message.init(id:)))
)
|> deliverOnMainQueue).start(next: { [weak self] messages in
guard let self else {
return
}
if let message = messages.values.compactMap({ $0 }).first(where: { message in message.attributes.contains(where: { $0 is PublishedSuggestedPostMessageAttribute }) }), let attribute = message.attributes.first(where: { $0 is PublishedSuggestedPostMessageAttribute }) as? PublishedSuggestedPostMessageAttribute, message.timestamp > Int32(Date().timeIntervalSince1970) - 60 * 60 * 24 {
let commit = { [weak self] in
guard let self else {
return
}
let titleString: String
let textString: String
switch attribute.currency {
case .stars:
titleString = self.presentationData.strings.Chat_DeletePaidMessageStars_Title
textString = self.presentationData.strings.Chat_DeletePaidMessageStars_Text
case .ton:
titleString = self.presentationData.strings.Chat_DeletePaidMessageTon_Title
textString = self.presentationData.strings.Chat_DeletePaidMessageTon_Text
}
self.present(standardTextAlertController(
theme: AlertControllerTheme(presentationData: self.presentationData),
title: titleString,
text: textString,
actions: [
TextAlertAction(type: .destructiveAction, title: self.presentationData.strings.Chat_DeletePaidMessage_Action, action: { [weak self] in
guard let self else {
return
}
self.beginDeleteMessagesWithUndo(messageIds: messageIds, type: .forEveryone)
}),
TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_Cancel, action: {})
],
actionLayout: .vertical,
parseMarkdown: true
), in: .window(.root))
}
if let contextController {
contextController.dismiss(completion: commit)
} else {
commit()
}
return
}
if messageIds.count == 1, let message = messages.values.compactMap({ $0 }).first, let repeatAttribute = message.attributes.first(where: { $0 is ScheduledRepeatAttribute }) as? ScheduledRepeatAttribute {
let commit = { [weak self] in
guard let self else {
return
}
let title: String
let text: String
let deleteOneAction: String
let deleteAllAction: String
if message.id.peerId == self.context.account.peerId {
title = self.presentationData.strings.Reminders_DeleteRepeatingTitle
text = self.presentationData.strings.Reminders_DeleteRepeatingText
deleteOneAction = self.presentationData.strings.Reminders_DeleteRepeatingActionSingle
deleteAllAction = self.presentationData.strings.Reminders_DeleteRepeatingActionMultiple
} else {
title = self.presentationData.strings.ScheduledMessages_DeleteRepeatingTitle
text = self.presentationData.strings.ScheduledMessages_DeleteRepeatingText
deleteOneAction = self.presentationData.strings.ScheduledMessages_DeleteRepeatingActionSingle
deleteAllAction = self.presentationData.strings.ScheduledMessages_DeleteRepeatingActionMultiple
}
self.present(standardTextAlertController(
theme: AlertControllerTheme(presentationData: self.presentationData),
title: title,
text: text,
actions: [
TextAlertAction(type: .destructiveAction, title: deleteOneAction, action: { [weak self] in
guard let self else {
return
}
var entities: TextEntitiesMessageAttribute?
for attribute in message.attributes {
if let attribute = attribute as? TextEntitiesMessageAttribute {
entities = attribute
break
}
}
let scheduleTime = message.timestamp + repeatAttribute.repeatPeriod
self.editMessageDisposable.set((self.context.engine.messages.requestEditMessage(messageId: message.id, text: message.text, media: .keep, entities: entities, inlineStickers: [:], webpagePreviewAttribute: nil, disableUrlPreview: false, scheduleInfoAttribute: OutgoingScheduleInfoMessageAttribute(scheduleTime: scheduleTime, repeatPeriod: repeatAttribute.repeatPeriod)) |> deliverOnMainQueue).startStrict(next: { result in
}, error: { error in
}))
}),
TextAlertAction(type: .destructiveAction, title: deleteAllAction, action: { [weak self] in
guard let self else {
return
}
self.beginDeleteMessagesWithUndo(messageIds: messageIds, type: .forEveryone)
}),
TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_Cancel, action: {})
],
actionLayout: .vertical,
parseMarkdown: true
), in: .window(.root))
}
if let contextController {
contextController.dismiss(completion: commit)
} else {
commit()
}
return
}
let actionSheet = ActionSheetController(presentationData: self.presentationData)
var items: [ActionSheetItem] = []
var personalPeerName: String?
var isChannel = false
if let user = self.presentationInterfaceState.renderedPeer?.peer as? TelegramUser {
personalPeerName = EnginePeer(user).compactDisplayTitle
} else if let peer = self.presentationInterfaceState.renderedPeer?.peer as? TelegramSecretChat, let associatedPeerId = peer.associatedPeerId, let user = self.presentationInterfaceState.renderedPeer?.peers[associatedPeerId] as? TelegramUser {
personalPeerName = EnginePeer(user).compactDisplayTitle
} else if let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, case .broadcast = channel.info {
isChannel = true
}
if options.contains(.cancelSending) {
items.append(ActionSheetButtonItem(title: self.presentationData.strings.Conversation_ContextMenuCancelSending, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
if let strongSelf = self {
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } })
strongSelf.beginDeleteMessagesWithUndo(messageIds: messageIds, type: .forEveryone)
}
}))
}
var contextItems: [ContextMenuItem] = []
var canDisplayContextMenu = true
var unsendPersonalMessages = false
if options.contains(.unsendPersonal) {
canDisplayContextMenu = false
items.append(ActionSheetTextItem(title: self.presentationData.strings.Chat_UnsendMyMessagesAlertTitle(personalPeerName ?? "").string))
items.append(ActionSheetSwitchItem(title: self.presentationData.strings.Chat_UnsendMyMessages, isOn: false, action: { value in
unsendPersonalMessages = value
}))
} else if options.contains(.deleteGlobally) {
let globalTitle: String
if isChannel {
globalTitle = self.presentationData.strings.Conversation_DeleteMessagesForEveryone
} else if let personalPeerName = personalPeerName {
globalTitle = self.presentationData.strings.Conversation_DeleteMessagesFor(personalPeerName).string
} else {
globalTitle = self.presentationData.strings.Conversation_DeleteMessagesForEveryone
}
contextItems.append(.action(ContextMenuActionItem(text: globalTitle, textColor: .destructive, icon: { _ in nil }, action: { [weak self] c, f in
if let strongSelf = self {
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() } })
strongSelf.beginDeleteMessagesWithUndo(messageIds: messageIds, type: .forEveryone)
}
if let giveaway {
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
if currentTime < giveaway.untilDate {
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 {
f(.dismissWithoutContent)
commit()
}
} else {
if "".isEmpty {
f(.dismissWithoutContent)
commit()
} else {
c?.dismiss(completion: {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1, execute: {
commit()
})
})
}
}
}
})))
items.append(ActionSheetButtonItem(title: globalTitle, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
if let strongSelf = self {
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } })
strongSelf.beginDeleteMessagesWithUndo(messageIds: messageIds, type: .forEveryone)
}
}))
}
if options.contains(.deleteLocally) {
var localOptionText = self.presentationData.strings.Conversation_DeleteMessagesForMe
if self.chatLocation.peerId == self.context.account.peerId {
if case .scheduledMessages = self.presentationInterfaceState.subject {
localOptionText = messageIds.count > 1 ? self.presentationData.strings.ScheduledMessages_Reminder_DeleteMany : self.presentationData.strings.ScheduledMessages_Reminder_Delete
} else if case .peer(self.context.account.peerId) = self.chatLocation, messages.values.allSatisfy({ message in message?._asMessage().effectivelyIncoming(self.context.account.peerId) ?? false }) {
localOptionText = self.presentationData.strings.Chat_ConfirmationRemoveFromSavedMessages
} else {
localOptionText = self.presentationData.strings.Chat_ConfirmationDeleteFromSavedMessages
}
} else if case .scheduledMessages = self.presentationInterfaceState.subject {
localOptionText = messageIds.count > 1 ? self.presentationData.strings.ScheduledMessages_DeleteMany : self.presentationData.strings.ScheduledMessages_Delete
} else {
if options.contains(.unsendPersonal) {
localOptionText = self.presentationData.strings.Chat_DeleteMessagesConfirmation(Int32(messageIds.count))
} else if case .peer(self.context.account.peerId) = self.chatLocation {
if messageIds.count == 1 {
localOptionText = self.presentationData.strings.Conversation_Moderate_Delete
} else {
localOptionText = self.presentationData.strings.Conversation_DeleteManyMessages
}
}
}
contextItems.append(.action(ContextMenuActionItem(text: localOptionText, textColor: .destructive, icon: { _ in nil }, action: { [weak self] c, f in
if let strongSelf = self {
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } })
let commit: () -> Void = {
guard let strongSelf = self else {
return
}
strongSelf.beginDeleteMessagesWithUndo(messageIds: messageIds, type: unsendPersonalMessages ? .forEveryone : .forLocalPeer)
}
if "".isEmpty {
f(.dismissWithoutContent)
commit()
} else {
c?.dismiss(completion: {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1, execute: {
commit()
})
})
}
}
})))
items.append(ActionSheetButtonItem(title: localOptionText, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
if let strongSelf = self {
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } })
strongSelf.beginDeleteMessagesWithUndo(messageIds: messageIds, type: unsendPersonalMessages ? .forEveryone : .forLocalPeer)
}
}))
}
if canDisplayContextMenu, let contextController = contextController {
contextController.setItems(.single(ContextController.Items(content: .list(contextItems))), minHeight: nil, animated: true)
} else {
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
if let contextController = contextController {
contextController.dismiss(completion: { [weak self] in
self?.present(actionSheet, in: .window(.root))
})
} else {
self.chatDisplayNode.dismissInput()
self.present(actionSheet, in: .window(.root))
completion(.default)
}
}
})
}
func presentClearCacheSuggestion() {
guard let peer = self.presentationInterfaceState.renderedPeer?.peer else {
return
}
self.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) })
let actionSheet = ActionSheetController(presentationData: self.presentationData)
var items: [ActionSheetItem] = []
items.append(DeleteChatPeerActionSheetItem(context: self.context, peer: EnginePeer(peer), chatPeer: EnginePeer(peer), action: .clearCacheSuggestion, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder))
var presented = false
items.append(ActionSheetButtonItem(title: self.presentationData.strings.ClearCache_FreeSpace, color: .accent, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
if let strongSelf = self, !presented {
presented = true
let context = strongSelf.context
strongSelf.push(StorageUsageScreen(context: context, makeStorageUsageExceptionsScreen: { category in
return storageUsageExceptionsScreen(context: context, category: category)
}))
}
}))
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
self.chatDisplayNode.dismissInput()
self.presentInGlobalOverlay(actionSheet)
}
func openDeleteMonoforumPeer(peerId: EnginePeer.Id) {
guard let chatPeerId = self.chatLocation.peerId else {
return
}
guard let mainChannel = self.presentationInterfaceState.renderedPeer?.chatOrMonoforumMainPeer as? TelegramChannel else {
return
}
let _ = (self.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: chatPeerId),
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] chatPeer, authorPeer in
guard let self, let chatPeer, let authorPeer else {
return
}
var initialUserBannedRights: [EnginePeer.Id: InitialBannedRights] = [:]
initialUserBannedRights[authorPeer.id] = InitialBannedRights(value: nil)
let participant: ChannelParticipant = .member(id: authorPeer.id, invitedAt: 0, adminInfo: nil, banInfo: ChannelParticipantBannedInfo(
rights: TelegramChatBannedRights(flags: [], untilDate: 0),
restrictedBy: self.context.account.peerId,
timestamp: 0,
isMember: false
), rank: nil, subscriptionUntilDate: nil)
self.push(AdminUserActionsSheet(
context: self.context,
chatPeer: chatPeer,
peers: [RenderedChannelParticipant(
participant: participant,
peer: authorPeer._asPeer()
)],
mode: .monoforum(completion: { [weak self] result in
guard let self else {
return
}
if self.chatLocation.threadId == peerId.toInt64() {
self.updateChatLocationThread(threadId: nil)
}
let _ = self.context.engine.peers.removeForumChannelThread(id: chatPeerId, threadId: peerId.toInt64()).startStandalone(completed: {
})
if result.ban {
let _ = self.context.engine.peers.updateChannelMemberBannedRights(peerId: mainChannel.id,
memberId: peerId,
rights: TelegramChatBannedRights(flags: [.banReadMessages], untilDate: Int32.max)
).startStandalone()
}
if result.reportSpam {
let _ = self.context.engine.peers.reportPeer(peerId: peerId, reason: .spam, message: "").startStandalone()
}
})
))
})
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
import Foundation
import TelegramPresentationData
import AccountContext
import Postbox
import TelegramCore
import SwiftSignalKit
import Display
import TelegramPresentationData
import PresentationDataUtils
import ChatMessageItemView
import TelegramNotices
extension ChatControllerImpl {
func displayBusinessBotMessageTooltip(itemNode: ChatMessageItemView) {
let _ = (ApplicationSpecificNotice.getBusinessBotMessageTooltip(accountManager: self.context.sharedContext.accountManager)
|> deliverOnMainQueue).startStandalone(next: { [weak self, weak itemNode] value in
guard let self, let itemNode else {
return
}
if value >= 2 {
return
}
guard let statusNode = itemNode.getStatusNode() else {
return
}
let bounds = statusNode.view.convert(statusNode.view.bounds, to: self.chatDisplayNode.view)
let location = CGPoint(x: bounds.midX, y: bounds.minY - 11.0)
let tooltipController = TooltipController(content: .text(self.presentationData.strings.Chat_BusinessBotMessageTooltip), baseFontSize: self.presentationData.listsFontSize.baseDisplaySize, balancedTextLayout: true, timeout: 3.5, dismissByTapOutside: true, dismissImmediatelyOnLayoutUpdate: true)
self.checksTooltipController = tooltipController
tooltipController.dismissed = { [weak self, weak tooltipController] _ in
if let strongSelf = self, let tooltipController = tooltipController, strongSelf.checksTooltipController === tooltipController {
strongSelf.checksTooltipController = nil
}
}
let _ = self.chatDisplayNode.messageTransitionNode.addCustomOffsetHandler(itemNode: itemNode, update: { [weak tooltipController] offset, transition in
guard let tooltipController, tooltipController.isNodeLoaded else {
return false
}
guard let containerView = tooltipController.view else {
return false
}
containerView.bounds = containerView.bounds.offsetBy(dx: 0.0, dy: -offset)
transition.animateOffsetAdditive(layer: containerView.layer, offset: offset)
return true
})
self.present(tooltipController, in: .current, with: TooltipControllerPresentationArguments(sourceNodeAndRect: { [weak self] in
guard let self else {
return nil
}
return (self.chatDisplayNode, CGRect(origin: location, size: CGSize()))
}))
#if DEBUG
if "".isEmpty {
return
}
#endif
let _ = ApplicationSpecificNotice.incrementBusinessBotMessageTooltip(accountManager: self.context.sharedContext.accountManager).startStandalone()
})
}
}
@@ -0,0 +1,108 @@
import Foundation
import TelegramPresentationData
import AccountContext
import Postbox
import TelegramCore
import SwiftSignalKit
import Display
import TelegramPresentationData
import PresentationDataUtils
import QuickReplyNameAlertController
import BusinessLinkNameAlertController
extension ChatControllerImpl {
func editChat() {
if case let .customChatContents(customChatContents) = self.subject, case let .quickReplyMessageInput(currentValue, shortcutType) = customChatContents.kind, case .generic = shortcutType {
var completion: ((String?) -> Void)?
let alertController = quickReplyNameAlertController(
context: self.context,
text: self.presentationData.strings.QuickReply_EditShortcutTitle,
subtext: self.presentationData.strings.QuickReply_EditShortcutText,
value: currentValue,
characterLimit: 32,
apply: { value in
completion?(value)
}
)
completion = { [weak self, weak alertController] value in
guard let self else {
alertController?.dismissAnimated()
return
}
if let value, !value.isEmpty {
if value == currentValue {
alertController?.dismissAnimated()
return
}
let _ = (self.context.engine.accountData.shortcutMessageList(onlyRemote: false)
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] shortcutMessageList in
guard let self else {
alertController?.dismissAnimated()
return
}
if shortcutMessageList.items.contains(where: { $0.shortcut.lowercased() == value.lowercased() }) {
if let contentNode = alertController?.contentNode as? QuickReplyNameAlertContentNode {
contentNode.setErrorText(errorText: self.presentationData.strings.QuickReply_ShortcutExistsInlineError)
}
} else {
self.chatTitleView?.titleContent = .custom("\(value)", nil, false)
alertController?.view.endEditing(true)
alertController?.dismissAnimated()
if case let .customChatContents(customChatContents) = self.subject {
customChatContents.quickReplyUpdateShortcut(value: value)
}
}
})
}
}
self.present(alertController, in: .window(.root))
} else if case let .customChatContents(customChatContents) = self.subject, case let .businessLinkSetup(link) = customChatContents.kind {
let currentValue = link.title ?? ""
var completion: ((String?) -> Void)?
let alertController = businessLinkNameAlertController(
context: self.context,
text: self.presentationData.strings.Business_Links_LinkNameTitle,
subtext: self.presentationData.strings.Business_Links_LinkNameText,
value: currentValue,
characterLimit: 32,
apply: { value in
completion?(value)
}
)
completion = { [weak self, weak alertController] value in
guard let self else {
alertController?.dismissAnimated()
return
}
if let value {
if value == currentValue {
alertController?.dismissAnimated()
return
}
let _ = self.context.engine.accountData.editBusinessChatLink(url: link.url, message: link.message, entities: link.entities, title: value.isEmpty ? nil : value).startStandalone()
let linkUrl: String
if link.url.hasPrefix("https://") {
linkUrl = String(link.url[link.url.index(link.url.startIndex, offsetBy: "https://".count)...])
} else {
linkUrl = link.url
}
self.chatTitleView?.titleContent = .custom(value.isEmpty ? self.presentationData.strings.Business_Links_EditLinkTitle : value, linkUrl, false)
if case let .customChatContents(customChatContents) = self.subject {
customChatContents.businessLinkUpdate(message: link.message, entities: link.entities, title: value.isEmpty ? nil : value)
}
alertController?.view.endEditing(true)
alertController?.dismissAnimated()
}
}
self.present(alertController, in: .window(.root))
}
}
}
@@ -0,0 +1,519 @@
import Foundation
import TelegramPresentationData
import AccountContext
import Postbox
import TelegramCore
import SwiftSignalKit
import Display
import TelegramPresentationData
import PresentationDataUtils
import TextFormat
import UndoUI
import ChatInterfaceState
import PremiumUI
import ReactionSelectionNode
import TopMessageReactions
import ChatMessagePaymentAlertController
extension ChatControllerImpl {
func forwardMessages(messageIds: [MessageId], options: ChatInterfaceForwardOptionsState? = nil, resetCurrent: Bool = false) {
let _ = (self.context.engine.data.get(EngineDataMap(
messageIds.map(TelegramEngine.EngineData.Item.Messages.Message.init)
))
|> deliverOnMainQueue).startStandalone(next: { [weak self] messages in
let sortedMessages = messages.values.compactMap { $0?._asMessage() }.sorted { lhs, rhs in
return lhs.id < rhs.id
}
self?.forwardMessages(messages: sortedMessages, options: options, resetCurrent: resetCurrent)
})
}
func forwardMessages(messages: [Message], options: ChatInterfaceForwardOptionsState? = nil, resetCurrent: Bool) {
let _ = self.presentVoiceMessageDiscardAlert(action: {
var filter: ChatListNodePeersFilter = [.onlyWriteable, .excludeDisabled, .doNotSearchMessages]
var hasPublicPolls = false
var hasPublicQuiz = false
var hasTodo = false
for message in messages {
for media in message.media {
if let poll = media as? TelegramMediaPoll, case .public = poll.publicity {
hasPublicPolls = true
if case .quiz = poll.kind {
hasPublicQuiz = true
}
filter.insert(.excludeChannels)
} else if let _ = media as? TelegramMediaTodo {
hasTodo = true
filter.insert(.excludeChannels)
} else if let _ = media as? TelegramMediaPaidContent {
filter.insert(.excludeSecretChats)
}
}
}
var attemptSelectionImpl: ((EnginePeer, ChatListDisabledPeerReason) -> Void)?
let controller = self.context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: self.context, updatedPresentationData: self.updatedPresentationData, filter: filter, hasFilters: true, attemptSelection: { peer, _, reason in
attemptSelectionImpl?(peer, reason)
}, multipleSelection: true, forwardedMessageIds: messages.map { $0.id }, selectForumThreads: true))
let context = self.context
attemptSelectionImpl = { [weak self, weak controller] peer, reason in
guard let strongSelf = self, let controller = controller else {
return
}
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
if hasPublicPolls {
if case let .channel(channel) = peer, case .broadcast = channel.info {
controller.present(textAlertController(context: context, title: nil, text: hasPublicQuiz ? presentationData.strings.Forward_ErrorPublicQuizDisabledInChannels : presentationData.strings.Forward_ErrorPublicPollDisabledInChannels, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root))
return
}
} else if hasTodo {
if case let .channel(channel) = peer, case .broadcast = channel.info {
controller.present(textAlertController(context: context, title: nil, text: presentationData.strings.Forward_ErrorTodoDisabledInChannels, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root))
return
}
}
switch reason {
case .generic:
controller.present(textAlertController(context: context, updatedPresentationData: strongSelf.updatedPresentationData, title: nil, text: presentationData.strings.Forward_ErrorDisabledForChat, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root))
case .premiumRequired:
controller.forEachController { c in
if let c = c as? UndoOverlayController {
c.dismiss()
}
return true
}
var hasAction = false
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: strongSelf.context.currentAppConfiguration.with { $0 })
if !premiumConfiguration.isPremiumDisabled {
hasAction = true
}
controller.present(UndoOverlayController(presentationData: presentationData, content: .premiumPaywall(title: nil, text: presentationData.strings.Chat_ToastMessagingRestrictedToPremium_Text(peer.compactDisplayTitle).string, customUndoText: hasAction ? presentationData.strings.Chat_ToastMessagingRestrictedToPremium_Action : nil, timeout: nil, linkAction: { _ in
}), elevatedLayout: false, animateInAsReplacement: true, action: { [weak controller] action in
guard let self, let controller else {
return false
}
if case .undo = action {
let premiumController = PremiumIntroScreen(context: self.context, source: .settings)
controller.push(premiumController)
}
return false
}), in: .current)
}
}
controller.multiplePeersSelected = { [weak self, weak controller] peers, peerMap, messageText, mode, forwardOptions, _ in
let peerIds = peers.map { $0.id }
let _ = (context.engine.data.get(
EngineDataMap(
peerIds.map(TelegramEngine.EngineData.Item.Peer.SendPaidMessageStars.init(id:))
),
EngineDataList(
peerIds.map(TelegramEngine.EngineData.Item.Peer.RenderedPeer.init(id:))
)
)
|> deliverOnMainQueue).start(next: { [weak self, weak controller] sendPaidMessageStars, renderedPeers in
guard let strongSelf = self else {
return
}
let renderedPeers = renderedPeers.compactMap({ $0 })
var count: Int32 = Int32(messages.count)
if messageText.string.count > 0 {
count += 1
}
var totalAmount: StarsAmount = .zero
var chargingPeers: [EngineRenderedPeer] = []
for peer in renderedPeers {
if let maybeAmount = sendPaidMessageStars[peer.peerId], let amount = maybeAmount {
totalAmount = totalAmount + amount
chargingPeers.append(peer)
}
}
let proceed = { [weak self, weak controller] in
guard let strongSelf = self, let strongController = controller else {
return
}
strongController.dismiss()
var result: [EnqueueMessage] = []
if messageText.string.count > 0 {
let inputText = convertMarkdownToAttributes(messageText)
for text in breakChatInputText(trimChatInputText(inputText)) {
if text.length != 0 {
var attributes: [MessageAttribute] = []
let entities = generateTextEntities(text.string, enabledTypes: .all, currentEntities: generateChatInputTextEntities(text))
if !entities.isEmpty {
attributes.append(TextEntitiesMessageAttribute(entities: entities))
}
result.append(.message(text: text.string, attributes: attributes, inlineStickers: [:], mediaReference: nil, threadId: strongSelf.chatLocation.threadId, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []))
}
}
}
var attributes: [MessageAttribute] = []
attributes.append(ForwardOptionsMessageAttribute(hideNames: forwardOptions?.hideNames == true, hideCaptions: forwardOptions?.hideCaptions == true))
result.append(contentsOf: messages.map { message -> EnqueueMessage in
return .forward(source: message.id, threadId: nil, grouping: .auto, attributes: attributes, correlationId: nil)
})
let commit: ([EnqueueMessage]) -> Void = { result in
guard let strongSelf = self else {
return
}
var result = result
strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }).updatedSearch(nil) })
var correlationIds: [Int64] = []
for i in 0 ..< result.count {
let correlationId = Int64.random(in: Int64.min ... Int64.max)
correlationIds.append(correlationId)
result[i] = result[i].withUpdatedCorrelationId(correlationId)
}
let targetPeersShouldDivertSignals: [Signal<(EnginePeer, Bool), NoError>] = peers.map { peer -> Signal<(EnginePeer, Bool), NoError> in
return strongSelf.shouldDivertMessagesToScheduled(targetPeer: peer, messages: result)
|> map { shouldDivert -> (EnginePeer, Bool) in
return (peer, shouldDivert)
}
}
let targetPeersShouldDivert: Signal<[(EnginePeer, Bool)], NoError> = combineLatest(targetPeersShouldDivertSignals)
let _ = (targetPeersShouldDivert
|> deliverOnMainQueue).startStandalone(next: { targetPeersShouldDivert in
guard let strongSelf = self else {
return
}
var displayConvertingTooltip = false
var displayPeers: [EnginePeer] = []
for (peer, shouldDivert) in targetPeersShouldDivert {
var peerMessages = result
if shouldDivert {
displayConvertingTooltip = true
peerMessages = peerMessages.map { message -> EnqueueMessage in
return message.withUpdatedAttributes { attributes in
var attributes = attributes
attributes.removeAll(where: { $0 is OutgoingScheduleInfoMessageAttribute })
attributes.append(OutgoingScheduleInfoMessageAttribute(scheduleTime: Int32(Date().timeIntervalSince1970) + 10 * 24 * 60 * 60, repeatPeriod: nil))
return attributes
}
}
}
if let maybeAmount = sendPaidMessageStars[peer.id], let amount = maybeAmount {
peerMessages = peerMessages.map { message -> EnqueueMessage in
return message.withUpdatedAttributes { attributes in
var attributes = attributes
attributes.append(PaidStarsMessageAttribute(stars: amount, postponeSending: false))
return attributes
}
}
}
let _ = (enqueueMessages(account: strongSelf.context.account, peerId: peer.id, messages: peerMessages)
|> deliverOnMainQueue).startStandalone(next: { messageIds in
if let strongSelf = self {
let signals: [Signal<Bool, NoError>] = messageIds.compactMap({ id -> Signal<Bool, NoError>? in
guard let id = id else {
return nil
}
return strongSelf.context.account.pendingMessageManager.pendingMessageStatus(id)
|> mapToSignal { status, _ -> Signal<Bool, NoError> in
if status != nil {
return .never()
} else {
return .single(true)
}
}
|> take(1)
})
if strongSelf.shareStatusDisposable == nil {
strongSelf.shareStatusDisposable = MetaDisposable()
}
strongSelf.shareStatusDisposable?.set((combineLatest(signals)
|> deliverOnMainQueue).startStrict())
}
})
if case let .secretChat(secretPeer) = peer {
if let peer = peerMap[secretPeer.regularPeerId] {
displayPeers.append(peer)
}
} else {
displayPeers.append(peer)
}
}
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
let text: String
var savedMessages = false
if displayPeers.count == 1, let peerId = displayPeers.first?.id, peerId == strongSelf.context.account.peerId {
text = messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_SavedMessages_One : presentationData.strings.Conversation_ForwardTooltip_SavedMessages_Many
savedMessages = true
} else {
if displayPeers.count == 1, let peer = displayPeers.first {
var peerName = peer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
peerName = peerName.replacingOccurrences(of: "**", with: "")
text = messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_Chat_One(peerName).string : presentationData.strings.Conversation_ForwardTooltip_Chat_Many(peerName).string
} else if displayPeers.count == 2, let firstPeer = displayPeers.first, let secondPeer = displayPeers.last {
var firstPeerName = firstPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
firstPeerName = firstPeerName.replacingOccurrences(of: "**", with: "")
var secondPeerName = secondPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
secondPeerName = secondPeerName.replacingOccurrences(of: "**", with: "")
text = messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_TwoChats_One(firstPeerName, secondPeerName).string : presentationData.strings.Conversation_ForwardTooltip_TwoChats_Many(firstPeerName, secondPeerName).string
} else if let peer = displayPeers.first {
var peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
peerName = peerName.replacingOccurrences(of: "**", with: "")
text = messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_ManyChats_One(peerName, "\(displayPeers.count - 1)").string : presentationData.strings.Conversation_ForwardTooltip_ManyChats_Many(peerName, "\(displayPeers.count - 1)").string
} else {
text = ""
}
}
let reactionItems: Signal<[ReactionItem], NoError>
if savedMessages && messages.count > 0 {
reactionItems = tagMessageReactions(context: strongSelf.context, subPeerId: nil)
} else {
reactionItems = .single([])
}
let _ = (reactionItems
|> deliverOnMainQueue).startStandalone(next: { [weak strongSelf] reactionItems in
guard let strongSelf else {
return
}
strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: savedMessages, text: text), elevatedLayout: false, position: savedMessages && messages.count > 0 ? .top : .bottom, animateInAsReplacement: true, action: { action in
if savedMessages, let self, action == .info {
let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId))
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let self, let peer else {
return
}
guard let navigationController = self.navigationController as? NavigationController else {
return
}
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), forceOpenChat: true))
})
}
return false
}, additionalView: (savedMessages && messages.count > 0) ? chatShareToSavedMessagesAdditionalView(strongSelf, reactionItems: reactionItems, correlationIds: correlationIds) : nil), in: .current)
})
if displayConvertingTooltip {
}
})
}
switch mode {
case .generic:
commit(result)
case .silent:
let transformedMessages = strongSelf.transformEnqueueMessages(result, silentPosting: true)
commit(transformedMessages)
case .schedule:
strongSelf.presentScheduleTimePicker(completion: { [weak self] scheduleTime, repeatPeriod in
if let strongSelf = self {
let transformedMessages = strongSelf.transformEnqueueMessages(result, silentPosting: false, scheduleTime: scheduleTime, repeatPeriod: repeatPeriod)
commit(transformedMessages)
}
})
case .whenOnline:
let transformedMessages = strongSelf.transformEnqueueMessages(result, silentPosting: false, scheduleTime: scheduleWhenOnlineTimestamp)
commit(transformedMessages)
}
}
if totalAmount.value > 0 {
let controller = chatMessagePaymentAlertController(
context: nil,
presentationData: strongSelf.presentationData,
updatedPresentationData: nil,
peers: chargingPeers,
count: count,
amount: totalAmount,
totalAmount: totalAmount,
hasCheck: false,
navigationController: strongSelf.navigationController as? NavigationController,
completion: { _ in
proceed()
}
)
strongSelf.present(controller, in: .window(.root))
} else {
proceed()
}
})
}
controller.peerSelected = { [weak self, weak controller] peer, threadId in
guard let strongSelf = self, let strongController = controller else {
return
}
let peerId = peer.id
let accountPeerId = strongSelf.context.account.peerId
if resetCurrent {
strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withUpdatedForwardMessageIds(nil).withUpdatedForwardOptionsState(nil) }) })
}
var isPinnedMessages = false
if case .pinnedMessages = strongSelf.presentationInterfaceState.subject {
isPinnedMessages = true
}
var hasNotOwnMessages = false
for message in messages {
if message.id.peerId == accountPeerId && message.forwardInfo == nil {
} else {
hasNotOwnMessages = true
}
}
if case .peer(peerId) = strongSelf.chatLocation, strongSelf.parentController == nil, !isPinnedMessages {
strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withUpdatedForwardMessageIds(messages.map { $0.id }).withUpdatedForwardOptionsState(ChatInterfaceForwardOptionsState(hideNames: !hasNotOwnMessages, hideCaptions: false, unhideNamesOnCaptionChange: false)).withoutSelectionState() }).updatedSearch(nil) })
strongSelf.updateItemNodesSearchTextHighlightStates()
strongSelf.searchResultsController = nil
strongController.dismiss()
} else if peerId == strongSelf.context.account.peerId {
Queue.mainQueue().after(0.88) {
strongSelf.chatDisplayNode.hapticFeedback.success()
}
let reactionItems: Signal<[ReactionItem], NoError>
if messages.count > 0 {
reactionItems = tagMessageReactions(context: strongSelf.context, subPeerId: nil)
} else {
reactionItems = .single([])
}
var correlationIds: [Int64] = []
let mappedMessages = messages.map { message -> EnqueueMessage in
let correlationId = Int64.random(in: Int64.min ... Int64.max)
correlationIds.append(correlationId)
return .forward(source: message.id, threadId: nil, grouping: .auto, attributes: [], correlationId: correlationId)
}
let _ = (reactionItems
|> deliverOnMainQueue).startStandalone(next: { [weak strongSelf] reactionItems in
guard let strongSelf else {
return
}
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: true, text: messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_SavedMessages_One : presentationData.strings.Conversation_ForwardTooltip_SavedMessages_Many), elevatedLayout: false, position: .top, animateInAsReplacement: true, action: { [weak self] value in
if case .info = value, let strongSelf = self {
let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.context.account.peerId))
|> deliverOnMainQueue).startStandalone(next: { peer in
guard let strongSelf = self, let peer = peer, let navigationController = strongSelf.effectiveNavigationController else {
return
}
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil, forceOpenChat: true))
})
return true
}
return false
}, additionalView: messages.count > 0 ? chatShareToSavedMessagesAdditionalView(strongSelf, reactionItems: reactionItems, correlationIds: correlationIds) : nil), in: .current)
})
let _ = (enqueueMessages(account: strongSelf.context.account, peerId: peerId, messages: mappedMessages)
|> deliverOnMainQueue).startStandalone(next: { messageIds in
if let strongSelf = self {
let signals: [Signal<Bool, NoError>] = messageIds.compactMap({ id -> Signal<Bool, NoError>? in
guard let id = id else {
return nil
}
return strongSelf.context.account.pendingMessageManager.pendingMessageStatus(id)
|> mapToSignal { status, _ -> Signal<Bool, NoError> in
if status != nil {
return .never()
} else {
return .single(true)
}
}
|> take(1)
})
if strongSelf.shareStatusDisposable == nil {
strongSelf.shareStatusDisposable = MetaDisposable()
}
strongSelf.shareStatusDisposable?.set((combineLatest(signals)
|> deliverOnMainQueue).startStrict())
}
})
strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) })
strongController.dismiss()
} else {
if let navigationController = strongSelf.navigationController as? NavigationController {
for controller in navigationController.viewControllers {
if let maybeChat = controller as? ChatControllerImpl {
if case .peer(peerId) = maybeChat.chatLocation {
var isChatPinnedMessages = false
if case .pinnedMessages = maybeChat.presentationInterfaceState.subject {
isChatPinnedMessages = true
}
if !isChatPinnedMessages {
maybeChat.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withUpdatedForwardMessageIds(messages.map { $0.id }).withoutSelectionState() }) })
strongSelf.dismiss()
strongController.dismiss()
return
}
}
}
}
}
let _ = (ChatInterfaceState.update(engine: strongSelf.context.engine, peerId: peerId, threadId: threadId, { currentState in
return currentState.withUpdatedForwardMessageIds(messages.map { $0.id }).withUpdatedForwardOptionsState(ChatInterfaceForwardOptionsState(hideNames: !hasNotOwnMessages, hideCaptions: false, unhideNamesOnCaptionChange: false))
})
|> deliverOnMainQueue).startStandalone(completed: {
if let strongSelf = self {
let proceed: (ChatController) -> Void = { chatController in
strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) })
let navigationController: NavigationController?
if let parentController = strongSelf.parentController {
navigationController = (parentController.navigationController as? NavigationController)
} else {
navigationController = strongSelf.effectiveNavigationController
}
if let navigationController = navigationController {
var viewControllers = navigationController.viewControllers
if threadId != nil {
viewControllers.insert(chatController, at: viewControllers.count - 2)
} else {
viewControllers.insert(chatController, at: viewControllers.count - 1)
}
navigationController.setViewControllers(viewControllers, animated: false)
strongSelf.controllerNavigationDisposable.set((chatController.ready.get()
|> SwiftSignalKit.filter { $0 }
|> take(1)
|> deliverOnMainQueue).startStrict(next: { [weak navigationController] _ in
viewControllers.removeAll(where: { $0 is PeerSelectionController })
navigationController?.setViewControllers(viewControllers, animated: true)
}))
}
}
if let threadId = threadId {
let _ = (strongSelf.context.sharedContext.chatControllerForForumThread(context: strongSelf.context, peerId: peerId, threadId: threadId)
|> deliverOnMainQueue).startStandalone(next: { chatController in
proceed(chatController)
})
} else {
proceed(ChatControllerImpl(context: strongSelf.context, chatLocation: .peer(id: peerId)))
}
}
})
}
}
self.chatDisplayNode.dismissInput()
self.effectiveNavigationController?.pushViewController(controller)
})
}
}
@@ -0,0 +1,284 @@
import Foundation
import TelegramPresentationData
import AccountContext
import Postbox
import ChatInterfaceState
import TelegramCore
import SwiftSignalKit
import TextFormat
import UndoUI
import PresentationDataUtils
import UIKit
import Display
import ChatPresentationInterfaceState
import ChatControllerInteraction
extension ChatControllerImpl {
var keyShortcutsInternal: [KeyShortcut] {
if !self.traceVisibility() || !isTopmostChatController(self) {
return []
}
let strings = self.presentationData.strings
var inputShortcuts: [KeyShortcut]
if self.chatDisplayNode.isInputViewFocused {
inputShortcuts = [
KeyShortcut(title: strings.KeyCommand_SendMessage, input: "\r", action: {}),
KeyShortcut(input: "B", modifiers: [.command], action: { [weak self] in
if let strongSelf = self {
strongSelf.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in
return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.bold, value: nil), inputMode)
}
}
}),
KeyShortcut(input: "I", modifiers: [.command], action: { [weak self] in
if let strongSelf = self {
strongSelf.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in
return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.italic, value: nil), inputMode)
}
}
}),
KeyShortcut(input: "M", modifiers: [.shift, .command], action: { [weak self] in
if let strongSelf = self {
strongSelf.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in
return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.monospace, value: nil), inputMode)
}
}
}),
KeyShortcut(input: "U", modifiers: [.command], action: { [weak self] in
if let strongSelf = self {
strongSelf.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in
return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.underline, value: nil), inputMode)
}
}
}),
KeyShortcut(input: "X", modifiers: [.command, .shift], action: { [weak self] in
if let strongSelf = self {
strongSelf.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in
return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.strikethrough, value: nil), inputMode)
}
}
}),
KeyShortcut(input: "P", modifiers: [.command, .shift], action: { [weak self] in
if let strongSelf = self {
strongSelf.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in
return (chatTextInputAddFormattingAttribute(current, attribute: ChatTextInputAttributes.spoiler, value: nil), inputMode)
}
}
}),
KeyShortcut(input: "K", modifiers: [.command], action: { [weak self] in
if let strongSelf = self {
strongSelf.interfaceInteraction?.openLinkEditing()
}
}),
KeyShortcut(input: "N", modifiers: [.shift, .command], action: { [weak self] in
if let strongSelf = self {
strongSelf.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in
return (chatTextInputClearFormattingAttributes(current), inputMode)
}
}
})
]
} else if UIResponder.currentFirst() == nil {
inputShortcuts = [
KeyShortcut(title: strings.KeyCommand_FocusOnInputField, input: "\r", action: { [weak self] in
if let strongSelf = self {
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in
return state.updatedInterfaceState { interfaceState in
return interfaceState.withUpdatedEffectiveInputState(interfaceState.effectiveInputState)
}.updatedInputMode({ _ in .text })
})
}
}),
KeyShortcut(input: "/", modifiers: [], action: { [weak self] in
if let strongSelf = self {
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in
if state.interfaceState.effectiveInputState.inputText.length == 0 {
return state.updatedInterfaceState { interfaceState in
let effectiveInputState = ChatTextInputState(inputText: NSAttributedString(string: "/"))
return interfaceState.withUpdatedEffectiveInputState(effectiveInputState)
}.updatedInputMode({ _ in .text })
} else {
return state
}
})
}
}),
KeyShortcut(input: "2", modifiers: [.shift], action: { [weak self] in
if let strongSelf = self {
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in
if state.interfaceState.effectiveInputState.inputText.length == 0 {
return state.updatedInterfaceState { interfaceState in
let effectiveInputState = ChatTextInputState(inputText: NSAttributedString(string: "@"))
return interfaceState.withUpdatedEffectiveInputState(effectiveInputState)
}.updatedInputMode({ _ in .text })
} else {
return state
}
})
}
}),
KeyShortcut(input: "3", modifiers: [.shift], action: { [weak self] in
if let strongSelf = self {
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in
if state.interfaceState.effectiveInputState.inputText.length == 0 {
return state.updatedInterfaceState { interfaceState in
let effectiveInputState = ChatTextInputState(inputText: NSAttributedString(string: "#"))
return interfaceState.withUpdatedEffectiveInputState(effectiveInputState)
}.updatedInputMode({ _ in .text })
} else {
return state
}
})
}
})
]
} else {
inputShortcuts = []
}
inputShortcuts.append(
KeyShortcut(
input: "W",
modifiers: [.command],
action: { [weak self] in
self?.dismiss(animated: true, completion: nil)
}
)
)
if canReplyInChat(self.presentationInterfaceState, accountPeerId: self.context.account.peerId) {
inputShortcuts.append(
KeyShortcut(
input: UIKeyCommand.inputUpArrow,
modifiers: [.alternate, .command],
action: { [weak self] in
guard let strongSelf = self else {
return
}
if let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject {
if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(before: replyMessageSubject.messageId) {
strongSelf.updateChatPresentationInterfaceState(interactive: true, { state in
var updatedState = state.updatedInterfaceState({ state in
return state.withUpdatedReplyMessageSubject(ChatInterfaceState.ReplyMessageSubject(
messageId: message.id,
quote: nil,
todoItemId: nil
))
})
if updatedState.inputMode == .none {
updatedState = updatedState.updatedInputMode({ _ in .text })
}
return updatedState
})
strongSelf.navigateToMessage(messageLocation: .id(message.id, NavigateToMessageParams(timestamp: nil, quote: nil)), animated: true)
}
} else {
strongSelf.scrollToEndOfHistory()
Queue.mainQueue().after(0.1, {
let lastMessage = strongSelf.chatDisplayNode.historyNode.latestMessageInCurrentHistoryView()
strongSelf.updateChatPresentationInterfaceState(interactive: true, { state in
var updatedState = state.updatedInterfaceState({ state in
return state.withUpdatedReplyMessageSubject((lastMessage?.id).flatMap { id in
return ChatInterfaceState.ReplyMessageSubject(
messageId: id,
quote: nil,
todoItemId: nil
)
})
})
if updatedState.inputMode == .none {
updatedState = updatedState.updatedInputMode({ _ in .text })
}
return updatedState
})
if let lastMessage = lastMessage {
strongSelf.navigateToMessage(messageLocation: .id(lastMessage.id, NavigateToMessageParams(timestamp: nil, quote: nil)), animated: true)
}
})
}
}
)
)
inputShortcuts.append(
KeyShortcut(
input: UIKeyCommand.inputDownArrow,
modifiers: [.alternate, .command],
action: { [weak self] in
guard let strongSelf = self else {
return
}
if let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject {
let lastMessage = strongSelf.chatDisplayNode.historyNode.latestMessageInCurrentHistoryView()
var updatedReplyMessageSubject: ChatInterfaceState.ReplyMessageSubject?
if replyMessageSubject.messageId == lastMessage?.id {
updatedReplyMessageSubject = nil
} else if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(after: replyMessageSubject.messageId) {
updatedReplyMessageSubject = ChatInterfaceState.ReplyMessageSubject(messageId: message.id, quote: nil, todoItemId: nil)
}
strongSelf.updateChatPresentationInterfaceState(interactive: true, { state in
var updatedState = state.updatedInterfaceState({ state in
return state.withUpdatedReplyMessageSubject(updatedReplyMessageSubject)
})
if updatedState.inputMode == .none {
updatedState = updatedState.updatedInputMode({ _ in .text })
} else if updatedReplyMessageSubject == nil {
updatedState = updatedState.updatedInputMode({ _ in .none })
}
return updatedState
})
if let updatedReplyMessageSubject {
strongSelf.navigateToMessage(messageLocation: .id(updatedReplyMessageSubject.messageId, NavigateToMessageParams(timestamp: nil, quote: nil)), animated: true)
}
}
}
)
)
}
var canEdit = false
if self.presentationInterfaceState.interfaceState.effectiveInputState.inputText.length == 0 && self.presentationInterfaceState.interfaceState.editMessage == nil {
canEdit = true
}
if canEdit, let message = self.chatDisplayNode.historyNode.firstMessageForEditInCurrentHistoryView() {
inputShortcuts.append(KeyShortcut(input: UIKeyCommand.inputUpArrow, action: { [weak self] in
if let strongSelf = self {
strongSelf.interfaceInteraction?.setupEditMessage(message.id, { _ in })
}
}))
}
let otherShortcuts: [KeyShortcut] = [
KeyShortcut(title: strings.KeyCommand_ChatInfo, input: "I", modifiers: [.command, .control], action: { [weak self] in
if let strongSelf = self {
strongSelf.interfaceInteraction?.openPeerInfo()
}
}),
KeyShortcut(input: "/", modifiers: [.command], action: { [weak self] in
if let strongSelf = self {
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in
return state.updatedInterfaceState { interfaceState in
return interfaceState.withUpdatedEffectiveInputState(interfaceState.effectiveInputState)
}.updatedInputMode({ _ in ChatInputMode.media(mode: .other, expanded: nil, focused: false) })
})
}
}),
KeyShortcut(title: strings.KeyCommand_SearchInChat, input: "F", modifiers: [.command], action: { [weak self] in
if let strongSelf = self {
strongSelf.beginMessageSearch("")
}
})
]
return inputShortcuts + otherShortcuts
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
import Foundation
import TelegramPresentationData
import AccountContext
import Postbox
import TelegramCore
import SwiftSignalKit
import CalendarMessageScreen
import ContextUI
import ChatControllerInteraction
import Display
import UIKit
import UndoUI
extension ChatControllerImpl {
func openCalendarSearch(timestamp: Int32) {
guard let peerId = self.chatLocation.peerId else {
return
}
self.chatDisplayNode.dismissInput()
let initialTimestamp = timestamp
var dismissCalendarScreen: (() -> Void)?
var selectDay: ((Int32) -> Void)?
var openClearHistory: ((Int32) -> Void)?
let enableMessageRangeDeletion: Bool = peerId.namespace == Namespaces.Peer.CloudUser
let displayMedia = self.presentationInterfaceState.historyFilter == nil
let calendarScreen = CalendarMessageScreen(
context: self.context,
peerId: peerId,
calendarSource: self.context.engine.messages.sparseMessageCalendar(peerId: peerId, threadId: self.chatLocation.threadId, tag: .photoOrVideo, displayMedia: displayMedia),
initialTimestamp: initialTimestamp,
enableMessageRangeDeletion: enableMessageRangeDeletion,
canNavigateToEmptyDays: true,
navigateToDay: { [weak self] c, index, timestamp in
guard let strongSelf = self else {
c.dismiss()
return
}
strongSelf.alwaysShowSearchResultsAsList = false
strongSelf.chatDisplayNode.alwaysShowSearchResultsAsList = false
strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: false, { state in
return state.updatedDisplayHistoryFilterAsList(false).updatedSearch(nil)
})
c.dismiss()
strongSelf.loadingMessage.set(.single(.generic))
let peerId: PeerId
let threadId: Int64?
switch strongSelf.chatLocation {
case let .peer(peerIdValue):
peerId = peerIdValue
threadId = nil
case let .replyThread(replyThreadMessage):
peerId = replyThreadMessage.peerId
threadId = replyThreadMessage.threadId
case .customChatContents:
return
}
strongSelf.messageIndexDisposable.set((strongSelf.context.engine.messages.searchMessageIdByTimestamp(peerId: peerId, threadId: threadId, timestamp: timestamp) |> deliverOnMainQueue).startStrict(next: { messageId in
if let strongSelf = self {
strongSelf.loadingMessage.set(.single(nil))
if let messageId = messageId {
strongSelf.navigateToMessage(from: nil, to: .id(messageId, NavigateToMessageParams(timestamp: nil, quote: nil)), forceInCurrentChat: true)
}
}
}))
},
previewDay: { [weak self] timestamp, _, sourceNode, sourceRect, gesture in
guard let strongSelf = self else {
return
}
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Chat_JumpToDate, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.contextMenu.primaryColor)
}, action: { _, f in
f(.dismissWithoutContent)
dismissCalendarScreen?()
strongSelf.loadingMessage.set(.single(.generic))
let peerId: PeerId
let threadId: Int64?
switch strongSelf.chatLocation {
case let .peer(peerIdValue):
peerId = peerIdValue
threadId = nil
case let .replyThread(replyThreadMessage):
peerId = replyThreadMessage.peerId
threadId = replyThreadMessage.threadId
case .customChatContents:
return
}
strongSelf.messageIndexDisposable.set((strongSelf.context.engine.messages.searchMessageIdByTimestamp(peerId: peerId, threadId: threadId, timestamp: timestamp) |> deliverOnMainQueue).startStrict(next: { messageId in
if let strongSelf = self {
strongSelf.loadingMessage.set(.single(nil))
if let messageId = messageId {
strongSelf.navigateToMessage(from: nil, to: .id(messageId, NavigateToMessageParams(timestamp: nil, quote: nil)), forceInCurrentChat: true)
}
}
}))
})))
if enableMessageRangeDeletion && (peerId.namespace == Namespaces.Peer.CloudUser || peerId.namespace == Namespaces.Peer.SecretChat) {
items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.DialogList_ClearHistoryConfirmation, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor)
}, action: { _, f in
f(.dismissWithoutContent)
openClearHistory?(timestamp)
})))
items.append(.separator)
items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Common_Select, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor)
}, action: { _, f in
f(.dismissWithoutContent)
selectDay?(timestamp)
})))
}
let chatController = strongSelf.context.sharedContext.makeChatController(context: strongSelf.context, chatLocation: .peer(id: peerId), subject: .message(id: .timestamp(timestamp), highlight: nil, timecode: nil, setupReply: false), botStart: nil, mode: .standard(.previewing), params: nil)
chatController.canReadHistory.set(false)
strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts()
let contextController = ContextController(presentationData: strongSelf.presentationData, source: .controller(ChatContextControllerContentSourceImpl(controller: chatController, sourceNode: sourceNode, sourceRect: sourceRect, passthroughTouches: true)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture)
strongSelf.presentInGlobalOverlay(contextController)
}
)
calendarScreen.completedWithRemoveMessagesInRange = { [weak self] range, type, dayCount, calendarSource in
guard let strongSelf = self else {
return
}
let statusText: String
switch type {
case .forEveryone:
statusText = strongSelf.presentationData.strings.Chat_MessageRangeDeleted_ForBothSides(Int32(dayCount))
default:
statusText = strongSelf.presentationData.strings.Chat_MessageRangeDeleted_ForMe(Int32(dayCount))
}
strongSelf.chatDisplayNode.historyNode.ignoreMessagesInTimestampRange = range
strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: strongSelf.context, title: NSAttributedString(string: statusText), text: nil), elevatedLayout: false, action: { value in
guard let strongSelf = self else {
return false
}
if value == .commit {
let _ = calendarSource.removeMessagesInRange(minTimestamp: range.lowerBound, maxTimestamp: range.upperBound, type: type, completion: {
Queue.mainQueue().after(1.0, {
guard let strongSelf = self else {
return
}
strongSelf.chatDisplayNode.historyNode.ignoreMessagesInTimestampRange = nil
})
})
return true
} else if value == .undo {
strongSelf.chatDisplayNode.historyNode.ignoreMessagesInTimestampRange = nil
return true
}
return false
}), in: .current)
}
self.effectiveNavigationController?.pushViewController(calendarScreen)
dismissCalendarScreen = { [weak calendarScreen] in
calendarScreen?.dismiss(completion: nil)
}
selectDay = { [weak calendarScreen] timestamp in
calendarScreen?.selectDay(timestamp: timestamp)
}
openClearHistory = { [weak calendarScreen] timestamp in
calendarScreen?.openClearHistory(timestamp: timestamp)
}
}
}
@@ -0,0 +1,554 @@
import Foundation
import TelegramPresentationData
import AccountContext
import Postbox
import TelegramCore
import SwiftSignalKit
import ContextUI
import Display
import UIKit
import ReactionListContextMenuContent
import UndoUI
import TooltipUI
import StickerPackPreviewUI
import TextNodeWithEntities
import ChatPresentationInterfaceState
import SavedTagNameAlertController
import PremiumUI
import ChatSendStarsScreen
import ChatMessageItemCommon
import ChatMessageItemView
import ReactionSelectionNode
import AnimatedTextComponent
extension ChatControllerImpl {
func presentTagPremiumPaywall() {
let context = self.context
var replaceImpl: ((ViewController) -> Void)?
let controller = PremiumDemoScreen(context: context, subject: .messageTags, action: {
let controller = PremiumIntroScreen(context: context, source: .messageTags)
replaceImpl?(controller)
})
replaceImpl = { [weak controller] c in
controller?.replace(with: c)
}
self.push(controller)
}
func openMessageReactionContextMenu(message: Message, sourceView: ContextExtractedContentContainingView, gesture: ContextGesture?, value: MessageReaction.Reaction) {
if message.areReactionsTags(accountPeerId: self.context.account.peerId) {
if !self.presentationInterfaceState.isPremium {
self.presentTagPremiumPaywall()
return
}
let reactionFile: Signal<TelegramMediaFile?, NoError>
switch value {
case .builtin, .stars:
reactionFile = self.context.engine.stickers.availableReactions()
|> take(1)
|> map { availableReactions -> TelegramMediaFile? in
return availableReactions?.reactions.first(where: { $0.value == value })?.selectAnimation._parse()
}
case let .custom(fileId):
reactionFile = self.context.engine.stickers.resolveInlineStickers(fileIds: [fileId])
|> map { files -> TelegramMediaFile? in
return files.values.first
}
}
let _ = (combineLatest(queue: .mainQueue(),
self.context.engine.stickers.savedMessageTagData(),
reactionFile
)
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] savedMessageTags, reactionFile in
guard let self, let savedMessageTags else {
return
}
guard let reactionFile else {
return
}
var items: [ContextMenuItem] = []
let tag: EngineMessage.CustomTag = ReactionsMessageAttribute.messageTag(reaction: value)
var hasTitle = false
if let tag = savedMessageTags.tags.first(where: { $0.reaction == value }) {
if let title = tag.title, !title.isEmpty {
hasTitle = true
}
}
let optionTitle = hasTitle ? self.presentationData.strings.Chat_EditTagTitle_TitleEdit : self.presentationData.strings.Chat_EditTagTitle_TitleSet
items.append(.action(ContextMenuActionItem(text: optionTitle, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/TagEditName"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] c, a in
guard let self else {
a(.default)
return
}
c?.dismiss(completion: { [weak self] in
guard let self else {
return
}
let _ = (self.context.engine.stickers.savedMessageTagData()
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] savedMessageTags in
guard let self else {
return
}
let reaction = value
let promptController = savedTagNameAlertController(context: self.context, updatedPresentationData: nil, text: optionTitle, subtext: self.presentationData.strings.Chat_EditTagTitle_Text, value: savedMessageTags?.tags.first(where: { $0.reaction == reaction })?.title ?? "", reaction: reaction, file: reactionFile, characterLimit: 12, apply: { [weak self] value in
guard let self else {
return
}
if let value {
let _ = self.context.engine.stickers.setSavedMessageTagTitle(reaction: reaction, title: value.isEmpty ? nil : value).start()
}
})
self.interfaceInteraction?.presentController(promptController, nil)
})
})
})))
if case .pinnedMessages = self.subject {
} else {
if self.presentationInterfaceState.historyFilter?.customTag != tag {
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_ReactionContextMenu_FilterByTag, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/TagFilter"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
guard let self else {
a(.default)
return
}
self.chatDisplayNode.historyNode.frozenMessageForScrollingReset = message.id
self.interfaceInteraction?.updateHistoryFilter { _ in
return ChatPresentationInterfaceState.HistoryFilter(customTag: tag, isActive: true)
}
a(.default)
})))
}
}
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_ReactionContextMenu_RemoveTag, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/TagRemove"), color: theme.contextMenu.destructiveColor)
}, action: { [weak self] _, a in
a(.dismissWithoutContent)
guard let self else {
return
}
self.controllerInteraction?.updateMessageReaction(message, .reaction(value), true, nil)
})))
self.canReadHistory.set(false)
let controller = ContextController(presentationData: self.presentationData, source: .extracted(ChatMessageReactionContextExtractedContentSource(chatNode: self.chatDisplayNode, engine: self.context.engine, message: message, contentView: sourceView)), items: .single(ContextController.Items(content: .list(items))), recognizer: nil, gesture: gesture)
controller.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
self.forEachController({ controller in
if let controller = controller as? TooltipScreen {
controller.dismiss()
}
return true
})
self.window?.presentInGlobalOverlay(controller)
})
} else {
if case .stars = value {
gesture?.cancel()
cancelParentGestures(view: sourceView)
self.openMessageSendStarsScreen(message: message)
return
}
var customFileIds: [Int64] = []
if case let .custom(fileId) = value {
customFileIds.append(fileId)
}
let _ = (combineLatest(
self.context.engine.stickers.availableReactions(),
self.context.engine.stickers.resolveInlineStickers(fileIds: customFileIds)
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] availableReactions, customEmoji in
guard let self else {
return
}
var dismissController: ((@escaping () -> Void) -> Void)?
var items: ContextController.Items
if canViewMessageReactionList(message: message) {
items = ContextController.Items(content: .custom(ReactionListContextMenuContent(
context: self.context,
displayReadTimestamps: true,
availableReactions: availableReactions,
animationCache: self.controllerInteraction!.presentationContext.animationCache,
animationRenderer: self.controllerInteraction!.presentationContext.animationRenderer,
message: EngineMessage(message),
reaction: value, readStats: nil, back: nil, openPeer: { peer, hasReaction in
dismissController?({ [weak self] in
guard let self else {
return
}
self.openPeer(peer: peer, navigation: .default, fromMessage: MessageReference(message), fromReactionMessageId: hasReaction ? message.id : nil)
})
}
)))
} else {
items = ContextController.Items(content: .list([]))
}
var packReferences: [StickerPackReference] = []
var existingIds = Set<Int64>()
for (_, file) in customEmoji {
loop: for attribute in file.attributes {
if case let .CustomEmoji(_, _, _, packReference) = attribute, let packReference = packReference {
if case let .id(id, _) = packReference, !existingIds.contains(id) {
packReferences.append(packReference)
existingIds.insert(id)
}
break loop
}
}
}
self.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts()
let context = self.context
let presentationData = self.presentationData
let action = { [weak self] in
guard let packReference = packReferences.first, let self else {
return
}
self.chatDisplayNode.dismissTextInput()
let presentationData = self.presentationData
let controller = StickerPackScreen(context: context, updatedPresentationData: self.updatedPresentationData, mainStickerPack: packReference, stickerPacks: Array(packReferences), parentNavigationController: self.effectiveNavigationController, actionPerformed: { [weak self] actions in
guard let self else {
return
}
if actions.count > 1, let first = actions.first {
if case .add = first.2 {
self.presentInGlobalOverlay(UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.EmojiPackActionInfo_AddedTitle, text: presentationData.strings.EmojiPackActionInfo_MultipleAddedText(Int32(actions.count)), undo: false, info: first.0, topItem: first.1.first, context: context), elevatedLayout: true, animateInAsReplacement: false, action: { _ in
return true
}))
} else if actions.allSatisfy({
if case .remove = $0.2 {
return true
} else {
return false
}
}) {
let isEmoji = actions[0].0.id.namespace == Namespaces.ItemCollection.CloudEmojiPacks
self.presentInGlobalOverlay(UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: isEmoji ? presentationData.strings.EmojiPackActionInfo_RemovedTitle : presentationData.strings.StickerPackActionInfo_RemovedTitle, text: isEmoji ? presentationData.strings.EmojiPackActionInfo_MultipleRemovedText(Int32(actions.count)) : presentationData.strings.StickerPackActionInfo_MultipleRemovedText(Int32(actions.count)), undo: true, info: actions[0].0, topItem: actions[0].1.first, context: context), elevatedLayout: true, animateInAsReplacement: false, action: { action in
if case .undo = action {
var itemsAndIndices: [(StickerPackCollectionInfo, [StickerPackItem], Int)] = actions.compactMap { action -> (StickerPackCollectionInfo, [StickerPackItem], Int)? in
if case let .remove(index) = action.2 {
return (action.0, action.1, index)
} else {
return nil
}
}
itemsAndIndices.sort(by: { $0.2 < $1.2 })
for (info, items, index) in itemsAndIndices.reversed() {
let _ = context.engine.stickers.addStickerPackInteractively(info: info, items: items, positionInList: index).startStandalone()
}
}
return true
}))
}
} else if let (info, items, action) = actions.first {
let isEmoji = info.id.namespace == Namespaces.ItemCollection.CloudEmojiPacks
switch action {
case .add:
self.presentInGlobalOverlay(UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: isEmoji ? presentationData.strings.EmojiPackActionInfo_AddedTitle : presentationData.strings.StickerPackActionInfo_AddedTitle, text: isEmoji ? presentationData.strings.EmojiPackActionInfo_AddedText(info.title).string : presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: context), elevatedLayout: true, animateInAsReplacement: false, action: { _ in
return true
}))
case let .remove(positionInList):
self.presentInGlobalOverlay(UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: isEmoji ? presentationData.strings.EmojiPackActionInfo_RemovedTitle : presentationData.strings.StickerPackActionInfo_RemovedTitle, text: isEmoji ? presentationData.strings.EmojiPackActionInfo_RemovedText(info.title).string : presentationData.strings.StickerPackActionInfo_RemovedText(info.title).string, undo: true, info: info, topItem: items.first, context: context), elevatedLayout: true, animateInAsReplacement: false, action: { action in
if case .undo = action {
let _ = context.engine.stickers.addStickerPackInteractively(info: info, items: items, positionInList: positionInList).startStandalone()
}
return true
}))
}
}
})
self.present(controller, in: .window(.root))
}
let presentationContext = self.controllerInteraction?.presentationContext
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
if !packReferences.isEmpty && !premiumConfiguration.isPremiumDisabled {
items.tip = .animatedEmoji(text: nil, arguments: nil, file: nil, action: nil)
if packReferences.count > 1 {
items.tip = .animatedEmoji(text: presentationData.strings.ChatContextMenu_EmojiSet(Int32(packReferences.count)), arguments: nil, file: nil, action: action)
} else if let reference = packReferences.first {
var tipSignal: Signal<LoadedStickerPack, NoError>
tipSignal = context.engine.stickers.loadedStickerPack(reference: reference, forceActualized: false)
items.tipSignal = tipSignal
|> filter { result in
if case .result = result {
return true
} else {
return false
}
}
|> mapToSignal { result -> Signal<ContextController.Tip?, NoError> in
if case let .result(info, items, _) = result, let presentationContext = presentationContext {
let tip: ContextController.Tip = .animatedEmoji(
text: presentationData.strings.ChatContextMenu_SingleReactionEmojiSet(info.title).string,
arguments: TextNodeWithEntities.Arguments(
context: context,
cache: presentationContext.animationCache,
renderer: presentationContext.animationRenderer,
placeholderColor: .clear,
attemptSynchronous: true
),
file: items.first?.file._parse(),
action: action)
return .single(tip)
} else {
return .complete()
}
}
}
}
let reactionFile: TelegramMediaFile?
switch value {
case .builtin, .stars:
reactionFile = availableReactions?.reactions.first(where: { $0.value == value })?.selectAnimation._parse()
case let .custom(fileId):
reactionFile = customEmoji[fileId]
}
items.context = self.context
items.previewReaction = reactionFile
self.canReadHistory.set(false)
let controller = ContextController(presentationData: self.presentationData, source: .extracted(ChatMessageReactionContextExtractedContentSource(chatNode: self.chatDisplayNode, engine: self.context.engine, message: message, contentView: sourceView)), items: .single(items), recognizer: nil, gesture: gesture)
controller.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
dismissController = { [weak controller] completion in
controller?.dismiss(completion: {
completion()
})
}
self.forEachController({ controller in
if let controller = controller as? TooltipScreen {
controller.dismiss()
}
return true
})
self.window?.presentInGlobalOverlay(controller)
})
}
}
func openMessageSendStarsScreen(message: Message) {
if let current = self.currentSendStarsUndoController {
self.currentSendStarsUndoController = nil
current.dismiss()
}
self.context.engine.messages.forceSendPendingSendStarsReaction(id: message.id)
guard let peerId = self.chatLocation.peerId else {
return
}
let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.ReactionSettings(id: peerId))
|> deliverOnMainQueue).startStandalone(next: { [weak self] reactionSettings in
guard let self else {
return
}
let reactionsAttribute = mergedMessageReactions(attributes: message.attributes, isTags: false)
let _ = (ChatSendStarsScreen.initialData(context: self.context, peerId: message.id.peerId, reactSubject: .message(message.id), topPeers: reactionsAttribute?.topPeers ?? [], completion: { [weak self] amount, privacy, isBecomingTop, transitionOut in
guard let self, amount > 0 else {
return
}
if case let .known(reactionSettings) = reactionSettings, let starsAllowed = reactionSettings.starsAllowed, !starsAllowed {
if let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer {
self.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: self.presentationData), title: nil, text: self.presentationData.strings.Chat_ToastStarsReactionsDisabled(peer.debugDisplayTitle).string, actions: [
TextAlertAction(type: .genericAction, title: self.presentationData.strings.Common_OK, action: {})
]), in: .window(.root))
}
return
}
var sourceItemNode: ChatMessageItemView?
self.chatDisplayNode.historyNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ChatMessageItemView {
if itemNode.item?.message.id == message.id {
sourceItemNode = itemNode
return
}
}
}
if let itemNode = sourceItemNode, let item = itemNode.item, let availableReactions = item.associatedData.availableReactions, let targetView = itemNode.targetReactionView(value: .stars) {
var reactionItem: ReactionItem?
for reaction in availableReactions.reactions {
guard let centerAnimation = reaction.centerAnimation else {
continue
}
guard let aroundAnimation = reaction.aroundAnimation else {
continue
}
if reaction.value == .stars {
reactionItem = ReactionItem(
reaction: ReactionItem.Reaction(rawValue: reaction.value),
appearAnimation: reaction.appearAnimation,
stillAnimation: reaction.selectAnimation,
listAnimation: centerAnimation,
largeListAnimation: reaction.activateAnimation,
applicationAnimation: aroundAnimation,
largeApplicationAnimation: reaction.effectAnimation,
isCustom: false
)
break
}
}
if let reactionItem {
let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: self.chatDisplayNode.historyNode.takeGenericReactionEffect())
self.chatDisplayNode.messageTransitionNode.addMessageStandaloneReactionAnimation(messageId: item.message.id, standaloneReactionAnimation: standaloneReactionAnimation)
self.view.window?.addSubview(standaloneReactionAnimation.view)
standaloneReactionAnimation.frame = self.chatDisplayNode.bounds
standaloneReactionAnimation.animateOutToReaction(
context: self.context,
theme: self.presentationData.theme,
item: reactionItem,
value: .stars,
sourceView: transitionOut.sourceView,
targetView: targetView,
hideNode: false,
forceSwitchToInlineImmediately: false,
animateTargetContainer: nil,
addStandaloneReactionAnimation: { [weak self] standaloneReactionAnimation in
guard let self else {
return
}
self.chatDisplayNode.messageTransitionNode.addMessageStandaloneReactionAnimation(messageId: item.message.id, standaloneReactionAnimation: standaloneReactionAnimation)
standaloneReactionAnimation.frame = self.chatDisplayNode.bounds
self.chatDisplayNode.addSubnode(standaloneReactionAnimation)
},
onHit: { [weak self, weak itemNode] in
guard let self else {
return
}
if isBecomingTop {
self.chatDisplayNode.playConfettiAnimation()
}
if let itemNode, let targetView = itemNode.targetReactionView(value: .stars), self.context.sharedContext.energyUsageSettings.fullTranslucency {
self.chatDisplayNode.wrappingNode.triggerRipple(at: targetView.convert(targetView.bounds.center, to: self.chatDisplayNode.view))
}
},
completion: { [weak standaloneReactionAnimation] in
standaloneReactionAnimation?.view.removeFromSuperview()
}
)
}
}
let _ = self.context.engine.messages.sendStarsReaction(id: message.id, count: Int(amount), privacy: privacy).startStandalone()
self.displayOrUpdateSendStarsUndo(messageId: message.id, count: Int(amount), privacy: privacy)
})
|> deliverOnMainQueue).start(next: { [weak self] initialData in
guard let self, let initialData else {
return
}
HapticFeedback().tap()
self.push(ChatSendStarsScreen(context: self.context, initialData: initialData))
})
})
}
func displayOrUpdateSendStarsUndo(messageId: EngineMessage.Id, count: Int, privacy: TelegramPaidReactionPrivacy) {
var privacyPeer: Signal<EnginePeer?, NoError> = .single(nil)
if case let .peer(id) = privacy {
privacyPeer = self.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: id)
)
}
let _ = (privacyPeer
|> deliverOnMainQueue).startStandalone(next: { [weak self] privacyPeer in
guard let self else {
return
}
if self.currentSendStarsUndoMessageId != messageId {
if let current = self.currentSendStarsUndoController {
self.currentSendStarsUndoController = nil
current.dismiss()
}
}
if let _ = self.currentSendStarsUndoController {
self.currentSendStarsUndoCount += count
} else {
self.currentSendStarsUndoCount = count
}
let title: String
if case .anonymous = privacy {
title = self.presentationData.strings.Chat_ToastStarsSent_AnonymousTitle(Int32(self.currentSendStarsUndoCount))
} else if case .peer = privacy, let privacyPeer {
let rawTitle = self.presentationData.strings.Chat_ToastStarsSent_TitleChannel(Int32(self.currentSendStarsUndoCount))
title = rawTitle.replacingOccurrences(of: "{name}", with: privacyPeer.compactDisplayTitle)
} else {
title = self.presentationData.strings.Chat_ToastStarsSent_Title(Int32(self.currentSendStarsUndoCount))
}
let textItems = AnimatedTextComponent.extractAnimatedTextString(string: self.presentationData.strings.Chat_ToastStarsSent_Text("", ""), id: "text", mapping: [
0: .number(self.currentSendStarsUndoCount, minDigits: 1),
1: .text(self.presentationData.strings.Chat_ToastStarsSent_TextStarAmount(Int32(self.currentSendStarsUndoCount)))
])
self.currentSendStarsUndoMessageId = messageId
if let current = self.currentSendStarsUndoController {
current.content = .starsSent(context: self.context, title: title, text: textItems, hasUndo: true)
} else {
let controller = UndoOverlayController(presentationData: self.presentationData, content: .starsSent(context: self.context, title: title, text: textItems, hasUndo: true), elevatedLayout: false, position: .top, action: { [weak self] action in
guard let self else {
return false
}
if case .undo = action {
self.context.engine.messages.cancelPendingSendStarsReaction(id: messageId)
}
return false
})
self.currentSendStarsUndoController = controller
self.present(controller, in: .current)
}
})
}
}
@@ -0,0 +1,110 @@
import Foundation
import TelegramPresentationData
import AccountContext
import Postbox
import TelegramCore
import SwiftSignalKit
import PresentationDataUtils
import Display
extension ChatControllerImpl {
func openMessageReplies(messageId: MessageId, displayProgressInMessage: MessageId?, isChannelPost: Bool, atMessage atMessageId: MessageId?, displayModalProgress: Bool) {
guard let navigationController = self.effectiveNavigationController else {
return
}
if let displayProgressInMessage = displayProgressInMessage, self.controllerInteraction?.currentMessageWithLoadingReplyThread == displayProgressInMessage {
return
}
let _ = self.presentVoiceMessageDiscardAlert(action: {
let progressSignal: Signal<Never, NoError> = Signal { [weak self] _ in
guard let strongSelf = self, let controllerInteraction = strongSelf.controllerInteraction else {
return EmptyDisposable
}
if let displayProgressInMessage = displayProgressInMessage, controllerInteraction.currentMessageWithLoadingReplyThread != displayProgressInMessage {
let previousId = controllerInteraction.currentMessageWithLoadingReplyThread
controllerInteraction.currentMessageWithLoadingReplyThread = displayProgressInMessage
strongSelf.chatDisplayNode.historyNode.requestMessageUpdate(displayProgressInMessage)
if let previousId = previousId {
strongSelf.chatDisplayNode.historyNode.requestMessageUpdate(previousId)
}
}
return ActionDisposable {
Queue.mainQueue().async {
guard let strongSelf = self, let controllerInteraction = strongSelf.controllerInteraction else {
return
}
if let displayProgressInMessage = displayProgressInMessage, controllerInteraction.currentMessageWithLoadingReplyThread == displayProgressInMessage {
controllerInteraction.currentMessageWithLoadingReplyThread = nil
strongSelf.chatDisplayNode.historyNode.requestMessageUpdate(displayProgressInMessage)
}
}
}
}
|> runOn(.mainQueue())
let progress = (progressSignal
|> delay(0.15, queue: .mainQueue())).startStrict()
self.navigationActionDisposable.set((ChatControllerImpl.openMessageReplies(context: self.context, updatedPresentationData: self.updatedPresentationData, navigationController: navigationController, present: { [weak self] c, a in
self?.present(c, in: .window(.root), with: a)
}, messageId: messageId, isChannelPost: isChannelPost, atMessage: atMessageId, displayModalProgress: displayModalProgress)
|> afterDisposed {
progress.dispose()
}).startStrict())
})
}
static func openMessageReplies(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil, navigationController: NavigationController, present: @escaping (ViewController, Any?) -> Void, messageId: MessageId, isChannelPost: Bool, atMessage atMessageId: MessageId?, displayModalProgress: Bool) -> Signal<Never, NoError> {
return Signal { subscriber in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
var cancelImpl: (() -> Void)?
let statusController = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
cancelImpl?()
}))
if displayModalProgress {
present(statusController, nil)
}
let disposable = (fetchAndPreloadReplyThreadInfo(context: context, subject: isChannelPost ? .channelPost(messageId) : .groupMessage(messageId), atMessageId: atMessageId, preload: true)
|> deliverOnMainQueue).startStrict(next: { [weak statusController] result in
if displayModalProgress {
statusController?.dismiss()
}
let chatLocation: NavigateToChatControllerParams.Location = .replyThread(result.message)
let subject: ChatControllerSubject?
if let atMessageId = atMessageId {
subject = .message(id: .id(atMessageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false)
} else if let index = result.scrollToLowerBoundMessage {
subject = .message(id: .id(index.id), highlight: nil, timecode: nil, setupReply: false)
} else {
subject = nil
}
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: chatLocation, chatLocationContextHolder: result.contextHolder, subject: subject, activateInput: result.isEmpty ? .text : nil, keepStack: .always))
subscriber.putCompletion()
}, error: { _ in
let presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 }
present(textAlertController(context: context, updatedPresentationData: updatedPresentationData, title: nil, text: presentationData.strings.Channel_DiscussionMessageUnavailable, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), nil)
})
cancelImpl = { [weak statusController] in
disposable.dispose()
statusController?.dismiss()
subscriber.putCompletion()
}
return ActionDisposable {
cancelImpl?()
}
}
|> runOn(.mainQueue())
}
}
@@ -0,0 +1,233 @@
import Foundation
import TelegramPresentationData
import AccountContext
import Postbox
import TelegramCore
import SwiftSignalKit
import ContextUI
import ChatControllerInteraction
import Display
import UIKit
import UndoUI
import ShareController
import ChatShareMessageTagView
import ReactionSelectionNode
import TopMessageReactions
func chatShareToSavedMessagesAdditionalView(_ chatController: ChatControllerImpl, reactionItems: [ReactionItem], correlationIds: [Int64]) -> (() -> UndoOverlayControllerAdditionalView?)? {
if !chatController.presentationInterfaceState.isPremium {
return nil
}
if correlationIds.count < 1 {
return nil
}
return { [weak chatController] () -> UndoOverlayControllerAdditionalView? in
guard let chatController else {
return nil
}
return ChatShareMessageTagView(context: chatController.context, presentationData: chatController.presentationData, isSingleMessage: correlationIds.count == 1, reactionItems: reactionItems, completion: { [weak chatController] file, updateReaction in
guard let chatController else {
return
}
let _ = (chatController.context.account.postbox.aroundMessageHistoryViewForLocation(.peer(peerId: chatController.context.account.peerId, threadId: nil), anchor: .upperBound, ignoreMessagesInTimestampRange: nil, ignoreMessageIds: Set(), count: 45, fixedCombinedReadStates: nil, topTaggedMessageIdNamespaces: Set(), tag: nil, appendMessagesFromTheSameGroup: false, namespaces: .not(Namespaces.Message.allNonRegular), orderStatistics: [])
|> map { view, _, _ -> [EngineMessage.Id] in
let messageIds = correlationIds.compactMap { correlationId in
return chatController.context.engine.messages.synchronouslyLookupCorrelationId(correlationId: correlationId)
}
if messageIds.isEmpty {
return []
}
let exactResult = view.entries.compactMap { entry -> EngineMessage.Id? in
if messageIds.contains(entry.message.id) {
return entry.message.id
} else {
return nil
}
}
if !exactResult.isEmpty {
return exactResult
}
return []
}
|> filter { !$0.isEmpty }
|> take(1)
|> timeout(5.0, queue: .mainQueue(), alternate: .single([]))
|> deliverOnMainQueue).start(next: { [weak chatController] messageIds in
guard let chatController else {
return
}
if !messageIds.isEmpty {
let _ = chatController.context.engine.messages.setMessageReactions(ids: messageIds, reactions: [updateReaction])
var isBuiltinReaction = false
if case .builtin = updateReaction {
isBuiltinReaction = true
}
let presentationData = chatController.context.sharedContext.currentPresentationData.with { $0 }
chatController.present(UndoOverlayController(presentationData: presentationData, content: .messageTagged(context: chatController.context, isSingleMessage: messageIds.count == 1, customEmoji: file, isBuiltinReaction: isBuiltinReaction, customUndoText: presentationData.strings.Chat_ToastMessageTagged_Action), elevatedLayout: false, position: .top, animateInAsReplacement: false, action: { [weak chatController] action in
if (action == .info || action == .undo), let chatController {
let _ = (chatController.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: chatController.context.account.peerId))
|> deliverOnMainQueue).start(next: { [weak chatController] peer in
guard let chatController else {
return
}
guard let peer else {
return
}
guard let navigationController = chatController.navigationController as? NavigationController else {
return
}
chatController.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: chatController.context, chatLocation: .peer(peer), forceOpenChat: true))
})
return false
}
return false
}), in: .current)
}
})
})
}
}
extension ChatControllerImpl {
func openMessageShareMenu(id: EngineMessage.Id) {
guard let messages = self.chatDisplayNode.historyNode.messageGroupInCurrentHistoryView(id), let message = messages.first else {
return
}
let chatPresentationInterfaceState = self.presentationInterfaceState
var warnAboutPrivate = false
var canShareToStory = false
if case .peer = chatPresentationInterfaceState.chatLocation, let channel = message.peers[message.id.peerId] as? TelegramChannel {
if case .broadcast = channel.info {
canShareToStory = true
}
if channel.addressName == nil {
warnAboutPrivate = true
}
}
let shareController = ShareController(context: self.context, subject: .messages(messages), updatedPresentationData: self.updatedPresentationData, shareAsLink: true)
shareController.parentNavigationController = self.navigationController as? NavigationController
if let message = messages.first, message.media.contains(where: { media in
if media is TelegramMediaContact || media is TelegramMediaPoll || media is TelegramMediaTodo {
return true
} else if let file = media as? TelegramMediaFile, file.isSticker || file.isAnimatedSticker || file.isVideoSticker {
return true
} else {
return false
}
}) {
canShareToStory = false
}
if message.text.containsOnlyEmoji {
canShareToStory = false
}
if canShareToStory {
shareController.shareStory = { [weak self] in
guard let self else {
return
}
Queue.mainQueue().after(0.15) {
let controller = self.context.sharedContext.makeStorySharingScreen(context: self.context, subject: .messages(messages), parentController: self)
self.push(controller)
}
}
}
shareController.dismissed = { [weak self] shared in
if shared {
self?.commitPurposefulAction()
}
}
shareController.actionCompleted = { [weak self] in
guard let self else {
return
}
let content: UndoOverlayContent
if warnAboutPrivate {
content = .linkCopied(title: nil, text: self.presentationData.strings.Conversation_PrivateMessageLinkCopiedLong)
} else {
content = .linkCopied(title: nil, text: self.presentationData.strings.Conversation_LinkCopied)
}
self.present(UndoOverlayController(presentationData: self.presentationData, content: content, elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}
shareController.enqueued = { [weak self] peerIds, correlationIds in
guard let self else {
return
}
let _ = (self.context.engine.data.get(
EngineDataList(
peerIds.map(TelegramEngine.EngineData.Item.Peer.RenderedPeer.init)
)
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] peerList in
guard let self else {
return
}
let peers = peerList.compactMap { $0 }
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let text: String
var savedMessages = false
if peerIds.count == 1, let peerId = peerIds.first, peerId == self.context.account.peerId {
text = messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_SavedMessages_One : presentationData.strings.Conversation_ForwardTooltip_SavedMessages_Many
savedMessages = true
} else {
if peers.count == 1, let peer = peers.first?.chatOrMonoforumMainPeer {
var peerName = peer.id == self.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
peerName = peerName.replacingOccurrences(of: "**", with: "")
text = messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_Chat_One(peerName).string : presentationData.strings.Conversation_ForwardTooltip_Chat_Many(peerName).string
} else if peers.count == 2, let firstPeer = peers.first?.chatOrMonoforumMainPeer, let secondPeer = peers.last?.chatOrMonoforumMainPeer {
var firstPeerName = firstPeer.id == self.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
firstPeerName = firstPeerName.replacingOccurrences(of: "**", with: "")
var secondPeerName = secondPeer.id == self.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
secondPeerName = secondPeerName.replacingOccurrences(of: "**", with: "")
text = messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_TwoChats_One(firstPeerName, secondPeerName).string : presentationData.strings.Conversation_ForwardTooltip_TwoChats_Many(firstPeerName, secondPeerName).string
} else if let peer = peers.first?.chatOrMonoforumMainPeer {
var peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
peerName = peerName.replacingOccurrences(of: "**", with: "")
text = messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_ManyChats_One(peerName, "\(peers.count - 1)").string : presentationData.strings.Conversation_ForwardTooltip_ManyChats_Many(peerName, "\(peers.count - 1)").string
} else {
text = ""
}
}
let reactionItems: Signal<[ReactionItem], NoError>
if savedMessages {
reactionItems = tagMessageReactions(context: self.context, subPeerId: self.chatLocation.threadId.flatMap(EnginePeer.Id.init))
} else {
reactionItems = .single([])
}
let _ = (reactionItems
|> deliverOnMainQueue).startStandalone(next: { [weak self] reactionItems in
guard let self else {
return
}
self.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: savedMessages, text: text), elevatedLayout: false, position: savedMessages ? .top : .bottom, animateInAsReplacement: !savedMessages, action: { [weak self] action in
if savedMessages, let self, action == .info {
let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId))
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let self, let peer else {
return
}
guard let navigationController = self.navigationController as? NavigationController else {
return
}
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), forceOpenChat: true))
})
}
return false
}, additionalView: savedMessages ? chatShareToSavedMessagesAdditionalView(self, reactionItems: reactionItems, correlationIds: correlationIds) : nil), in: .current)
})
})
}
self.chatDisplayNode.dismissInput()
self.present(shareController, in: .window(.root), blockInteraction: true)
}
}
@@ -0,0 +1,25 @@
import Foundation
import TelegramPresentationData
import AccountContext
import Postbox
import TelegramCore
import SwiftSignalKit
import Display
import TelegramPresentationData
import PresentationDataUtils
import ChatMessageItemView
public extension ChatControllerImpl {
func removeAd(opaqueId: Data) {
var foundItemNode: ChatMessageItemView?
self.chatDisplayNode.historyNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ChatMessageItemView, let item = itemNode.item, let adAttribute = item.message.adAttribute, adAttribute.opaqueId == opaqueId {
foundItemNode = itemNode
}
}
if let foundItemNode, let message = foundItemNode.item?.message {
self.chatDisplayNode.historyNode.setCurrentDeleteAnimationCorrelationIds(Set([message.stableId]))
}
self.chatDisplayNode.adMessagesContext?.remove(opaqueId: opaqueId)
}
}
@@ -0,0 +1,140 @@
import Foundation
import TelegramPresentationData
import AccountContext
import Display
import SwiftSignalKit
import TelegramCore
import Postbox
import UIKit
import OverlayStatusController
import PresentationDataUtils
extension ChatControllerImpl {
func scrollToEndOfHistory() {
let locationInput = ChatHistoryLocationInput(content: .Scroll(subject: MessageHistoryScrollToSubject(index: .upperBound, quote: nil), anchorIndex: .upperBound, sourceIndex: .lowerBound, scrollPosition: .top(0.0), animated: true, highlight: false, setupReply: false), id: 0)
let historyView = preloadedChatHistoryViewForLocation(locationInput, context: self.context, chatLocation: self.chatLocation, subject: self.subject, chatLocationContextHolder: self.chatLocationContextHolder, fixedCombinedReadStates: nil, tag: nil, additionalData: [])
let signal = historyView
|> mapToSignal { historyView -> Signal<(MessageIndex?, Bool), NoError> in
switch historyView {
case .Loading:
return .single((nil, true))
case .HistoryView:
return .single((nil, false))
}
}
|> take(until: { index in
return SignalTakeAction(passthrough: true, complete: !index.1)
})
var cancelImpl: (() -> Void)?
let presentationData = self.presentationData
let displayTime = CACurrentMediaTime()
let progressSignal = Signal<Never, NoError> { [weak self] subscriber in
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
if CACurrentMediaTime() - displayTime > 1.5 {
cancelImpl?()
}
}))
self?.present(controller, in: .window(.root))
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
}
|> runOn(Queue.mainQueue())
|> delay(0.05, queue: Queue.mainQueue())
let progressDisposable = MetaDisposable()
var progressStarted = false
self.messageIndexDisposable.set((signal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
|> deliverOnMainQueue).startStrict(next: { index in
if index.1 {
if !progressStarted {
progressStarted = true
progressDisposable.set(progressSignal.startStrict())
}
}
}, completed: { [weak self] in
if let strongSelf = self {
strongSelf.loadingMessage.set(.single(nil))
strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory()
}
}))
cancelImpl = { [weak self] in
if let strongSelf = self {
strongSelf.loadingMessage.set(.single(nil))
strongSelf.messageIndexDisposable.set(nil)
}
}
}
func scrollToStartOfHistory() {
let locationInput = ChatHistoryLocationInput(content: .Scroll(subject: MessageHistoryScrollToSubject(index: .lowerBound, quote: nil), anchorIndex: .lowerBound, sourceIndex: .upperBound, scrollPosition: .bottom(0.0), animated: true, highlight: false, setupReply: false), id: 0)
let historyView = preloadedChatHistoryViewForLocation(locationInput, context: self.context, chatLocation: self.chatLocation, subject: self.subject, chatLocationContextHolder: self.chatLocationContextHolder, fixedCombinedReadStates: nil, tag: nil, additionalData: [])
let signal = historyView
|> mapToSignal { historyView -> Signal<(MessageIndex?, Bool), NoError> in
switch historyView {
case .Loading:
return .single((nil, true))
case .HistoryView:
return .single((nil, false))
}
}
|> take(until: { index in
return SignalTakeAction(passthrough: true, complete: !index.1)
})
var cancelImpl: (() -> Void)?
let presentationData = self.presentationData
let displayTime = CACurrentMediaTime()
let progressSignal = Signal<Never, NoError> { [weak self] subscriber in
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
if CACurrentMediaTime() - displayTime > 1.5 {
cancelImpl?()
}
}))
self?.present(controller, in: .window(.root))
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
}
|> runOn(Queue.mainQueue())
|> delay(0.05, queue: Queue.mainQueue())
let progressDisposable = MetaDisposable()
var progressStarted = false
self.messageIndexDisposable.set((signal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
|> deliverOnMainQueue).startStrict(next: { index in
if index.1 {
if !progressStarted {
progressStarted = true
progressDisposable.set(progressSignal.startStrict())
}
}
}, completed: { [weak self] in
if let strongSelf = self {
strongSelf.loadingMessage.set(.single(nil))
strongSelf.chatDisplayNode.historyNode.scrollToStartOfHistory()
}
}))
cancelImpl = { [weak self] in
if let strongSelf = self {
strongSelf.loadingMessage.set(.single(nil))
strongSelf.messageIndexDisposable.set(nil)
}
}
}
}
@@ -0,0 +1,20 @@
import Foundation
import UIKit
import AsyncDisplayKit
final class ChatControllerTitlePanelNodeContainer: ASDisplayNode {
var hitTestExcludeInsets = UIEdgeInsets()
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if point.x < self.hitTestExcludeInsets.left {
return nil
}
for subview in self.view.subviews {
if let result = subview.hitTest(self.view.convert(point, to: subview), with: event) {
return result
}
}
return nil
}
}
@@ -0,0 +1,190 @@
import Foundation
import TelegramPresentationData
import AccountContext
import ChatPresentationInterfaceState
import SwiftSignalKit
import Postbox
import TelegramCore
extension ChatControllerImpl {
func updateSearch(_ interfaceState: ChatPresentationInterfaceState) -> ChatPresentationInterfaceState? {
guard let peerId = self.chatLocation.peerId else {
return nil
}
let limit: Int32 = 100
var derivedSearchState: ChatSearchState?
if let search = interfaceState.search {
func loadMoreStateFromResultsState(_ resultsState: ChatSearchResultsState?) -> SearchMessagesState? {
guard let resultsState = resultsState, let currentId = resultsState.currentId else {
return nil
}
if let index = resultsState.messageIndices.firstIndex(where: { $0.id == currentId }) {
if index <= limit / 2 {
return resultsState.state
}
}
return nil
}
var threadId: Int64?
switch self.chatLocation {
case .peer:
break
case let .replyThread(replyThreadMessage):
threadId = replyThreadMessage.threadId
case .customChatContents:
break
}
var reactions: [MessageReaction.Reaction]?
if !search.query.isEmpty, let historyFilter = interfaceState.historyFilter {
reactions = ReactionsMessageAttribute.reactionFromMessageTag(tag: historyFilter.customTag).flatMap {
[$0]
}
}
switch search.domain {
case .everything:
derivedSearchState = ChatSearchState(query: search.query, location: .peer(peerId: peerId, fromId: nil, tags: nil, reactions: reactions, threadId: threadId, minDate: nil, maxDate: nil), loadMoreState: loadMoreStateFromResultsState(search.resultsState))
case let .tag(reaction):
derivedSearchState = ChatSearchState(query: search.query, location: .peer(peerId: peerId, fromId: nil, tags: nil, reactions: reactions ?? [reaction], threadId: threadId, minDate: nil, maxDate: nil), loadMoreState: loadMoreStateFromResultsState(search.resultsState))
case .members:
derivedSearchState = nil
case let .member(peer):
derivedSearchState = ChatSearchState(query: search.query, location: .peer(peerId: peerId, fromId: peer.id, tags: nil, reactions: reactions, threadId: threadId, minDate: nil, maxDate: nil), loadMoreState: loadMoreStateFromResultsState(search.resultsState))
}
}
if derivedSearchState != self.searchState {
let previousSearchState = self.searchState
self.searchState = derivedSearchState
if let searchState = derivedSearchState {
if previousSearchState?.query != searchState.query || previousSearchState?.location != searchState.location {
var queryIsEmpty = false
if searchState.query.isEmpty {
if case let .peer(_, fromId, _, reactions, _, _, _) = searchState.location {
if fromId == nil {
queryIsEmpty = true
}
if let reactions, !reactions.isEmpty {
queryIsEmpty = false
}
} else {
queryIsEmpty = true
}
}
if queryIsEmpty {
self.searching.set(false)
self.searchResultsCount.set(0)
self.searchDisposable?.set(nil)
self.searchResult.set(.single(nil))
if let data = interfaceState.search {
return interfaceState.updatedSearch(data.withUpdatedResultsState(nil))
}
} else {
self.searching.set(true)
let searchDisposable: MetaDisposable
if let current = self.searchDisposable {
searchDisposable = current
} else {
searchDisposable = MetaDisposable()
self.searchDisposable = searchDisposable
}
let search = self.context.engine.messages.searchMessages(location: searchState.location, query: searchState.query, state: nil, limit: limit)
|> delay(0.2, queue: Queue.mainQueue())
self.searchResult.set(search
|> map { (result, state) -> (SearchMessagesResult, SearchMessagesState, SearchMessagesLocation)? in
return (result, state, searchState.location)
})
searchDisposable.set((search
|> deliverOnMainQueue).startStrict(next: { [weak self] results, updatedState in
guard let strongSelf = self else {
return
}
strongSelf.searchResultsCount.set(results.totalCount)
var navigateIndex: MessageIndex?
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { current in
if let data = current.search {
let messageIndices = results.messages.map({ $0.index }).sorted()
var currentIndex = messageIndices.last
if let previousResultId = data.resultsState?.currentId {
for index in messageIndices {
if index.id >= previousResultId {
currentIndex = index
break
}
}
}
navigateIndex = currentIndex
return current.updatedSearch(data.withUpdatedResultsState(ChatSearchResultsState(messageIndices: messageIndices, currentId: currentIndex?.id, state: updatedState, totalCount: results.totalCount, completed: results.completed)))
} else {
return current
}
})
if let navigateIndex = navigateIndex {
switch strongSelf.chatLocation {
case .peer, .replyThread, .customChatContents:
strongSelf.navigateToMessage(from: nil, to: .index(navigateIndex), forceInCurrentChat: true)
}
}
strongSelf.updateItemNodesSearchTextHighlightStates()
}, completed: { [weak self] in
if let strongSelf = self {
strongSelf.searching.set(false)
}
}))
}
} else if previousSearchState?.loadMoreState != searchState.loadMoreState {
if let loadMoreState = searchState.loadMoreState {
self.searching.set(true)
let searchDisposable: MetaDisposable
if let current = self.searchDisposable {
searchDisposable = current
} else {
searchDisposable = MetaDisposable()
self.searchDisposable = searchDisposable
}
searchDisposable.set((self.context.engine.messages.searchMessages(location: searchState.location, query: searchState.query, state: loadMoreState, limit: limit)
|> delay(0.2, queue: Queue.mainQueue())
|> deliverOnMainQueue).startStrict(next: { [weak self] results, updatedState in
guard let strongSelf = self else {
return
}
strongSelf.searchResultsCount.set(results.totalCount)
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { current in
if let data = current.search, let previousResultsState = data.resultsState {
let messageIndices = results.messages.map({ $0.index }).sorted()
return current.updatedSearch(data.withUpdatedResultsState(ChatSearchResultsState(messageIndices: messageIndices, currentId: previousResultsState.currentId, state: updatedState, totalCount: results.totalCount, completed: results.completed)))
} else {
return current
}
})
}, completed: { [weak self] in
if let strongSelf = self {
strongSelf.searching.set(false)
}
}))
} else {
self.searching.set(false)
self.searchResultsCount.set(0)
self.searchDisposable?.set(nil)
}
}
} else {
self.searching.set(false)
self.searchResultsCount.set(0)
self.searchDisposable?.set(nil)
if let data = interfaceState.search {
return interfaceState.updatedSearch(data.withUpdatedResultsState(nil))
}
}
}
self.updateItemNodesSearchTextHighlightStates()
return nil
}
}
@@ -0,0 +1,186 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import TelegramStringFormatting
import TextFormat
import ChatPresentationInterfaceState
import TextNodeWithEntities
import ChatControllerInteraction
final class ChatFeePanelNode: ASDisplayNode {
private let context: AccountContext
var controllerInteraction: ChatControllerInteraction?
private let contextContainer: ContextControllerSourceNode
private let clippingContainer: ASDisplayNode
private let contentContainer: ASDisplayNode
private let textContainer: ASDisplayNode
private let textNode: ImmediateTextNodeWithEntities
private let removeButtonNode: HighlightTrackingButtonNode
private let removeTextNode: ImmediateTextNode
private let separatorNode: ASDisplayNode
private var currentLayout: (CGFloat, CGFloat, CGFloat)?
init(context: AccountContext) {
self.context = context
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.contextContainer = ContextControllerSourceNode()
self.clippingContainer = ASDisplayNode()
self.clippingContainer.clipsToBounds = true
self.contentContainer = ASDisplayNode()
self.contextContainer.isGestureEnabled = false
self.textContainer = ASDisplayNode()
self.textNode = ImmediateTextNodeWithEntities()
self.textNode.anchorPoint = CGPoint()
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
self.textNode.maximumNumberOfLines = 2
self.textNode.textAlignment = .center
self.removeButtonNode = HighlightTrackingButtonNode()
self.removeTextNode = ImmediateTextNode()
self.removeTextNode.anchorPoint = CGPoint()
self.removeTextNode.displaysAsynchronously = false
self.removeTextNode.isUserInteractionEnabled = false
super.init()
self.addSubnode(self.contextContainer)
self.contextContainer.addSubnode(self.clippingContainer)
self.clippingContainer.addSubnode(self.contentContainer)
self.contextContainer.addSubnode(self.textContainer)
self.textContainer.addSubnode(self.textNode)
self.contextContainer.addSubnode(self.removeTextNode)
self.contextContainer.addSubnode(self.removeButtonNode)
self.addSubnode(self.separatorNode)
self.removeButtonNode.addTarget(self, action: #selector(self.removePressed), forControlEvents: [.touchUpInside])
self.removeButtonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.removeTextNode.layer.removeAnimation(forKey: "opacity")
strongSelf.removeTextNode.alpha = 0.4
} else {
strongSelf.removeTextNode.alpha = 1.0
strongSelf.removeTextNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
}
private var theme: PresentationTheme?
func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, leftDisplayInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> CGFloat {
if self.theme !== interfaceState.theme {
self.theme = interfaceState.theme
self.separatorNode.backgroundColor = interfaceState.theme.rootController.navigationBar.separatorColor
self.removeTextNode.attributedText = NSAttributedString(string: interfaceState.strings.Chat_PaidMessageFee_RemoveFee, font: Font.regular(17.0), textColor: interfaceState.theme.chat.inputPanel.panelControlAccentColor)
}
if let removePaidMessageFeeData = interfaceState.removePaidMessageFeeData {
let paidMessageStars = removePaidMessageFeeData.amount.value
let attributedText = NSMutableAttributedString(string: interfaceState.strings.Chat_PaidMessageFee_Text(removePaidMessageFeeData.peer.compactDisplayTitle, "⭐️\(paidMessageStars)").string, font: Font.regular(12.0), textColor: interfaceState.theme.rootController.navigationBar.secondaryTextColor)
let range = (attributedText.string as NSString).range(of: "⭐️")
if range.location != NSNotFound {
attributedText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: true)), range: range)
attributedText.addAttribute(.baselineOffset, value: 0.0, range: range)
}
self.textNode.attributedText = attributedText
self.textNode.visibility = true
self.textNode.arguments = TextNodeWithEntities.Arguments(
context: self.context,
cache: self.context.animationCache,
renderer: self.context.animationRenderer,
placeholderColor: UIColor(white: 1.0, alpha: 0.1),
attemptSynchronous: false
)
} else if let peer = interfaceState.renderedPeer?.peer.flatMap(EnginePeer.init) {
let paidMessageStars = interfaceState.contactStatus?.peerStatusSettings?.paidMessageStars?.value ?? 0
let attributedText = NSMutableAttributedString(string: interfaceState.strings.Chat_PaidMessageFee_Text(peer.compactDisplayTitle, "⭐️\(paidMessageStars)").string, font: Font.regular(12.0), textColor: interfaceState.theme.rootController.navigationBar.secondaryTextColor)
let range = (attributedText.string as NSString).range(of: "⭐️")
if range.location != NSNotFound {
attributedText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: true)), range: range)
attributedText.addAttribute(.baselineOffset, value: 0.0, range: range)
}
self.textNode.attributedText = attributedText
self.textNode.visibility = true
self.textNode.arguments = TextNodeWithEntities.Arguments(
context: self.context,
cache: self.context.animationCache,
renderer: self.context.animationRenderer,
placeholderColor: UIColor(white: 1.0, alpha: 0.1),
attemptSynchronous: false
)
}
let sideInset = 12.0
let textSize = self.textNode.updateLayout(CGSize(width: width - leftInset - rightInset - sideInset * 2.0, height: .greatestFiniteMagnitude))
let textFrame = CGRect(origin: CGPoint(x: leftInset + floorToScreenPixels((width - leftInset - rightInset - textSize.width) / 2.0), y: 9.0), size: textSize)
transition.updateFrame(node: self.textContainer, frame: textFrame)
if self.textNode.bounds.size.width != 0.0, transition.isAnimated {
if let snapshotLayer = self.textNode.layer.snapshotContentTree() {
self.textContainer.layer.addSublayer(snapshotLayer)
snapshotLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak snapshotLayer] _ in
snapshotLayer?.removeFromSuperlayer()
})
self.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
}
transition.updatePosition(node: self.textNode, position: CGPoint())
self.textNode.bounds = CGRect(origin: CGPoint(), size: textFrame.size)
let panelHeight: CGFloat = 48.0 + textSize.height
let removeSize = self.removeTextNode.updateLayout(CGSize(width: width - sideInset * 2.0, height: .greatestFiniteMagnitude))
let removeFrame = CGRect(origin: CGPoint(x: leftInset + floorToScreenPixels((width - leftInset - rightInset - removeSize.width) / 2.0), y: panelHeight - removeSize.height - 9.0), size: removeSize)
transition.updatePosition(node: self.removeTextNode, position: removeFrame.origin)
self.removeTextNode.bounds = CGRect(origin: CGPoint(), size: removeFrame.size)
transition.updateFrame(node: self.removeButtonNode, frame: removeFrame.insetBy(dx: -8.0, dy: -4.0))
self.contextContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight))
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: leftDisplayInset, y: 0.0), size: CGSize(width: width - leftDisplayInset, height: UIScreenPixel)))
self.clippingContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight))
self.contentContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight))
self.currentLayout = (width, leftInset, rightInset)
return panelHeight
}
@objc func removePressed() {
self.controllerInteraction?.openMessageFeeException()
}
}
@@ -0,0 +1,815 @@
import Foundation
import UIKit
import Postbox
import TelegramCore
import TemporaryCachedPeerDataManager
import Emoji
import AccountContext
import TelegramPresentationData
import ChatHistoryEntry
import ChatMessageItemCommon
import TextFormat
import Markdown
import Display
import TelegramStringFormatting
struct ChatHistoryEntriesForViewState {
private var messageStableIdToLocalId: [UInt32: Int64] = [:]
init() {
}
mutating func messageGroupStableId(messageStableId: UInt32, groupId: Int64, isLocal: Bool) -> Int64 {
if isLocal {
self.messageStableIdToLocalId[messageStableId] = groupId
return groupId
} else {
if let value = self.messageStableIdToLocalId[messageStableId] {
return value
} else {
return groupId
}
}
}
}
func chatHistoryEntriesForView(
currentState: ChatHistoryEntriesForViewState,
context: AccountContext,
location: ChatLocation,
view: MessageHistoryView,
includeUnreadEntry: Bool,
includeEmptyEntry: Bool,
includeChatInfoEntry: Bool,
includeSearchEntry: Bool,
includeEmbeddedSavedChatInfo: Bool,
reverse: Bool,
groupMessages: Bool,
reverseGroupedMessages: Bool,
selectedMessages: Set<MessageId>?,
presentationData: ChatPresentationData,
historyAppearsCleared: Bool,
skipViewOnceMedia: Bool,
pendingUnpinnedAllMessages: Bool,
pendingRemovedMessages: Set<MessageId>,
associatedData: ChatMessageItemAssociatedData,
updatingMedia: [MessageId: ChatUpdatingMessageMedia],
customChannelDiscussionReadState: MessageId?,
customThreadOutgoingReadState: MessageId?,
cachedData: CachedPeerData?,
adMessage: Message?,
dynamicAdMessages: [Message]
) -> ([ChatHistoryEntry], ChatHistoryEntriesForViewState) {
var currentState = currentState
if historyAppearsCleared {
return ([], currentState)
}
var entries: [ChatHistoryEntry] = []
var adminRanks: [PeerId: CachedChannelAdminRank] = [:]
var stickersEnabled = true
var chatPeer: Peer?
if let peerId = location.peerId {
for additionalEntry in view.additionalData {
if case let .cacheEntry(id, data) = additionalEntry {
if peerId.namespace == Namespaces.Peer.CloudChannel {
if id == cachedChannelAdminRanksEntryId(peerId: peerId), let data = data?.get(CachedChannelAdminRanks.self) {
adminRanks = data.ranks
}
}
} else if case let .peer(_, peer) = additionalEntry {
chatPeer = peer
if let channel = peer as? TelegramChannel, !channel.flags.contains(.isGigagroup) {
if let defaultBannedRights = channel.defaultBannedRights, defaultBannedRights.flags.contains(.banSendStickers) {
stickersEnabled = false
}
}
}
}
}
var joinMessage: Message?
if (associatedData.subject?.isService ?? false) {
} else {
if let peer = chatPeer as? TelegramChannel, case .broadcast = peer.info, case .member = peer.participationStatus, !peer.flags.contains(.isCreator) {
joinMessage = Message(
stableId: UInt32.max - 1000,
stableVersion: 0,
id: MessageId(peerId: peer.id, namespace: Namespaces.Message.Local, id: 0),
globallyUniqueId: nil,
groupingKey: nil,
groupInfo: nil,
threadId: nil,
timestamp: peer.creationDate,
flags: [.Incoming],
tags: [],
globalTags: [],
localTags: [],
customTags: [],
forwardInfo: nil,
author: chatPeer,
text: "",
attributes: [],
media: [TelegramMediaAction(action: .joinedChannel)],
peers: SimpleDictionary<PeerId, Peer>(),
associatedMessages: SimpleDictionary<MessageId, Message>(),
associatedMessageIds: [],
associatedMedia: [:],
associatedThreadInfo: nil,
associatedStories: [:]
)
}
}
var count = 0
loop: for entry in view.entries {
var message = entry.message
var isRead = entry.isRead
if pendingRemovedMessages.contains(message.id) {
continue
}
if case let .replyThread(replyThreadMessage) = location, replyThreadMessage.isForumPost {
for media in message.media {
if let action = media as? TelegramMediaAction {
if case .topicCreated = action.action {
continue loop
} else if case .groupCreated = action.action {
var chatPeer: Peer?
for entry in view.additionalData {
if case let .peer(_, peer) = entry {
chatPeer = peer
}
}
if let channel = chatPeer as? TelegramChannel, channel.isMonoForum {
continue loop
} else if let user = chatPeer as? TelegramUser, user.isForum {
continue loop
}
}
}
}
} else if case .peer = location {
for media in message.media {
if let action = media as? TelegramMediaAction, case .groupCreated = action.action {
var chatPeer: Peer?
for entry in view.additionalData {
if case let .peer(_, peer) = entry {
chatPeer = peer
}
}
if let channel = chatPeer as? TelegramChannel, channel.isMonoForum {
continue loop
} else if let user = chatPeer as? TelegramChannel, user.isForum {
continue loop
}
}
}
}
count += 1
if let customThreadOutgoingReadState = customThreadOutgoingReadState {
isRead = customThreadOutgoingReadState >= message.id
}
if let customChannelDiscussionReadState = customChannelDiscussionReadState {
attibuteLoop: for i in 0 ..< message.attributes.count {
if let attribute = message.attributes[i] as? ReplyThreadMessageAttribute {
if let maxReadMessageId = attribute.maxReadMessageId {
if maxReadMessageId < customChannelDiscussionReadState.id {
var attributes = message.attributes
attributes[i] = ReplyThreadMessageAttribute(count: attribute.count, latestUsers: attribute.latestUsers, commentsPeerId: attribute.commentsPeerId, maxMessageId: attribute.maxMessageId, maxReadMessageId: customChannelDiscussionReadState.id)
message = message.withUpdatedAttributes(attributes)
}
}
break attibuteLoop
}
}
}
if skipViewOnceMedia, let minAutoremoveOrClearTimeout = message.minAutoremoveOrClearTimeout {
if minAutoremoveOrClearTimeout <= 60 {
continue loop
}
}
var contentTypeHint: ChatMessageEntryContentType = .generic
for media in message.media {
if media is TelegramMediaDice {
contentTypeHint = .animatedEmoji
}
if let action = media as? TelegramMediaAction {
switch action.action {
case .channelMigratedFromGroup, .groupMigratedToChannel, .historyCleared:
continue loop
default:
break
}
}
}
var adminRank: CachedChannelAdminRank?
if let author = message.author {
adminRank = adminRanks[author.id]
}
if presentationData.largeEmoji, message.media.isEmpty {
if messageIsEligibleForLargeCustomEmoji(message) {
contentTypeHint = .animatedEmoji
} else if stickersEnabled && message.text.count == 1, let _ = associatedData.animatedEmojiStickers[message.text.basicEmoji.0], (message.textEntitiesAttribute?.entities.isEmpty ?? true) {
contentTypeHint = .animatedEmoji
} else if messageIsEligibleForLargeEmoji(message) {
contentTypeHint = .animatedEmoji
}
}
if groupMessages || reverseGroupedMessages {
if let messageGroupingKey = message.groupingKey {
let selection: ChatHistoryMessageSelection
if let selectedMessages = selectedMessages {
selection = .selectable(selected: selectedMessages.contains(message.id))
} else {
selection = .none
}
var isCentered = false
if case let .messageOptions(_, _, info) = associatedData.subject, case let .link(link) = info {
isCentered = link.isCentered
}
let attributes = ChatMessageEntryAttributes(rank: adminRank, isContact: entry.attributes.authorIsContact, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: message.index == associatedData.currentlyPlayingMessageId, isCentered: isCentered, authorStoryStats: message.author.flatMap { view.peerStoryStats[$0.id] }, displayContinueThreadFooter: false)
let groupStableId = currentState.messageGroupStableId(messageStableId: message.stableId, groupId: messageGroupingKey, isLocal: Namespaces.Message.allLocal.contains(message.id.namespace))
var found = false
for i in 0 ..< entries.count {
if case let .MessageEntry(currentMessage, _, currentIsRead, currentLocation, currentSelection, currentAttributes) = entries[i], let currentGroupingKey = currentMessage.groupingKey, currentState.messageGroupStableId(messageStableId: currentMessage.stableId, groupId: currentGroupingKey, isLocal: Namespaces.Message.allLocal.contains(currentMessage.id.namespace)) == groupStableId {
found = true
var currentMessages: [(Message, Bool, ChatHistoryMessageSelection, ChatMessageEntryAttributes, MessageHistoryEntryLocation?)] = []
currentMessages.append((currentMessage, currentIsRead, currentSelection, currentAttributes, currentLocation))
if reverseGroupedMessages {
currentMessages.insert((message, isRead, selection, attributes, entry.location), at: 0)
} else {
currentMessages.append((message, isRead, selection, attributes, entry.location))
}
entries[i] = .MessageGroupEntry(groupStableId, currentMessages, presentationData)
} else if case let .MessageGroupEntry(currentGroupStableId, currentMessages, _) = entries[i], currentGroupStableId == groupStableId {
found = true
var currentMessages = currentMessages
if reverseGroupedMessages {
currentMessages.insert((message, isRead, selection, attributes, entry.location), at: 0)
} else {
currentMessages.append((message, isRead, selection, attributes, entry.location))
}
entries[i] = .MessageGroupEntry(currentGroupStableId, currentMessages, presentationData)
}
}
if !found {
entries.append(.MessageEntry(message, presentationData, isRead, entry.location, selection, attributes))
}
} else {
let selection: ChatHistoryMessageSelection
if let selectedMessages = selectedMessages {
selection = .selectable(selected: selectedMessages.contains(message.id))
} else {
selection = .none
}
var isCentered = false
if case let .messageOptions(_, _, info) = associatedData.subject, case let .link(link) = info {
isCentered = link.isCentered
}
entries.append(.MessageEntry(message, presentationData, isRead, entry.location, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: entry.attributes.authorIsContact, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: message.index == associatedData.currentlyPlayingMessageId, isCentered: isCentered, authorStoryStats: message.author.flatMap { view.peerStoryStats[$0.id] }, displayContinueThreadFooter: false)))
}
} else {
let selection: ChatHistoryMessageSelection
if let selectedMessages = selectedMessages {
selection = .selectable(selected: selectedMessages.contains(message.id))
} else {
selection = .none
}
entries.append(.MessageEntry(message, presentationData, isRead, entry.location, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: entry.attributes.authorIsContact, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: message.index == associatedData.currentlyPlayingMessageId, isCentered: false, authorStoryStats: message.author.flatMap { view.peerStoryStats[$0.id] }, displayContinueThreadFooter: false)))
}
}
if !groupMessages && reverseGroupedMessages {
var flatEntries: [ChatHistoryEntry] = []
for entry in entries {
switch entry {
case let .MessageGroupEntry(_, messages, presentationData):
for (message, isRead, selection, attributes, location) in messages {
flatEntries.append(.MessageEntry(message, presentationData, isRead, location, selection, attributes))
}
default:
flatEntries.append(entry)
}
}
entries = flatEntries
}
var addBotForumHeader = false
if location.threadId == nil, let user = chatPeer as? TelegramUser, user.isForum, !entries.isEmpty, !view.holeEarlier, !view.isLoading {
addBotForumHeader = true
outer: for i in (0 ..< entries.count).reversed() {
switch entries[i] {
case let .MessageEntry(message, presentationData, isRead, location, selection, attributes):
if message.threadId == nil {
continue outer
}
for media in message.media {
if let _ = media as? TelegramMediaAction {
continue outer
}
}
var attributes = attributes
attributes.displayContinueThreadFooter = true
entries[i] = .MessageEntry(message, presentationData, isRead, location, selection, attributes)
break outer
default:
break
}
}
}
let insertPendingProcessingMessage: ([Message], Int) -> Void = { messages, index in
let serviceMessage = Message(
stableId: UInt32.max - messages[0].stableId,
stableVersion: 0,
id: MessageId(peerId: messages[0].id.peerId, namespace: -1, id: messages[0].id.id),
globallyUniqueId: nil,
groupingKey: nil,
groupInfo: nil,
threadId: nil,
timestamp: messages[0].timestamp,
flags: [.Incoming],
tags: [],
globalTags: [],
localTags: [],
customTags: [],
forwardInfo: nil,
author: nil,
text: "",
attributes: [],
media: [TelegramMediaAction(action: .customText(text: presentationData.strings.Chat_VideoProcessingServiceMessage(Int32(messages.count)), entities: [], additionalAttributes: nil))],
peers: SimpleDictionary<PeerId, Peer>(),
associatedMessages: SimpleDictionary<MessageId, Message>(),
associatedMessageIds: [],
associatedMedia: [:],
associatedThreadInfo: nil,
associatedStories: [:]
)
entries.insert(.MessageEntry(serviceMessage, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil, displayContinueThreadFooter: false)), at: index)
}
for i in (0 ..< entries.count).reversed() {
switch entries[i] {
case let .MessageEntry(message, _, _, _, _, _):
if message.id.namespace == Namespaces.Message.ScheduledCloud && message.pendingProcessingAttribute != nil {
insertPendingProcessingMessage([message], i)
}
case let .MessageGroupEntry(_, messages, _):
if !messages.isEmpty && messages[0].0.id.namespace == Namespaces.Message.ScheduledCloud {
var videoCount = 0
for message in messages {
if message.0.pendingProcessingAttribute != nil {
videoCount += 1
}
}
if videoCount != 0 {
insertPendingProcessingMessage(messages.map(\.0), i)
}
}
default:
break
}
}
if let lowerTimestamp = view.entries.last?.message.timestamp, let upperTimestamp = view.entries.first?.message.timestamp {
if let joinMessage {
var insertAtPosition: Int?
if joinMessage.timestamp >= lowerTimestamp && view.laterId == nil {
insertAtPosition = entries.count
} else if joinMessage.timestamp < lowerTimestamp && joinMessage.timestamp > upperTimestamp {
for i in 0 ..< entries.count {
if let timestamp = entries[i].timestamp, timestamp > joinMessage.timestamp {
insertAtPosition = i
break
}
}
}
if let insertAtPosition {
entries.insert(.MessageEntry(joinMessage, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil, displayContinueThreadFooter: false)), at: insertAtPosition)
}
}
}
if let maxReadIndex = view.maxReadIndex, includeUnreadEntry {
var i = 0
let unreadEntry: ChatHistoryEntry = .UnreadEntry(maxReadIndex, presentationData)
for entry in entries {
if entry > unreadEntry {
if i != 0 {
entries.insert(unreadEntry, at: i)
}
break
}
i += 1
}
}
var addedThreadHead = false
if case let .replyThread(replyThreadMessage) = location, !replyThreadMessage.isForumPost, view.earlierId == nil, !view.holeEarlier, !view.isLoading {
loop: for entry in view.additionalData {
switch entry {
case let .message(id, messages) where id == replyThreadMessage.effectiveTopId:
if !messages.isEmpty {
let selection: ChatHistoryMessageSelection = .none
let topMessage = messages[0]
var hasTopicCreated = false
inner: for media in topMessage.media {
if let action = media as? TelegramMediaAction {
switch action.action {
case .topicCreated:
hasTopicCreated = true
break inner
default:
break
}
}
}
var adminRank: CachedChannelAdminRank?
if let author = topMessage.author {
adminRank = adminRanks[author.id]
}
var contentTypeHint: ChatMessageEntryContentType = .generic
if presentationData.largeEmoji, topMessage.media.isEmpty {
if messageIsEligibleForLargeCustomEmoji(topMessage) {
contentTypeHint = .animatedEmoji
} else if stickersEnabled && topMessage.text.count == 1, let _ = associatedData.animatedEmojiStickers[topMessage.text.basicEmoji.0] {
contentTypeHint = .animatedEmoji
} else if messageIsEligibleForLargeEmoji(topMessage) {
contentTypeHint = .animatedEmoji
}
}
addedThreadHead = true
if messages.count > 1, let groupingKey = messages[0].groupingKey {
var groupMessages: [(Message, Bool, ChatHistoryMessageSelection, ChatMessageEntryAttributes, MessageHistoryEntryLocation?)] = []
for message in messages {
groupMessages.append((message, false, .none, ChatMessageEntryAttributes(rank: adminRank, isContact: false, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: false, isCentered: false, authorStoryStats: message.author.flatMap { view.peerStoryStats[$0.id] }, displayContinueThreadFooter: false), nil))
}
entries.insert(.MessageGroupEntry(groupingKey, groupMessages, presentationData), at: 0)
} else {
if !hasTopicCreated {
entries.insert(.MessageEntry(messages[0], presentationData, false, nil, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: false, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[messages[0].id], isPlaying: false, isCentered: false, authorStoryStats: messages[0].author.flatMap { view.peerStoryStats[$0.id] }, displayContinueThreadFooter: false)), at: 0)
}
}
if !replyThreadMessage.isForumPost {
let replyCount = view.entries.isEmpty ? 0 : 1
entries.insert(.ReplyCountEntry(messages[0].index, replyThreadMessage.isChannelPost, replyCount, presentationData), at: 1)
}
}
break loop
default:
break
}
}
}
if includeChatInfoEntry {
if view.earlierId == nil, !view.isLoading {
var chatPeer: Peer?
var cachedPeerData: CachedPeerData?
for entry in view.additionalData {
if case let .cachedPeerData(_, data) = entry {
cachedPeerData = data
} else if case let .peer(_, peer) = entry {
chatPeer = peer
}
}
if case let .peer(peerId) = location, peerId.isReplies {
entries.insert(.ChatInfoEntry(.botInfo(title: "", text: presentationData.strings.RepliesChat_DescriptionText, photo: nil, video: nil), presentationData), at: 0)
} else if case let .peer(peerId) = location, peerId.isVerificationCodes {
entries.insert(.ChatInfoEntry(.botInfo(title: "", text: presentationData.strings.VerificationCodes_DescriptionText, photo: nil, video: nil), presentationData), at: 0)
} else if let cachedPeerData = cachedPeerData as? CachedUserData {
if let botInfo = cachedPeerData.botInfo, !botInfo.description.isEmpty {
entries.insert(.ChatInfoEntry(.botInfo(title: presentationData.strings.Bot_DescriptionTitle, text: botInfo.description, photo: botInfo.photo, video: botInfo.video), presentationData), at: 0)
} else if let peerStatusSettings = cachedPeerData.peerStatusSettings, peerStatusSettings.registrationDate != nil || peerStatusSettings.phoneCountry != nil {
if peerStatusSettings.flags.contains(.canAddContact) || peerStatusSettings.flags.contains(.canReport) || peerStatusSettings.flags.contains(.canBlock) {
if let chatPeer, let photoChangeDate = peerStatusSettings.photoChangeDate, photoChangeDate > 0 {
let timeText = stringForIntervalSinceUpdateAction(strings: presentationData.strings, value: photoChangeDate)
let text = presentationData.strings.Chat_NonContactUser_UpdatedPhoto(timeText)
var entities: [MessageTextEntity] = []
for range in text.ranges {
entities.append(MessageTextEntity(range: range.range.lowerBound ..< range.range.upperBound, type: .Bold))
}
let message = Message(
stableId: UInt32.max - 1001,
stableVersion: 0,
id: MessageId(peerId: chatPeer.id, namespace: Namespaces.Message.Local, id: -1),
globallyUniqueId: nil,
groupingKey: nil,
groupInfo: nil,
threadId: nil,
timestamp: 2,
flags: [.Incoming],
tags: [],
globalTags: [],
localTags: [],
customTags: [],
forwardInfo: nil,
author: chatPeer,
text: "",
attributes: [],
media: [TelegramMediaAction(action: .customText(
text: text.string,
entities: entities,
additionalAttributes: nil
))],
peers: SimpleDictionary<PeerId, Peer>(),
associatedMessages: SimpleDictionary<MessageId, Message>(),
associatedMessageIds: [],
associatedMedia: [:],
associatedThreadInfo: nil,
associatedStories: [:]
)
entries.insert(.MessageEntry(message, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil, displayContinueThreadFooter: false)), at: 0)
}
if let chatPeer, let nameChangeDate = peerStatusSettings.nameChangeDate, nameChangeDate > 0 {
let timeText = stringForIntervalSinceUpdateAction(strings: presentationData.strings, value: nameChangeDate)
let text = presentationData.strings.Chat_NonContactUser_UpdatedName(timeText)
var entities: [MessageTextEntity] = []
for range in text.ranges {
entities.append(MessageTextEntity(range: range.range.lowerBound ..< range.range.upperBound, type: .Bold))
}
let message = Message(
stableId: UInt32.max - 1002,
stableVersion: 0,
id: MessageId(peerId: chatPeer.id, namespace: Namespaces.Message.Local, id: -2),
globallyUniqueId: nil,
groupingKey: nil,
groupInfo: nil,
threadId: nil,
timestamp: 1,
flags: [.Incoming],
tags: [],
globalTags: [],
localTags: [],
customTags: [],
forwardInfo: nil,
author: chatPeer,
text: "",
attributes: [],
media: [TelegramMediaAction(action: .customText(
text: text.string,
entities: entities,
additionalAttributes: nil
))],
peers: SimpleDictionary<PeerId, Peer>(),
associatedMessages: SimpleDictionary<MessageId, Message>(),
associatedMessageIds: [],
associatedMedia: [:],
associatedThreadInfo: nil,
associatedStories: [:]
)
entries.insert(.MessageEntry(message, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil, displayContinueThreadFooter: false)), at: 0)
}
if let peer = chatPeer.flatMap(EnginePeer.init) {
entries.insert(.ChatInfoEntry(.userInfo(peer: peer, verification: cachedPeerData.verification, registrationDate: peerStatusSettings.registrationDate, phoneCountry: peerStatusSettings.phoneCountry, groupsInCommonCount: cachedPeerData.commonGroupCount), presentationData), at: 0)
}
}
}
} else {
var isEmpty = true
if entries.count <= 3 {
loop: for entry in view.entries {
var isEmptyMedia = false
var isPeerJoined = false
for media in entry.message.media {
if let action = media as? TelegramMediaAction {
switch action.action {
case .groupCreated, .photoUpdated, .channelMigratedFromGroup, .groupMigratedToChannel:
isEmptyMedia = true
case .peerJoined:
isPeerJoined = true
default:
break
}
}
}
var isCreator = false
if let peer = entry.message.peers[entry.message.id.peerId] as? TelegramGroup, case .creator = peer.role {
isCreator = true
} else if let peer = entry.message.peers[entry.message.id.peerId] as? TelegramChannel, case .group = peer.info, peer.flags.contains(.isCreator) {
isCreator = true
}
if isPeerJoined || (isEmptyMedia && isCreator) {
} else {
isEmpty = false
break loop
}
}
} else {
isEmpty = false
}
if addedThreadHead {
isEmpty = false
}
if isEmpty {
entries.removeAll()
}
}
}
if !dynamicAdMessages.isEmpty {
assert(entries.sorted() == entries)
for message in dynamicAdMessages {
entries.append(.MessageEntry(message, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil, displayContinueThreadFooter: false)))
}
entries.sort()
}
if view.laterId == nil && !view.isLoading {
if !entries.isEmpty, case let .MessageEntry(lastMessage, _, _, _, _, _) = entries[entries.count - 1], let message = adMessage {
var nextAdMessageId: Int32 = 10000
let updatedMessage = Message(
stableId: ChatHistoryListNodeImpl.fixedAdMessageStableId,
stableVersion: message.stableVersion,
id: MessageId(peerId: message.id.peerId, namespace: message.id.namespace, id: nextAdMessageId),
globallyUniqueId: nil,
groupingKey: nil,
groupInfo: nil,
threadId: nil,
timestamp: lastMessage.timestamp,
flags: message.flags,
tags: message.tags,
globalTags: message.globalTags,
localTags: message.localTags,
customTags: message.customTags,
forwardInfo: message.forwardInfo,
author: message.author,
text: /*"\(message.adAttribute!.opaqueId.hashValue)" + */message.text,
attributes: message.attributes,
media: message.media,
peers: message.peers,
associatedMessages: message.associatedMessages,
associatedMessageIds: message.associatedMessageIds,
associatedMedia: message.associatedMedia,
associatedThreadInfo: message.associatedThreadInfo,
associatedStories: message.associatedStories
)
nextAdMessageId += 1
entries.append(.MessageEntry(updatedMessage, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil, displayContinueThreadFooter: false)))
}
}
} else if includeSearchEntry {
if view.laterId == nil {
if !view.entries.isEmpty {
entries.append(.SearchEntry(presentationData.theme.theme, presentationData.strings))
}
}
}
if addBotForumHeader {
entries.append(.ChatInfoEntry(.newThreadInfo, presentationData))
}
if includeEmbeddedSavedChatInfo, let peerId = location.peerId {
if !view.isLoading && view.laterId == nil {
let string = presentationData.strings.Chat_SavedMessagesTabInfoText
let formattedString = parseMarkdownIntoAttributedString(
string,
attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: .black),
bold: MarkdownAttributeSet(font: Font.regular(15.0), textColor: .black),
link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: .white),
linkAttribute: { url in
return ("URL", url)
}
)
)
var entities: [MessageTextEntity] = []
formattedString.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: formattedString.length), options: [], using: { value, range, _ in
if let value = value as? UIColor, value == .white {
entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .Bold))
}
})
formattedString.enumerateAttribute(NSAttributedString.Key(rawValue: "URL"), in: NSRange(location: 0, length: formattedString.length), options: [], using: { value, range, _ in
if value != nil {
entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .TextMention(peerId: context.account.peerId)))
}
})
let message = Message(
stableId: UInt32.max - 1001,
stableVersion: 0,
id: MessageId(peerId: peerId, namespace: Namespaces.Message.Local, id: 123),
globallyUniqueId: nil,
groupingKey: nil,
groupInfo: nil,
threadId: nil,
timestamp: Int32.max - 1,
flags: [.Incoming],
tags: [],
globalTags: [],
localTags: [],
customTags: [],
forwardInfo: nil,
author: nil,
text: "",
attributes: [],
media: [TelegramMediaAction(action: .customText(text: formattedString.string, entities: entities, additionalAttributes: nil))],
peers: SimpleDictionary<PeerId, Peer>(),
associatedMessages: SimpleDictionary<MessageId, Message>(),
associatedMessageIds: [],
associatedMedia: [:],
associatedThreadInfo: nil,
associatedStories: [:]
)
entries.append(.MessageEntry(message, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil, displayContinueThreadFooter: false)))
}
}
if let subject = associatedData.subject, case let .customChatContents(customChatContents) = subject, case let .quickReplyMessageInput(_, shortcutType) = customChatContents.kind, case .generic = shortcutType {
if !view.isLoading && view.laterId == nil && !view.entries.isEmpty {
for i in 0 ..< 2 {
let string = i == 1 ? presentationData.strings.Chat_QuickReply_ServiceHeader1 : presentationData.strings.Chat_QuickReply_ServiceHeader2
let formattedString = parseMarkdownIntoAttributedString(
string,
attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: .black),
bold: MarkdownAttributeSet(font: Font.regular(15.0), textColor: .black),
link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: .white),
linkAttribute: { url in
return ("URL", url)
}
)
)
var entities: [MessageTextEntity] = []
formattedString.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: formattedString.length), options: [], using: { value, range, _ in
if let value = value as? UIColor, value == .white {
entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .Bold))
}
})
formattedString.enumerateAttribute(NSAttributedString.Key(rawValue: "URL"), in: NSRange(location: 0, length: formattedString.length), options: [], using: { value, range, _ in
if value != nil {
entities.append(MessageTextEntity(range: range.lowerBound ..< range.upperBound, type: .TextMention(peerId: context.account.peerId)))
}
})
let message = Message(
stableId: UInt32.max - 1001 - UInt32(i),
stableVersion: 0,
id: MessageId(peerId: context.account.peerId, namespace: Namespaces.Message.Local, id: Int32.max - 100 - Int32(i)),
globallyUniqueId: nil,
groupingKey: nil,
groupInfo: nil,
threadId: nil,
timestamp: -Int32(i),
flags: [.Incoming],
tags: [],
globalTags: [],
localTags: [],
customTags: [],
forwardInfo: nil,
author: nil,
text: "",
attributes: [],
media: [TelegramMediaAction(action: .customText(text: formattedString.string, entities: entities, additionalAttributes: nil))],
peers: SimpleDictionary<PeerId, Peer>(),
associatedMessages: SimpleDictionary<MessageId, Message>(),
associatedMessageIds: [],
associatedMedia: [:],
associatedThreadInfo: nil,
associatedStories: [:]
)
entries.insert(.MessageEntry(message, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil, displayContinueThreadFooter: false)), at: 0)
}
}
}
if reverse {
return (entries.reversed(), currentState)
} else {
// #if DEBUG
// assert(entries.map(\.stableId) == entries.sorted().map(\.stableId))
// #endif
return (entries, currentState)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,210 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import WallpaperBackgroundNode
import AnimatedCountLabelNode
import GlassBackgroundComponent
import ComponentFlow
import ComponentDisplayAdapters
private let badgeFont = Font.with(size: 13.0, traits: [.monospacedNumbers])
enum ChatHistoryNavigationButtonType {
case down
case up
case mentions
case reactions
}
class ChatHistoryNavigationButtonNode: ContextControllerSourceNode {
let containerNode: ContextExtractedContentContainingNode
let buttonNode: HighlightTrackingButtonNode
private let backgroundView: GlassBackgroundView
let imageView: GlassBackgroundView.ContentImageView
private let badgeBackgroundView: GlassBackgroundView
private let badgeTextNode: ImmediateAnimatedCountLabelNode
var tapped: (() -> Void)? {
didSet {
if (oldValue != nil) != (self.tapped != nil) {
if self.tapped != nil {
self.buttonNode.addTarget(self, action: #selector(self.onTap), forControlEvents: .touchUpInside)
} else {
self.buttonNode.removeTarget(self, action: #selector(self.onTap), forControlEvents: .touchUpInside)
}
}
}
}
var badge: String = "" {
didSet {
if self.badge != oldValue {
self.layoutBadge()
}
}
}
private var theme: PresentationTheme
private let type: ChatHistoryNavigationButtonType
init(theme: PresentationTheme, backgroundNode: WallpaperBackgroundNode, type: ChatHistoryNavigationButtonType) {
self.theme = theme
self.type = type
self.containerNode = ContextExtractedContentContainingNode()
self.buttonNode = HighlightTrackingButtonNode()
self.backgroundView = GlassBackgroundView()
self.imageView = GlassBackgroundView.ContentImageView()
switch type {
case .down:
self.imageView.image = PresentationResourcesChat.chatHistoryNavigationButtonImage(theme)
case .up:
self.imageView.image = PresentationResourcesChat.chatHistoryNavigationUpButtonImage(theme)
case .mentions:
self.imageView.image = PresentationResourcesChat.chatHistoryMentionsButtonImage(theme)
case .reactions:
self.imageView.image = PresentationResourcesChat.chatHistoryReactionsButtonImage(theme)
}
self.badgeBackgroundView = GlassBackgroundView()
self.badgeBackgroundView.alpha = 0.0
self.badgeTextNode = ImmediateAnimatedCountLabelNode()
self.badgeTextNode.isUserInteractionEnabled = false
self.badgeTextNode.displaysAsynchronously = false
self.badgeTextNode.reverseAnimationDirection = true
super.init()
self.targetNodeForActivationProgress = self.buttonNode
self.addSubnode(self.containerNode)
let size = CGSize(width: 40.0, height: 40.0)
self.containerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size)
self.containerNode.contentRect = CGRect(origin: CGPoint(), size: size)
self.buttonNode.frame = CGRect(origin: CGPoint(), size: size)
self.containerNode.contentNode.addSubnode(self.buttonNode)
self.buttonNode.view.addSubview(self.backgroundView)
self.backgroundView.frame = CGRect(origin: CGPoint(), size: size)
self.backgroundView.update(size: size, cornerRadius: size.height * 0.5, isDark: theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: theme.chat.inputPanel.inputBackgroundColor.withMultipliedAlpha(0.7)), transition: .immediate)
self.imageView.tintColor = theme.chat.inputPanel.panelControlColor
self.backgroundView.contentView.addSubview(self.imageView)
self.imageView.frame = CGRect(origin: CGPoint(), size: size)
self.buttonNode.view.addSubview(self.badgeBackgroundView)
self.badgeBackgroundView.contentView.addSubview(self.badgeTextNode.view)
self.frame = CGRect(origin: CGPoint(), size: size)
}
func updateTheme(theme: PresentationTheme, backgroundNode: WallpaperBackgroundNode) {
if self.theme !== theme {
self.theme = theme
self.backgroundView.update(size: self.backgroundView.bounds.size, cornerRadius: self.backgroundView.bounds.size.height * 0.5, isDark: theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: theme.chat.inputPanel.inputBackgroundColor.withMultipliedAlpha(0.7)), transition: .immediate)
self.imageView.tintColor = theme.chat.inputPanel.panelControlColor
switch self.type {
case .down:
self.imageView.image = PresentationResourcesChat.chatHistoryNavigationButtonImage(theme)
case .up:
self.imageView.image = PresentationResourcesChat.chatHistoryNavigationUpButtonImage(theme)
case .mentions:
self.imageView.image = PresentationResourcesChat.chatHistoryMentionsButtonImage(theme)
case .reactions:
self.imageView.image = PresentationResourcesChat.chatHistoryReactionsButtonImage(theme)
}
self.badgeBackgroundView.update(size: self.badgeBackgroundView.bounds.size, cornerRadius: self.badgeBackgroundView.bounds.height * 0.5, isDark: theme.overallDarkAppearance, tintColor: .init(kind: .custom, color: theme.chat.inputPanel.actionControlFillColor), transition: .immediate)
var segments: [AnimatedCountLabelNode.Segment] = []
if let value = Int(self.badge) {
self.currentValue = value
segments.append(.number(value, NSAttributedString(string: self.badge, font: badgeFont, textColor: self.theme.chat.historyNavigation.badgeTextColor)))
} else {
self.currentValue = 0
segments.append(.text(100, NSAttributedString(string: self.badge, font: badgeFont, textColor: self.theme.chat.historyNavigation.badgeTextColor)))
}
self.badgeTextNode.segments = segments
}
}
private var absoluteRect: (CGRect, CGSize)?
func update(rect: CGRect, within containerSize: CGSize, transition: ContainedViewLayoutTransition) {
self.absoluteRect = (rect, containerSize)
}
@objc func onTap() {
if let tapped = self.tapped {
tapped()
}
}
private var currentValue: Int = 0
private func layoutBadge() {
if !self.badge.isEmpty {
let previousValue = self.currentValue
var segments: [AnimatedCountLabelNode.Segment] = []
if let value = Int(self.badge) {
self.currentValue = value
segments.append(.number(value, NSAttributedString(string: self.badge, font: badgeFont, textColor: self.theme.chat.historyNavigation.badgeTextColor)))
} else {
self.currentValue = 0
segments.append(.text(100, NSAttributedString(string: self.badge, font: badgeFont, textColor: self.theme.chat.historyNavigation.badgeTextColor)))
}
self.badgeTextNode.segments = segments
let badgeSize = self.badgeTextNode.updateLayout(size: CGSize(width: 200.0, height: 100.0), animated: true)
let backgroundSize = CGSize(width: self.badge.count == 1 ? 20.0 : max(20.0, badgeSize.width + 10.0 + 1.0), height: 20.0)
let backgroundFrame = CGRect(origin: CGPoint(x: floor((40.0 - backgroundSize.width) / 2.0), y: -7.0), size: backgroundSize)
if backgroundFrame.width < self.badgeBackgroundView.frame.width {
self.badgeBackgroundView.layer.animateFrame(from: self.badgeBackgroundView.frame, to: backgroundFrame, duration: 0.2)
self.badgeBackgroundView.frame = backgroundFrame
} else {
self.badgeBackgroundView.frame = backgroundFrame
}
let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut)
self.badgeBackgroundView.update(size: backgroundFrame.size, cornerRadius: backgroundFrame.height * 0.5, isDark: theme.overallDarkAppearance, tintColor: .init(kind: .custom, color: self.theme.chat.inputPanel.actionControlFillColor), transition: ComponentTransition(transition))
self.badgeTextNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((backgroundFrame.width - badgeSize.width) / 2.0), y: 2.0), size: badgeSize)
if self.badgeBackgroundView.alpha < 1.0 {
self.badgeBackgroundView.alpha = 1.0
self.badgeBackgroundView.layer.animateScale(from: 0.01, to: 1.2, duration: 0.2, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.badgeBackgroundView.layer.animateScale(from: 1.15, to: 1.0, duration: 0.12, removeOnCompletion: false, completion: { _ in
strongSelf.badgeBackgroundView.layer.removeAllAnimations()
})
}
})
self.badgeBackgroundView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
} else if previousValue < self.currentValue {
self.badgeBackgroundView.layer.animateScale(from: 1.0, to: 1.2, duration: 0.12, removeOnCompletion: false, completion: { [weak self] finished in
if let strongSelf = self {
strongSelf.badgeBackgroundView.layer.animateScale(from: 1.2, to: 1.0, duration: 0.12, removeOnCompletion: false, completion: { _ in
strongSelf.badgeBackgroundView.layer.removeAllAnimations()
})
}
})
}
} else {
self.currentValue = 0
if self.badgeBackgroundView.alpha > 0.0 {
self.badgeBackgroundView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.badgeBackgroundView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2)
}
self.badgeBackgroundView.alpha = 0.0
}
}
}
@@ -0,0 +1,315 @@
import Foundation
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramPresentationData
import WallpaperBackgroundNode
final class ChatHistoryNavigationButtons: ASDisplayNode {
struct ButtonState: Equatable {
var isEnabled: Bool
init(isEnabled: Bool) {
self.isEnabled = isEnabled
}
}
struct DirectionState: Equatable {
var up: ButtonState?
var down: ButtonState?
init(up: ButtonState?, down: ButtonState?) {
self.up = up
self.down = down
}
}
private var theme: PresentationTheme
private var dateTimeFormat: PresentationDateTimeFormat
private let isChatRotated: Bool
let reactionsButton: ChatHistoryNavigationButtonNode
let mentionsButton: ChatHistoryNavigationButtonNode
let downButton: ChatHistoryNavigationButtonNode
let upButton: ChatHistoryNavigationButtonNode
var downPressed: (() -> Void)? {
didSet {
self.downButton.tapped = self.downPressed
}
}
var upPressed: (() -> Void)? {
didSet {
self.upButton.tapped = self.upPressed
}
}
var reactionsPressed: (() -> Void)?
var mentionsPressed: (() -> Void)?
var directionButtonState: DirectionState = DirectionState(up: nil, down: nil) {
didSet {
if oldValue != self.directionButtonState {
let _ = self.updateLayout(transition: .animated(duration: 0.3, curve: .spring))
}
}
}
var unreadCount: Int32 = 0 {
didSet {
if self.unreadCount != 0 {
self.downButton.badge = compactNumericCountString(Int(self.unreadCount), decimalSeparator: self.dateTimeFormat.decimalSeparator)
} else {
self.downButton.badge = ""
}
}
}
var mentionCount: Int32 = 0 {
didSet {
if self.mentionCount != 0 {
self.mentionsButton.badge = compactNumericCountString(Int(self.mentionCount), decimalSeparator: self.dateTimeFormat.decimalSeparator)
} else {
self.mentionsButton.badge = ""
}
if (oldValue != 0) != (self.mentionCount != 0) {
let _ = self.updateLayout(transition: .animated(duration: 0.3, curve: .spring))
}
}
}
var reactionsCount: Int32 = 0 {
didSet {
if self.reactionsCount != 0 {
self.reactionsButton.badge = compactNumericCountString(Int(self.reactionsCount), decimalSeparator: self.dateTimeFormat.decimalSeparator)
} else {
self.reactionsButton.badge = ""
}
if (oldValue != 0) != (self.reactionsCount != 0) {
let _ = self.updateLayout(transition: .animated(duration: 0.3, curve: .spring))
}
}
}
init(theme: PresentationTheme, dateTimeFormat: PresentationDateTimeFormat, backgroundNode: WallpaperBackgroundNode, isChatRotated: Bool) {
self.isChatRotated = isChatRotated
self.theme = theme
self.dateTimeFormat = dateTimeFormat
self.mentionsButton = ChatHistoryNavigationButtonNode(theme: theme, backgroundNode: backgroundNode, type: .mentions)
self.mentionsButton.alpha = 0.0
self.mentionsButton.isHidden = true
self.reactionsButton = ChatHistoryNavigationButtonNode(theme: theme, backgroundNode: backgroundNode, type: .reactions)
self.reactionsButton.alpha = 0.0
self.reactionsButton.isHidden = true
self.downButton = ChatHistoryNavigationButtonNode(theme: theme, backgroundNode: backgroundNode, type: isChatRotated ? .down : .up)
self.downButton.alpha = 0.0
self.downButton.isHidden = true
self.upButton = ChatHistoryNavigationButtonNode(theme: theme, backgroundNode: backgroundNode, type: isChatRotated ? .up : .down)
self.upButton.alpha = 0.0
self.upButton.isHidden = true
super.init()
self.addSubnode(self.reactionsButton)
self.addSubnode(self.mentionsButton)
self.addSubnode(self.downButton)
self.addSubnode(self.upButton)
self.reactionsButton.tapped = { [weak self] in
self?.reactionsPressed?()
}
self.mentionsButton.tapped = { [weak self] in
self?.mentionsPressed?()
}
self.downButton.isGestureEnabled = false
self.upButton.isGestureEnabled = false
}
override func didLoad() {
super.didLoad()
}
func update(theme: PresentationTheme, dateTimeFormat: PresentationDateTimeFormat, backgroundNode: WallpaperBackgroundNode) {
self.theme = theme
self.dateTimeFormat = dateTimeFormat
self.reactionsButton.updateTheme(theme: theme, backgroundNode: backgroundNode)
self.mentionsButton.updateTheme(theme: theme, backgroundNode: backgroundNode)
self.downButton.updateTheme(theme: theme, backgroundNode: backgroundNode)
self.upButton.updateTheme(theme: theme, backgroundNode: backgroundNode)
}
private var absoluteRect: (CGRect, CGSize)?
func update(rect: CGRect, within containerSize: CGSize, transition: ContainedViewLayoutTransition) {
self.absoluteRect = (rect, containerSize)
var reactionsFrame = self.reactionsButton.frame
reactionsFrame.origin.x += rect.minX
reactionsFrame.origin.y += rect.minY
self.reactionsButton.update(rect: reactionsFrame, within: containerSize, transition: transition)
var mentionsFrame = self.mentionsButton.frame
mentionsFrame.origin.x += rect.minX
mentionsFrame.origin.y += rect.minY
self.mentionsButton.update(rect: mentionsFrame, within: containerSize, transition: transition)
var upFrame = self.upButton.frame
upFrame.origin.x += rect.minX
upFrame.origin.y += rect.minY
self.upButton.update(rect: upFrame, within: containerSize, transition: transition)
var downFrame = self.downButton.frame
downFrame.origin.x += rect.minX
downFrame.origin.y += rect.minY
self.downButton.update(rect: downFrame, within: containerSize, transition: transition)
}
func updateLayout(transition: ContainedViewLayoutTransition) -> CGSize {
let buttonSize = CGSize(width: 40.0, height: 40.0)
let completeSize = CGSize(width: buttonSize.width, height: buttonSize.height * 2.0 + 8.0)
var upOffset: CGFloat = 0.0
var mentionsOffset: CGFloat = 0.0
var reactionsOffset: CGFloat = 0.0
if let down = self.directionButtonState.down {
self.downButton.imageView.alpha = down.isEnabled ? 1.0 : 0.5
self.downButton.buttonNode.isEnabled = down.isEnabled
mentionsOffset += buttonSize.height + 12.0
upOffset += buttonSize.height + 12.0
self.downButton.isHidden = false
transition.updateAlpha(node: self.downButton, alpha: 1.0)
transition.updateTransformScale(node: self.downButton, scale: 1.0)
} else {
transition.updateAlpha(node: self.downButton, alpha: 0.0, completion: { [weak self] completed in
guard let strongSelf = self, completed else {
return
}
strongSelf.downButton.isHidden = true
})
transition.updateTransformScale(node: self.downButton, scale: 0.2)
}
if let up = self.directionButtonState.up {
self.upButton.imageView.alpha = up.isEnabled ? 1.0 : 0.5
self.upButton.buttonNode.isEnabled = up.isEnabled
mentionsOffset += buttonSize.height + 12.0
self.upButton.isHidden = false
transition.updateAlpha(node: self.upButton, alpha: 1.0)
transition.updateTransformScale(node: self.upButton, scale: 1.0)
} else {
transition.updateAlpha(node: self.upButton, alpha: 0.0, completion: { [weak self] completed in
guard let strongSelf = self, completed else {
return
}
strongSelf.upButton.isHidden = true
})
transition.updateTransformScale(node: self.upButton, scale: 0.2)
}
if self.mentionCount != 0 {
reactionsOffset += buttonSize.height + 12.0
self.mentionsButton.isHidden = false
transition.updateAlpha(node: self.mentionsButton, alpha: 1.0)
transition.updateTransformScale(node: self.mentionsButton, scale: 1.0)
} else {
transition.updateAlpha(node: self.mentionsButton, alpha: 0.0, completion: { [weak self] completed in
guard let strongSelf = self, completed else {
return
}
strongSelf.mentionsButton.isHidden = true
})
transition.updateTransformScale(node: self.mentionsButton, scale: 0.2)
}
if self.reactionsCount != 0 {
self.reactionsButton.isHidden = false
transition.updateAlpha(node: self.reactionsButton, alpha: 1.0)
transition.updateTransformScale(node: self.reactionsButton, scale: 1.0)
} else {
transition.updateAlpha(node: self.reactionsButton, alpha: 0.0, completion: { [weak self] completed in
guard let strongSelf = self, completed else {
return
}
strongSelf.reactionsButton.isHidden = true
})
transition.updateTransformScale(node: self.reactionsButton, scale: 0.2)
}
if self.isChatRotated {
transition.updatePosition(node: self.downButton, position: CGRect(origin: CGPoint(x: 0.0, y: completeSize.height - buttonSize.height), size: buttonSize).center)
transition.updatePosition(node: self.upButton, position: CGRect(origin: CGPoint(x: 0.0, y: completeSize.height - buttonSize.height - upOffset), size: buttonSize).center)
transition.updatePosition(node: self.mentionsButton, position: CGRect(origin: CGPoint(x: 0.0, y: completeSize.height - buttonSize.height - mentionsOffset), size: buttonSize).center)
transition.updatePosition(node: self.reactionsButton, position: CGRect(origin: CGPoint(x: 0.0, y: completeSize.height - buttonSize.height - mentionsOffset - reactionsOffset), size: buttonSize).center)
} else {
transition.updatePosition(node: self.downButton, position: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: buttonSize).center)
transition.updatePosition(node: self.upButton, position: CGRect(origin: CGPoint(x: 0.0, y: upOffset), size: buttonSize).center)
transition.updatePosition(node: self.mentionsButton, position: CGRect(origin: CGPoint(x: 0.0, y: mentionsOffset), size: buttonSize).center)
transition.updatePosition(node: self.reactionsButton, position: CGRect(origin: CGPoint(x: 0.0, y: mentionsOffset + reactionsOffset), size: buttonSize).center)
}
if let (rect, containerSize) = self.absoluteRect {
self.update(rect: rect, within: containerSize, transition: transition)
}
return completeSize
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let subnodes = self.subnodes {
for subnode in subnodes {
if !subnode.isUserInteractionEnabled {
continue
}
if let result = subnode.hitTest(point.offsetBy(dx: -subnode.frame.minX, dy: -subnode.frame.minY), with: event) {
return result
}
}
}
return nil
}
final class SnapshotState {
fileprivate let downButtonSnapshotView: UIView?
fileprivate init(
downButtonSnapshotView: UIView?
) {
self.downButtonSnapshotView = downButtonSnapshotView
}
}
func prepareSnapshotState() -> SnapshotState {
var downButtonSnapshotView: UIView?
if !self.downButton.isHidden {
downButtonSnapshotView = self.downButton.view.snapshotView(afterScreenUpdates: false)!
}
return SnapshotState(
downButtonSnapshotView: downButtonSnapshotView
)
}
func animateFromSnapshot(_ snapshotState: SnapshotState) {
if self.downButton.isHidden != (snapshotState.downButtonSnapshotView == nil) {
if self.downButton.isHidden {
} else {
self.downButton.layer.animateAlpha(from: 0.0, to: self.downButton.alpha, duration: 0.3)
self.downButton.layer.animateScale(from: 0.1, to: 1.0, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: true)
}
}
}
}
@@ -0,0 +1,30 @@
import Foundation
import UIKit
import Postbox
struct ChatHistoryNavigationStack {
private var messageIndices: [MessageIndex] = []
mutating func add(_ index: MessageIndex) {
self.messageIndices.append(index)
}
mutating func removeLast() -> MessageIndex? {
if messageIndices.isEmpty {
return nil
}
return messageIndices.removeLast()
}
var isEmpty: Bool {
return self.messageIndices.isEmpty
}
mutating func filterOutIndicesLessThan(_ index: MessageIndex) {
for i in (0 ..< self.messageIndices.count).reversed() {
if self.messageIndices[i] <= index {
self.messageIndices.remove(at: i)
}
}
}
}
@@ -0,0 +1,36 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Postbox
import SwiftSignalKit
import Display
import ChatPresentationInterfaceState
import AccountContext
public enum ChatHistoryNodeLoadState: Equatable {
public enum EmptyType: Equatable {
case generic
case joined
case clearedHistory
case topic
case botInfo
}
case loading(Bool)
case empty(EmptyType)
case messages
}
public protocol ChatHistoryNode: AnyObject {
var historyState: ValuePromise<ChatHistoryNodeHistoryState> { get }
var preloadPages: Bool { get set }
var loadState: ChatHistoryNodeLoadState? { get }
func setLoadStateUpdated(_ f: @escaping (ChatHistoryNodeLoadState, Bool) -> Void)
func messageInCurrentHistoryView(_ id: MessageId) -> Message?
func updateLayout(transition: ContainedViewLayoutTransition, updateSizeAndInsets: ListViewUpdateSizeAndInsets)
func forEachItemNode(_ f: (ASDisplayNode) -> Void)
func disconnect()
func scrollToEndOfHistory()
}
@@ -0,0 +1,495 @@
import Foundation
import UIKit
import Postbox
import TelegramCore
import SwiftSignalKit
import Display
import AccountContext
import ChatInterfaceState
func preloadedChatHistoryViewForLocation(_ location: ChatHistoryLocationInput, context: AccountContext, chatLocation: ChatLocation, subject: ChatControllerSubject?, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>, fixedCombinedReadStates: MessageHistoryViewReadState?, tag: HistoryViewInputTag?, additionalData: [AdditionalMessageHistoryViewData], orderStatistics: MessageHistoryViewOrderStatistics = []) -> Signal<ChatHistoryViewUpdate, NoError> {
var isScheduled = false
if case .scheduledMessages = subject {
isScheduled = true
}
var tag = tag
if case .pinnedMessages = subject {
tag = .tag(.pinned)
}
return (chatHistoryViewForLocation(location, ignoreMessagesInTimestampRange: nil, ignoreMessageIds: Set(), context: context, chatLocation: chatLocation, chatLocationContextHolder: chatLocationContextHolder, scheduled: isScheduled, fixedCombinedReadStates: fixedCombinedReadStates, tag: tag, appendMessagesFromTheSameGroup: false, additionalData: additionalData, orderStatistics: orderStatistics)
|> castError(Bool.self)
|> mapToSignal { update -> Signal<ChatHistoryViewUpdate, Bool> in
switch update {
case let .Loading(_, type):
if case .Generic(.FillHole) = type {
return .fail(true)
}
case let .HistoryView(_, type, _, _, _, _, _):
if case .Generic(.FillHole) = type {
return .fail(true)
}
}
return .single(update)
})
|> restartIfError
}
func chatHistoryViewForLocation(
_ location: ChatHistoryLocationInput,
ignoreMessagesInTimestampRange: ClosedRange<Int32>?,
ignoreMessageIds: Set<EngineMessage.Id>,
context: AccountContext,
chatLocation: ChatLocation,
chatLocationContextHolder: Atomic<ChatLocationContextHolder?>,
scheduled: Bool,
fixedCombinedReadStates: MessageHistoryViewReadState?,
tag: HistoryViewInputTag?,
appendMessagesFromTheSameGroup: Bool,
additionalData: [AdditionalMessageHistoryViewData],
orderStatistics: MessageHistoryViewOrderStatistics = [],
useRootInterfaceStateForThread: Bool = false
) -> Signal<ChatHistoryViewUpdate, NoError> {
let account = context.account
if scheduled {
var first = true
var chatScrollPosition: ChatHistoryViewScrollPosition?
if case let .Scroll(subject, _, sourceIndex, position, animated, highlight, setupReply) = location.content {
let directionHint: ListViewScrollToItemDirectionHint = sourceIndex > subject.index ? .Down : .Up
chatScrollPosition = .index(subject: subject, position: position, directionHint: directionHint, animated: animated, highlight: highlight, displayLink: false, setupReply: setupReply)
}
return account.viewTracker.scheduledMessagesViewForLocation(context.chatLocationInput(for: chatLocation, contextHolder: chatLocationContextHolder), additionalData: additionalData)
|> map { view, updateType, initialData -> ChatHistoryViewUpdate in
let (cachedData, cachedDataMessages, readStateData) = extractAdditionalData(view: view, chatLocation: chatLocation)
let combinedInitialData = ChatHistoryCombinedInitialData(initialData: initialData, buttonKeyboardMessage: view.topTaggedMessages.first, cachedData: cachedData, cachedDataMessages: cachedDataMessages, readStateData: readStateData)
if view.isLoading {
return .Loading(initialData: combinedInitialData, type: .Generic(type: updateType))
}
let type: ChatHistoryViewUpdateType
let scrollPosition: ChatHistoryViewScrollPosition? = first ? chatScrollPosition : nil
if first {
first = false
if chatScrollPosition == nil {
type = .Initial(fadeIn: false)
} else {
type = .Generic(type: .UpdateVisible)
}
} else {
type = .Generic(type: .Generic)
}
return .HistoryView(view: view, type: type, scrollPosition: scrollPosition, flashIndicators: false, originalScrollPosition: chatScrollPosition, initialData: ChatHistoryCombinedInitialData(initialData: initialData, buttonKeyboardMessage: view.topTaggedMessages.first, cachedData: cachedData, cachedDataMessages: cachedDataMessages, readStateData: readStateData), id: location.id)
}
} else {
let ignoreRelatedChats: Bool
if let tag = tag, case .tag(.pinned) = tag {
ignoreRelatedChats = true
} else {
ignoreRelatedChats = false
}
let trackHoles = true
switch location.content {
case let .Initial(count):
var preloaded = false
var fadeIn = false
let signal: Signal<(MessageHistoryView, ViewUpdateType, InitialMessageHistoryData?), NoError>
var requestAroundId = false
var preFixedReadState: MessageHistoryViewReadState?
if tag != nil {
requestAroundId = true
}
if case let .replyThread(message) = chatLocation, (message.peerId == context.account.peerId) {
preFixedReadState = .peer([:])
}
if requestAroundId {
signal = account.viewTracker.aroundMessageHistoryViewForLocation(context.chatLocationInput(for: chatLocation, contextHolder: chatLocationContextHolder), ignoreMessagesInTimestampRange: ignoreMessagesInTimestampRange, ignoreMessageIds: ignoreMessageIds, index: .upperBound, anchorIndex: .upperBound, count: count, trackHoles: trackHoles, ignoreRelatedChats: ignoreRelatedChats, fixedCombinedReadStates: preFixedReadState, tag: tag, appendMessagesFromTheSameGroup: appendMessagesFromTheSameGroup, orderStatistics: orderStatistics, useRootInterfaceStateForThread: useRootInterfaceStateForThread)
} else {
signal = account.viewTracker.aroundMessageOfInterestHistoryViewForLocation(context.chatLocationInput(for: chatLocation, contextHolder: chatLocationContextHolder), ignoreMessagesInTimestampRange: ignoreMessagesInTimestampRange, ignoreMessageIds: ignoreMessageIds, count: count, trackHoles: trackHoles, tag: tag, appendMessagesFromTheSameGroup: appendMessagesFromTheSameGroup, orderStatistics: orderStatistics, additionalData: additionalData, useRootInterfaceStateForThread: useRootInterfaceStateForThread)
}
let isPossibleIntroLoaded: Signal<Bool, NoError>
if case let .peer(id) = chatLocation, id.namespace == Namespaces.Peer.CloudUser {
isPossibleIntroLoaded = context.engine.data.subscribe(
TelegramEngine.EngineData.Item.Peer.BusinessIntro(id: id)
)
|> map { result -> Bool in
switch result {
case .known:
return true
case .unknown:
return false
}
}
|> distinctUntilChanged
} else {
isPossibleIntroLoaded = .single(true)
}
return combineLatest(signal, isPossibleIntroLoaded)
|> map { viewData, isPossibleIntroLoaded -> ChatHistoryViewUpdate in
let (view, updateType, initialData) = viewData
let (cachedData, cachedDataMessages, readStateData) = extractAdditionalData(view: view, chatLocation: chatLocation)
let combinedInitialData = ChatHistoryCombinedInitialData(initialData: initialData, buttonKeyboardMessage: view.topTaggedMessages.first, cachedData: cachedData, cachedDataMessages: cachedDataMessages, readStateData: readStateData)
if !isPossibleIntroLoaded {
return .Loading(initialData: combinedInitialData, type: .Generic(type: updateType))
}
if preloaded {
return .HistoryView(view: view, type: .Generic(type: updateType), scrollPosition: nil, flashIndicators: false, originalScrollPosition: nil, initialData: combinedInitialData, id: location.id)
} else {
if view.isLoading {
return .Loading(initialData: combinedInitialData, type: .Generic(type: updateType))
}
var scrollPosition: ChatHistoryViewScrollPosition?
let canScrollToRead: Bool
if case let .replyThread(message) = chatLocation, !message.isForumPost, !message.isMonoforumPost {
if message.peerId == context.account.peerId {
canScrollToRead = false
} else {
canScrollToRead = true
}
} else if case let .replyThread(message) = chatLocation, message.isMonoforumPost {
canScrollToRead = true
} else if view.isAddedToChatList {
canScrollToRead = true
} else {
canScrollToRead = false
}
if tag == nil, case let .replyThread(message) = chatLocation, message.isForumPost, view.maxReadIndex == nil {
if case let .message(index) = view.anchorIndex {
scrollPosition = .index(subject: MessageHistoryScrollToSubject(index: .message(index), quote: nil), position: .bottom(0.0), directionHint: .Up, animated: false, highlight: false, displayLink: false, setupReply: false)
}
}
if let maxReadIndex = view.maxReadIndex, tag == nil, canScrollToRead {
let aroundIndex = maxReadIndex
scrollPosition = .unread(index: maxReadIndex)
if let _ = chatLocation.peerId {
var targetIndex = 0
for i in 0 ..< view.entries.count {
if view.entries[i].index >= aroundIndex {
targetIndex = i
break
}
}
let maxIndex = targetIndex + 40
let minIndex = targetIndex - 40
if minIndex <= 0 && view.holeEarlier {
fadeIn = true
return .Loading(initialData: combinedInitialData, type: .Generic(type: updateType))
}
if maxIndex >= view.entries.count {
if view.holeLater {
fadeIn = true
return .Loading(initialData: combinedInitialData, type: .Generic(type: updateType))
}
if view.holeEarlier {
var incomingCount: Int32 = 0
inner: for entry in view.entries.reversed() {
if !entry.message.flags.intersection(.IsIncomingMask).isEmpty {
incomingCount += 1
}
}
if case let .peer(peerId) = chatLocation, let combinedReadStates = view.fixedReadStates, case let .peer(readStates) = combinedReadStates, let readState = readStates[peerId], readState.count == incomingCount {
} else {
fadeIn = true
return .Loading(initialData: combinedInitialData, type: .Generic(type: updateType))
}
}
}
}
} else if view.isAddedToChatList, tag == nil, let historyScrollState = (initialData?.storedInterfaceState).flatMap(_internal_decodeStoredChatInterfaceState).flatMap(ChatInterfaceState.parse)?.historyScrollState {
scrollPosition = .positionRestoration(index: historyScrollState.messageIndex, relativeOffset: CGFloat(historyScrollState.relativeOffset))
} else {
if let _ = chatLocation.peerId, !view.isAddedToChatList {
if view.holeEarlier && view.entries.count <= 2 {
fadeIn = true
return .Loading(initialData: combinedInitialData, type: .Generic(type: updateType))
}
}
if view.entries.isEmpty && (view.holeEarlier || view.holeLater) {
fadeIn = true
return .Loading(initialData: combinedInitialData, type: .Generic(type: updateType))
}
}
preloaded = true
return .HistoryView(view: view, type: .Initial(fadeIn: fadeIn), scrollPosition: scrollPosition, flashIndicators: false, originalScrollPosition: nil, initialData: ChatHistoryCombinedInitialData(initialData: initialData, buttonKeyboardMessage: view.topTaggedMessages.first, cachedData: cachedData, cachedDataMessages: cachedDataMessages, readStateData: readStateData), id: location.id)
}
}
case let .InitialSearch(searchLocationSubject, count, highlight, setupReply):
var preloaded = false
var fadeIn = false
let signal: Signal<(MessageHistoryView, ViewUpdateType, InitialMessageHistoryData?), NoError>
switch searchLocationSubject.location {
case let .index(index):
signal = account.viewTracker.aroundMessageHistoryViewForLocation(context.chatLocationInput(for: chatLocation, contextHolder: chatLocationContextHolder), ignoreMessagesInTimestampRange: ignoreMessagesInTimestampRange, ignoreMessageIds: ignoreMessageIds, index: .message(index), anchorIndex: .message(index), count: count, trackHoles: trackHoles, ignoreRelatedChats: ignoreRelatedChats, fixedCombinedReadStates: nil, tag: tag, appendMessagesFromTheSameGroup: appendMessagesFromTheSameGroup, orderStatistics: orderStatistics, additionalData: additionalData, useRootInterfaceStateForThread: useRootInterfaceStateForThread)
case let .id(id):
signal = account.viewTracker.aroundIdMessageHistoryViewForLocation(context.chatLocationInput(for: chatLocation, contextHolder: chatLocationContextHolder), ignoreMessagesInTimestampRange: ignoreMessagesInTimestampRange, ignoreMessageIds: ignoreMessageIds, count: count, trackHoles: trackHoles, ignoreRelatedChats: ignoreRelatedChats, messageId: id, tag: tag, appendMessagesFromTheSameGroup: appendMessagesFromTheSameGroup, orderStatistics: orderStatistics, additionalData: additionalData, useRootInterfaceStateForThread: useRootInterfaceStateForThread)
}
return signal |> map { view, updateType, initialData -> ChatHistoryViewUpdate in
let (cachedData, cachedDataMessages, readStateData) = extractAdditionalData(view: view, chatLocation: chatLocation)
let combinedInitialData = ChatHistoryCombinedInitialData(initialData: initialData, buttonKeyboardMessage: view.topTaggedMessages.first, cachedData: cachedData, cachedDataMessages: cachedDataMessages, readStateData: readStateData)
if preloaded {
return .HistoryView(view: view, type: .Generic(type: updateType), scrollPosition: nil, flashIndicators: false, originalScrollPosition: nil, initialData: combinedInitialData, id: location.id)
} else {
let anchorIndex = view.anchorIndex
var targetIndex = 0
for i in 0 ..< view.entries.count {
if anchorIndex.isLessOrEqual(to: view.entries[i].index) {
targetIndex = i
break
}
}
if !view.entries.isEmpty {
let minIndex = max(0, targetIndex - count / 2)
let maxIndex = min(view.entries.count, targetIndex + count / 2)
if minIndex == 0 && view.holeEarlier {
fadeIn = true
return .Loading(initialData: combinedInitialData, type: .Generic(type: updateType))
}
if maxIndex == view.entries.count && view.holeLater {
fadeIn = true
return .Loading(initialData: combinedInitialData, type: .Generic(type: updateType))
}
} else if view.holeEarlier || view.holeLater {
fadeIn = true
return .Loading(initialData: combinedInitialData, type: .Generic(type: updateType))
}
var reportUpdateType: ChatHistoryViewUpdateType = .Initial(fadeIn: fadeIn)
if case .FillHole = updateType {
reportUpdateType = .Generic(type: updateType)
}
preloaded = true
return .HistoryView(view: view, type: reportUpdateType, scrollPosition: .index(subject: MessageHistoryScrollToSubject(index: anchorIndex, quote: searchLocationSubject.quote.flatMap { quote in MessageHistoryScrollToSubject.Quote(string: quote.string, offset: quote.offset) }, todoTaskId: searchLocationSubject.todoTaskId, setupReply: setupReply), position: .center(.bottom), directionHint: .Down, animated: false, highlight: highlight, displayLink: false, setupReply: setupReply), flashIndicators: false, originalScrollPosition: nil, initialData: ChatHistoryCombinedInitialData(initialData: initialData, buttonKeyboardMessage: view.topTaggedMessages.first, cachedData: cachedData, cachedDataMessages: cachedDataMessages, readStateData: readStateData), id: location.id)
}
}
case let .Navigation(index, anchorIndex, count, _):
var first = true
return account.viewTracker.aroundMessageHistoryViewForLocation(context.chatLocationInput(for: chatLocation, contextHolder: chatLocationContextHolder), ignoreMessagesInTimestampRange: ignoreMessagesInTimestampRange, ignoreMessageIds: ignoreMessageIds, index: index, anchorIndex: anchorIndex, count: count, trackHoles: trackHoles, ignoreRelatedChats: ignoreRelatedChats, fixedCombinedReadStates: fixedCombinedReadStates, tag: tag, appendMessagesFromTheSameGroup: appendMessagesFromTheSameGroup, orderStatistics: orderStatistics, additionalData: additionalData, useRootInterfaceStateForThread: useRootInterfaceStateForThread) |> map { view, updateType, initialData -> ChatHistoryViewUpdate in
let (cachedData, cachedDataMessages, readStateData) = extractAdditionalData(view: view, chatLocation: chatLocation)
let genericType: ViewUpdateType
if first {
first = false
genericType = ViewUpdateType.UpdateVisible
} else {
genericType = updateType
}
return .HistoryView(view: view, type: .Generic(type: genericType), scrollPosition: nil, flashIndicators: false, originalScrollPosition: nil, initialData: ChatHistoryCombinedInitialData(initialData: initialData, buttonKeyboardMessage: view.topTaggedMessages.first, cachedData: cachedData, cachedDataMessages: cachedDataMessages, readStateData: readStateData), id: location.id)
}
case let .Scroll(subject, anchorIndex, sourceIndex, scrollPosition, animated, highlight, setupReply):
let directionHint: ListViewScrollToItemDirectionHint = sourceIndex > subject.index ? .Down : .Up
let chatScrollPosition = ChatHistoryViewScrollPosition.index(subject: subject, position: scrollPosition, directionHint: directionHint, animated: animated, highlight: highlight, displayLink: false, setupReply: setupReply)
var first = true
return account.viewTracker.aroundMessageHistoryViewForLocation(context.chatLocationInput(for: chatLocation, contextHolder: chatLocationContextHolder), ignoreMessagesInTimestampRange: ignoreMessagesInTimestampRange, ignoreMessageIds: ignoreMessageIds, index: subject.index, anchorIndex: anchorIndex, count: 128, ignoreRelatedChats: ignoreRelatedChats, fixedCombinedReadStates: fixedCombinedReadStates, tag: tag, appendMessagesFromTheSameGroup: appendMessagesFromTheSameGroup, orderStatistics: orderStatistics, additionalData: additionalData, useRootInterfaceStateForThread: useRootInterfaceStateForThread)
|> map { view, updateType, initialData -> ChatHistoryViewUpdate in
let (cachedData, cachedDataMessages, readStateData) = extractAdditionalData(view: view, chatLocation: chatLocation)
let combinedInitialData = ChatHistoryCombinedInitialData(initialData: initialData, buttonKeyboardMessage: view.topTaggedMessages.first, cachedData: cachedData, cachedDataMessages: cachedDataMessages, readStateData: readStateData)
if view.isLoading {
return ChatHistoryViewUpdate.Loading(initialData: combinedInitialData, type: .Generic(type: updateType))
}
let genericType: ViewUpdateType
let scrollPosition: ChatHistoryViewScrollPosition? = first ? chatScrollPosition : nil
if first {
first = false
genericType = ViewUpdateType.UpdateVisible
} else {
genericType = updateType
}
return .HistoryView(view: view, type: .Generic(type: genericType), scrollPosition: scrollPosition, flashIndicators: animated, originalScrollPosition: chatScrollPosition, initialData: combinedInitialData, id: location.id)
}
}
}
}
private func extractAdditionalData(view: MessageHistoryView, chatLocation: ChatLocation) -> (
cachedData: CachedPeerData?,
cachedDataMessages: [MessageId: Message]?,
readStateData: [PeerId: ChatHistoryCombinedInitialReadStateData]?
) {
var cachedData: CachedPeerData?
var cachedDataMessages: [MessageId: Message] = [:]
var readStateData: [PeerId: ChatHistoryCombinedInitialReadStateData] = [:]
var notificationSettings: PeerNotificationSettings?
loop: for data in view.additionalData {
switch data {
case let .peerNotificationSettings(value):
notificationSettings = value
default:
break
}
}
for data in view.additionalData {
switch data {
case let .peerNotificationSettings(value):
notificationSettings = value
case let .cachedPeerData(peerIdValue, value):
if chatLocation.peerId == peerIdValue {
cachedData = value
}
case let .cachedPeerDataMessages(peerIdValue, value):
if case .peer(peerIdValue) = chatLocation {
if let value = value {
for (_, message) in value {
cachedDataMessages[message.id] = message
}
}
}
case let .message(_, messages):
for message in messages {
cachedDataMessages[message.id] = message
}
case let .totalUnreadState(totalUnreadState):
switch chatLocation {
case let .peer(peerId):
if let combinedReadStates = view.fixedReadStates {
if case let .peer(readStates) = combinedReadStates, let readState = readStates[peerId] {
readStateData[peerId] = ChatHistoryCombinedInitialReadStateData(unreadCount: readState.count, totalState: totalUnreadState, notificationSettings: notificationSettings)
}
}
case .replyThread, .customChatContents:
break
}
default:
break
}
}
return (cachedData, cachedDataMessages, readStateData)
}
struct ReplyThreadInfo {
var message: ChatReplyThreadMessage
var isChannelPost: Bool
var isEmpty: Bool
var scrollToLowerBoundMessage: MessageIndex?
var contextHolder: Atomic<ChatLocationContextHolder?>
}
enum ReplyThreadSubject {
case channelPost(MessageId)
case groupMessage(MessageId)
}
func fetchAndPreloadReplyThreadInfo(context: AccountContext, subject: ReplyThreadSubject, atMessageId: MessageId?, preload: Bool) -> Signal<ReplyThreadInfo, FetchChannelReplyThreadMessageError> {
let message: Signal<ChatReplyThreadMessage, FetchChannelReplyThreadMessageError>
switch subject {
case .channelPost(let messageId), .groupMessage(let messageId):
message = context.engine.messages.fetchChannelReplyThreadMessage(messageId: messageId, atMessageId: atMessageId)
}
return message
|> mapToSignal { replyThreadMessage -> Signal<ReplyThreadInfo, FetchChannelReplyThreadMessageError> in
let chatLocationContextHolder = Atomic<ChatLocationContextHolder?>(value: nil)
let input: ChatHistoryLocationInput
var scrollToLowerBoundMessage: MessageIndex?
switch replyThreadMessage.initialAnchor {
case .automatic:
if let atMessageId = atMessageId {
input = ChatHistoryLocationInput(
content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(atMessageId)), count: 40, highlight: true, setupReply: false),
id: 0
)
} else {
input = ChatHistoryLocationInput(
content: .Initial(count: 40),
id: 0
)
}
case let .lowerBoundMessage(index):
input = ChatHistoryLocationInput(
content: .Navigation(index: .message(index), anchorIndex: .message(index), count: 40, highlight: false),
id: 0
)
scrollToLowerBoundMessage = index
}
if replyThreadMessage.isNotAvailable {
return .single(ReplyThreadInfo(
message: replyThreadMessage,
isChannelPost: replyThreadMessage.isChannelPost,
isEmpty: false,
scrollToLowerBoundMessage: nil,
contextHolder: chatLocationContextHolder
))
}
if preload {
let preloadSignal = preloadedChatHistoryViewForLocation(
input,
context: context,
chatLocation: .replyThread(message: replyThreadMessage),
subject: nil,
chatLocationContextHolder: chatLocationContextHolder,
fixedCombinedReadStates: nil,
tag: nil,
additionalData: []
)
return preloadSignal
|> map { historyView -> Bool? in
switch historyView {
case .Loading:
return nil
case let .HistoryView(view, _, _, _, _, _, _):
return view.entries.isEmpty
}
}
|> mapToSignal { value -> Signal<Bool, NoError> in
if let value = value {
return .single(value)
} else {
return .complete()
}
}
|> take(1)
|> map { isEmpty -> ReplyThreadInfo in
return ReplyThreadInfo(
message: replyThreadMessage,
isChannelPost: replyThreadMessage.isChannelPost,
isEmpty: isEmpty,
scrollToLowerBoundMessage: scrollToLowerBoundMessage,
contextHolder: chatLocationContextHolder
)
}
|> castError(FetchChannelReplyThreadMessageError.self)
} else {
return .single(ReplyThreadInfo(
message: replyThreadMessage,
isChannelPost: replyThreadMessage.isChannelPost,
isEmpty: false,
scrollToLowerBoundMessage: scrollToLowerBoundMessage,
contextHolder: chatLocationContextHolder
))
}
}
}
@@ -0,0 +1,58 @@
import Foundation
import UIKit
import AsyncDisplayKit
import TelegramPresentationData
import Display
import AccountContext
final class ChatImportStatusPanel: ASDisplayNode {
private let labelNode: TextNode
private let backgroundNode: ASImageNode
private let secondaryBackgroundNode: ASImageNode
private var theme: PresentationTheme?
override init() {
self.labelNode = TextNode()
self.backgroundNode = ASImageNode()
self.secondaryBackgroundNode = ASImageNode()
super.init()
self.addSubnode(self.backgroundNode)
self.backgroundNode.addSubnode(self.secondaryBackgroundNode)
self.addSubnode(self.labelNode)
}
func update(context: AccountContext, progress: CGFloat, presentationData: ChatPresentationData, width: CGFloat) -> CGFloat {
if self.theme !== presentationData.theme.theme {
self.theme = presentationData.theme.theme
let graphics = PresentationResourcesChat.principalGraphics(theme: presentationData.theme.theme, wallpaper: presentationData.theme.wallpaper, bubbleCorners: presentationData.chatBubbleCorners)
self.backgroundNode.image = graphics.dateFloatingBackground
self.secondaryBackgroundNode.image = graphics.dateFloatingBackground
}
let titleFont = Font.medium(min(18.0, floor(presentationData.fontSize.baseDisplaySize * 13.0 / 17.0)))
let text = presentationData.strings.Conversation_ImportProgress("\(Int(progress * 100.0))").string
let attributedString = NSAttributedString(string: text, font: titleFont, textColor: bubbleVariableColor(variableColor: presentationData.theme.theme.chat.serviceMessage.dateTextColor, wallpaper: presentationData.theme.wallpaper))
let makeLabelLayout = TextNode.asyncLayout(self.labelNode)
let (labelLayout, apply) = makeLabelLayout(TextNodeLayoutArguments(attributedString: attributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 320.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let _ = apply()
let chatDateSize: CGFloat = 20.0
let chatDateInset: CGFloat = 6.0
let labelSize = labelLayout.size
let backgroundSize = CGSize(width: labelSize.width + chatDateInset * 2.0, height: chatDateSize)
let backgroundFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((width - backgroundSize.width) / 2.0), y: (34.0 - chatDateSize) / 2.0), size: backgroundSize)
self.backgroundNode.frame = backgroundFrame
self.secondaryBackgroundNode.frame = CGRect(origin: CGPoint(), size: backgroundFrame.size)
self.labelNode.frame = CGRect(origin: CGPoint(x: backgroundFrame.origin.x + chatDateInset, y: backgroundFrame.origin.y + floorToScreenPixels((backgroundSize.height - labelSize.height) / 2.0)), size: labelSize)
return 28.0
}
}
@@ -0,0 +1,335 @@
import Foundation
import UIKit
import TelegramCore
import AccountContext
import ChatPresentationInterfaceState
import ChatControllerInteraction
import ChatInputContextPanelNode
private func inputQueryResultPriority(_ result: ChatPresentationInputQueryResult) -> (Int, Bool) {
switch result {
case let .stickers(items):
return (0, !items.isEmpty)
case let .hashtags(items, _):
return (1, !items.isEmpty)
case let .mentions(items):
return (2, !items.isEmpty)
case let .commands(items):
return (3, !items.commands.isEmpty || items.hasShortcuts)
case let .contextRequestResult(_, result):
var nonEmpty = false
if let result = result, !result.results.isEmpty {
nonEmpty = true
}
return (4, nonEmpty)
case let .emojis(items, _):
return (5, !items.isEmpty)
}
}
func textInputContextPanel(context: AccountContext, chatPresentationInterfaceState: ChatPresentationInterfaceState, controllerInteraction: ChatControllerInteraction?, interfaceInteraction: ChatPanelInterfaceInteraction?, currentPanel: ChatInputContextPanelNode?) -> ChatInputContextPanelNode? {
guard let controllerInteraction else {
return nil
}
guard let inputQueryResult = chatPresentationInterfaceState.inputQueryResults.values.sorted(by: { lhs, rhs in
let (lhsP, lhsHasItems) = inputQueryResultPriority(lhs)
let (rhsP, rhsHasItems) = inputQueryResultPriority(rhs)
if lhsHasItems != rhsHasItems {
if lhsHasItems {
return true
} else {
return false
}
}
return lhsP < rhsP
}).first else {
return nil
}
var hasBannedInlineContent = false
if let channel = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.hasBannedPermission(.banSendInline) != nil {
hasBannedInlineContent = true
} else if let group = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramGroup, group.hasBannedPermission(.banSendInline) {
hasBannedInlineContent = true
}
if hasBannedInlineContent {
switch inputQueryResult {
case .stickers, .contextRequestResult:
if let currentPanel = currentPanel as? DisabledContextResultsChatInputContextPanelNode {
return currentPanel
} else {
let panel = DisabledContextResultsChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext)
panel.interfaceInteraction = interfaceInteraction
return panel
}
default:
break
}
}
switch inputQueryResult {
case let .stickers(unfilteredResults):
let _ = unfilteredResults
return nil
/*if !unfilteredResults.isEmpty {
var results: [FoundStickerItem] = []
for result in unfilteredResults {
if !results.contains(where: { $0.file.fileId == result.file.fileId }) {
results.append(result)
}
}
let query = chatPresentationInterfaceState.interfaceState.composeInputState.inputText.string
if let currentPanel = currentPanel as? InlineReactionSearchPanel {
currentPanel.updateResults(results: results.map({ $0.file }), query: query)
return currentPanel
} else {
let panel = InlineReactionSearchPanel(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, peerId: chatPresentationInterfaceState.renderedPeer?.peerId, chatPresentationContext: chatPresentationContext)
panel.controllerInteraction = controllerInteraction
panel.interfaceInteraction = interfaceInteraction
panel.updateResults(results: results.map({ $0.file }), query: query)
return panel
}
}*/
case let .hashtags(results, query):
var peer: EnginePeer?
if let chatPeer = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel, chatPeer.addressName != nil {
peer = EnginePeer(chatPeer)
}
if !results.isEmpty || (peer != nil && query.count >= 4) {
if let currentPanel = currentPanel as? HashtagChatInputContextPanelNode {
currentPanel.updateResults(results, query: query, peer: peer)
return currentPanel
} else {
let panel = HashtagChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext)
panel.interfaceInteraction = interfaceInteraction
panel.updateResults(results, query: query, peer: peer)
return panel
}
} else {
return nil
}
case let .emojis(results, _):
let _ = results
return nil
/*if !results.isEmpty {
if let currentPanel = currentPanel as? EmojisChatInputContextPanelNode {
currentPanel.updateResults(results)
return currentPanel
} else {
let panel = EmojisChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: chatPresentationContext)
panel.interfaceInteraction = interfaceInteraction
panel.updateResults(results)
return panel
}
}*/
case let .mentions(peers):
if !peers.isEmpty {
if let currentPanel = currentPanel as? MentionChatInputContextPanelNode, currentPanel.mode == .input {
currentPanel.updateResults(peers)
return currentPanel
} else {
let panel = MentionChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, mode: .input, chatPresentationContext: controllerInteraction.presentationContext)
panel.interfaceInteraction = interfaceInteraction
panel.updateResults(peers)
return panel
}
} else {
return nil
}
case let .commands(commands):
if !commands.commands.isEmpty || commands.hasShortcuts {
if let currentPanel = currentPanel as? CommandChatInputContextPanelNode {
currentPanel.updateResults(commands.commands, accountPeer: commands.accountPeer, hasShortcuts: commands.hasShortcuts, query: commands.query)
return currentPanel
} else {
let panel = CommandChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext)
panel.interfaceInteraction = interfaceInteraction
panel.updateResults(commands.commands, accountPeer: commands.accountPeer, hasShortcuts: commands.hasShortcuts, query: commands.query)
return panel
}
} else {
return nil
}
case let .contextRequestResult(_, results):
let _ = results
return nil
/*if let results = results, (!results.results.isEmpty || results.switchPeer != nil || results.webView != nil) {
switch results.presentation {
case .list:
if let currentPanel = currentPanel as? VerticalListContextResultsChatInputContextPanelNode {
currentPanel.updateResults(results)
return currentPanel
} else {
let panel = VerticalListContextResultsChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext)
panel.interfaceInteraction = interfaceInteraction
panel.updateResults(results)
return panel
}
case .media:
if let currentPanel = currentPanel as? HorizontalListContextResultsChatInputContextPanelNode {
currentPanel.updateResults(results)
return currentPanel
} else {
let panel = HorizontalListContextResultsChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext)
panel.interfaceInteraction = interfaceInteraction
panel.updateResults(results)
return panel
}
}
} else {
return nil
}*/
}
}
func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentPanel: ChatInputContextPanelNode?, controllerInteraction: ChatControllerInteraction, interfaceInteraction: ChatPanelInterfaceInteraction?, chatPresentationContext: ChatPresentationContext) -> ChatInputContextPanelNode? {
if chatPresentationInterfaceState.showCommands, let renderedPeer = chatPresentationInterfaceState.renderedPeer {
if let currentPanel = currentPanel as? CommandMenuChatInputContextPanelNode {
return currentPanel
} else {
let panel = CommandMenuChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, peerId: renderedPeer.peerId, chatPresentationContext: chatPresentationContext)
panel.interfaceInteraction = interfaceInteraction
return panel
}
}
guard let inputQueryResult = chatPresentationInterfaceState.inputQueryResults.values.sorted(by: { lhs, rhs in
let (lhsP, lhsHasItems) = inputQueryResultPriority(lhs)
let (rhsP, rhsHasItems) = inputQueryResultPriority(rhs)
if lhsHasItems != rhsHasItems {
if lhsHasItems {
return true
} else {
return false
}
}
return lhsP < rhsP
}).first else {
return nil
}
var hasBannedInlineContent = false
if let channel = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.hasBannedPermission(.banSendInline) != nil {
hasBannedInlineContent = true
} else if let group = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramGroup, group.hasBannedPermission(.banSendInline) {
hasBannedInlineContent = true
}
if hasBannedInlineContent {
switch inputQueryResult {
case .stickers, .contextRequestResult:
if let currentPanel = currentPanel as? DisabledContextResultsChatInputContextPanelNode {
return currentPanel
} else {
let panel = DisabledContextResultsChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext)
panel.interfaceInteraction = interfaceInteraction
return panel
}
default:
break
}
}
switch inputQueryResult {
case let .stickers(unfilteredResults):
if !unfilteredResults.isEmpty {
var results: [FoundStickerItem] = []
for result in unfilteredResults {
if !results.contains(where: { $0.file.fileId == result.file.fileId }) {
results.append(result)
}
}
let query = chatPresentationInterfaceState.interfaceState.composeInputState.inputText.string
if let currentPanel = currentPanel as? InlineReactionSearchPanel {
currentPanel.updateResults(results: results.map({ $0.file }), query: query)
return currentPanel
} else {
let panel = InlineReactionSearchPanel(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, peerId: chatPresentationInterfaceState.renderedPeer?.peerId, chatPresentationContext: chatPresentationContext)
panel.controllerInteraction = controllerInteraction
panel.interfaceInteraction = interfaceInteraction
panel.updateResults(results: results.map({ $0.file }), query: query)
return panel
}
}
case .hashtags:
return nil
case let .emojis(results, _):
if !results.isEmpty {
if let currentPanel = currentPanel as? EmojisChatInputContextPanelNode {
currentPanel.updateResults(results)
return currentPanel
} else {
let panel = EmojisChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: chatPresentationContext)
panel.interfaceInteraction = interfaceInteraction
panel.updateResults(results)
return panel
}
}
case .mentions:
return nil
case .commands:
return nil
case let .contextRequestResult(_, results):
if let results = results, (!results.results.isEmpty || results.switchPeer != nil || results.webView != nil) {
switch results.presentation {
case .list:
if let currentPanel = currentPanel as? VerticalListContextResultsChatInputContextPanelNode {
currentPanel.updateResults(results)
return currentPanel
} else {
let panel = VerticalListContextResultsChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext)
panel.interfaceInteraction = interfaceInteraction
panel.updateResults(results)
return panel
}
case .media:
if let currentPanel = currentPanel as? HorizontalListContextResultsChatInputContextPanelNode {
currentPanel.updateResults(results)
return currentPanel
} else {
let panel = HorizontalListContextResultsChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext)
panel.interfaceInteraction = interfaceInteraction
panel.updateResults(results)
return panel
}
}
} else {
return nil
}
}
return nil
}
func chatOverlayContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentPanel: ChatInputContextPanelNode?, interfaceInteraction: ChatPanelInterfaceInteraction?, chatPresentationContext: ChatPresentationContext) -> ChatInputContextPanelNode? {
guard let searchQuerySuggestionResult = chatPresentationInterfaceState.searchQuerySuggestionResult, let _ = chatPresentationInterfaceState.renderedPeer?.peer else {
return nil
}
switch searchQuerySuggestionResult {
case let .mentions(peers):
if !peers.isEmpty {
if let currentPanel = currentPanel as? MentionChatInputContextPanelNode, currentPanel.mode == .search {
currentPanel.updateResults(peers)
return currentPanel
} else {
let panel = MentionChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, mode: .search, chatPresentationContext: chatPresentationContext)
panel.interfaceInteraction = interfaceInteraction
panel.updateResults(peers)
return panel
}
} else {
return nil
}
default:
break
}
return nil
}
@@ -0,0 +1,278 @@
import Foundation
import UIKit
import TelegramCore
import Postbox
import Display
import AccountContext
import Emoji
import ChatInterfaceState
import ChatPresentationInterfaceState
import SwiftSignalKit
import TextFormat
import ChatContextQuery
import ChatTextInputPanelNode
func serviceTasksForChatPresentationIntefaceState(context: AccountContext, chatPresentationInterfaceState: ChatPresentationInterfaceState, updateState: @escaping ((ChatPresentationInterfaceState) -> ChatPresentationInterfaceState) -> Void) -> [AnyHashable: () -> Disposable] {
var missingEmoji = Set<Int64>()
let inputText = chatPresentationInterfaceState.interfaceState.composeInputState.inputText
inputText.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: NSRange(location: 0, length: inputText.length), using: { value, _, _ in
if let value = value as? ChatTextInputTextCustomEmojiAttribute {
if value.file == nil {
missingEmoji.insert(value.fileId)
}
}
})
var result: [AnyHashable: () -> Disposable] = [:]
for id in missingEmoji {
result["emoji-\(id)"] = {
return (context.engine.stickers.resolveInlineStickers(fileIds: [id])
|> deliverOnMainQueue).startStrict(next: { result in
if let file = result[id] {
updateState({ state -> ChatPresentationInterfaceState in
return state.updatedInterfaceState { interfaceState -> ChatInterfaceState in
var inputState = interfaceState.composeInputState
let text = NSMutableAttributedString(attributedString: inputState.inputText)
inputState.inputText.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: NSRange(location: 0, length: inputText.length), using: { value, range, _ in
if let value = value as? ChatTextInputTextCustomEmojiAttribute {
if value.fileId == id {
text.removeAttribute(ChatTextInputAttributes.customEmoji, range: range)
text.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file), range: range)
}
}
})
inputState.inputText = text
return interfaceState.withUpdatedComposeInputState(inputState)
}
})
}
})
}
}
return result
}
func inputContextQueriesForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState) -> [ChatPresentationInputQuery] {
if case let .customChatContents(customChatContents) = chatPresentationInterfaceState.subject {
switch customChatContents.kind {
case .hashTagSearch:
return []
case .quickReplyMessageInput:
break
case .businessLinkSetup:
return []
}
}
let inputState = chatPresentationInterfaceState.interfaceState.effectiveInputState
let inputString: NSString = inputState.inputText.string as NSString
var result: [ChatPresentationInputQuery] = []
for (possibleQueryRange, possibleTypes, additionalStringRange) in textInputStateContextQueryRangeAndType(inputState) {
let query = inputString.substring(with: possibleQueryRange)
if possibleTypes == [.emoji] {
result.append(.emoji(query.basicEmoji.0))
} else if possibleTypes == [.hashtag] {
result.append(.hashtag(query))
} else if possibleTypes == [.mention] {
var types: ChatInputQueryMentionTypes = [.members]
if possibleQueryRange.lowerBound == 1 {
types.insert(.contextBots)
}
result.append(.mention(query: query, types: types))
} else if possibleTypes == [.command] {
result.append(.command(query))
} else if possibleTypes == [.contextRequest], let additionalStringRange = additionalStringRange {
let additionalString = inputString.substring(with: additionalStringRange)
result.append(.contextRequest(addressName: query, query: additionalString))
} else if possibleTypes == [.emojiSearch], !query.isEmpty, let inputLanguage = chatPresentationInterfaceState.interfaceState.inputLanguage {
result.append(.emojiSearch(query: query, languageCode: inputLanguage, range: possibleQueryRange))
}
}
return result
}
func inputTextPanelStateForChatPresentationInterfaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext) -> ChatTextInputPanelState {
var contextPlaceholder: NSAttributedString?
loop: for (_, result) in chatPresentationInterfaceState.inputQueryResults {
if case let .contextRequestResult(peer, _) = result, case let .user(botUser) = peer, let botInfo = botUser.botInfo, let inlinePlaceholder = botInfo.inlinePlaceholder {
let inputQueries = inputContextQueriesForChatPresentationIntefaceState(chatPresentationInterfaceState)
for inputQuery in inputQueries {
if case let .contextRequest(addressName, query) = inputQuery, query.isEmpty {
let baseFontSize: CGFloat = max(chatTextInputMinFontSize, chatPresentationInterfaceState.fontSize.baseDisplaySize)
let string = NSMutableAttributedString()
string.append(NSAttributedString(string: "@" + addressName, font: Font.regular(baseFontSize), textColor: UIColor.clear))
string.append(NSAttributedString(string: " " + inlinePlaceholder, font: Font.regular(baseFontSize), textColor: chatPresentationInterfaceState.theme.chat.inputPanel.inputPlaceholderColor))
contextPlaceholder = string
}
}
break loop
}
}
var currentAutoremoveTimeout: Int32? = chatPresentationInterfaceState.autoremoveTimeout
var canSetupAutoremoveTimeout = false
var canSendTextMessages = true
var accessoryItems: [ChatTextInputAccessoryItem] = []
if let peer = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramSecretChat {
var extendedSearchLayout = false
loop: for (_, result) in chatPresentationInterfaceState.inputQueryResults {
if case let .contextRequestResult(peer, _) = result, peer != nil {
extendedSearchLayout = true
break loop
}
}
if !extendedSearchLayout {
currentAutoremoveTimeout = peer.messageAutoremoveTimeout
canSetupAutoremoveTimeout = true
}
} else if let group = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramGroup {
if !group.hasBannedPermission(.banChangeInfo) {
canSetupAutoremoveTimeout = true
}
canSendTextMessages = !group.hasBannedPermission(.banSendText)
} else if let user = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramUser {
if user.botInfo == nil {
canSetupAutoremoveTimeout = true
}
} else if let channel = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel {
if channel.hasPermission(.changeInfo) {
canSetupAutoremoveTimeout = true
}
canSendTextMessages = channel.hasBannedPermission(.banSendText) == nil
}
if canSetupAutoremoveTimeout {
if case .scheduledMessages = chatPresentationInterfaceState.subject {
} else if chatPresentationInterfaceState.renderedPeer?.peerId != context.account.peerId {
if currentAutoremoveTimeout != nil || chatPresentationInterfaceState.renderedPeer?.peer is TelegramSecretChat {
accessoryItems.append(.messageAutoremoveTimeout(currentAutoremoveTimeout))
}
}
}
switch chatPresentationInterfaceState.inputMode {
case .media:
accessoryItems.append(.input(isEnabled: true, inputMode: .keyboard))
return ChatTextInputPanelState(accessoryItems: accessoryItems, contextPlaceholder: contextPlaceholder, mediaRecordingState: chatPresentationInterfaceState.inputTextPanelState.mediaRecordingState)
case .inputButtons:
return ChatTextInputPanelState(accessoryItems: [.botInput(isEnabled: true, inputMode: .keyboard)], contextPlaceholder: contextPlaceholder, mediaRecordingState: chatPresentationInterfaceState.inputTextPanelState.mediaRecordingState)
case .none, .text:
if let _ = chatPresentationInterfaceState.interfaceState.editMessage {
accessoryItems.append(.input(isEnabled: true, inputMode: .emoji))
return ChatTextInputPanelState(accessoryItems: accessoryItems, contextPlaceholder: contextPlaceholder, mediaRecordingState: chatPresentationInterfaceState.inputTextPanelState.mediaRecordingState)
} else {
var accessoryItems: [ChatTextInputAccessoryItem] = []
let isTextEmpty = chatPresentationInterfaceState.interfaceState.composeInputState.inputText.length == 0
let hasForward = chatPresentationInterfaceState.interfaceState.forwardMessageIds != nil
var extendedSearchLayout = false
loop: for (_, result) in chatPresentationInterfaceState.inputQueryResults {
if case let .contextRequestResult(peer, _) = result, peer != nil {
extendedSearchLayout = true
break loop
}
}
if !extendedSearchLayout {
if case .scheduledMessages = chatPresentationInterfaceState.subject {
} else if chatPresentationInterfaceState.renderedPeer?.peerId != context.account.peerId {
if let peer = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramSecretChat, chatPresentationInterfaceState.interfaceState.composeInputState.inputText.length == 0 {
accessoryItems.append(.messageAutoremoveTimeout(peer.messageAutoremoveTimeout))
} else if currentAutoremoveTimeout != nil && chatPresentationInterfaceState.interfaceState.composeInputState.inputText.length == 0 {
accessoryItems.append(.messageAutoremoveTimeout(currentAutoremoveTimeout))
}
}
}
if case .scheduledMessages = chatPresentationInterfaceState.subject {
} else {
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
var showPremiumGift = false
if !premiumConfiguration.isPremiumDisabled && chatPresentationInterfaceState.disallowedGifts != TelegramDisallowedGifts.All {
if chatPresentationInterfaceState.alwaysShowGiftButton {
showPremiumGift = true
} else if chatPresentationInterfaceState.hasBirthdayToday {
showPremiumGift = true
} else if premiumConfiguration.showPremiumGiftInAttachMenu && premiumConfiguration.showPremiumGiftInTextField {
showPremiumGift = true
}
}
if isTextEmpty, showPremiumGift, let peer = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramUser, !peer.isDeleted && peer.botInfo == nil && !peer.flags.contains(.isSupport) { //&& chatPresentationInterfaceState.suggestPremiumGift {
accessoryItems.append(.gift)
}
}
if isTextEmpty && chatPresentationInterfaceState.hasScheduledMessages && !hasForward {
accessoryItems.append(.scheduledMessages)
}
var stickersEnabled = true
var stickersAreEmoji = !isTextEmpty
if let peer = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel {
if isTextEmpty, case .broadcast = peer.info, canSendMessagesToPeer(peer) {
accessoryItems.append(.silentPost(chatPresentationInterfaceState.interfaceState.silentPosting))
}
if let boostsToUnrestrict = chatPresentationInterfaceState.boostsToUnrestrict, boostsToUnrestrict > 0 {
} else {
if peer.hasBannedPermission(.banSendStickers) != nil {
stickersEnabled = false
}
}
} else if let peer = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramGroup {
if peer.hasBannedPermission(.banSendStickers) {
stickersEnabled = false
}
}
if let channel = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum, let mainChannel = chatPresentationInterfaceState.renderedPeer?.chatOrMonoforumMainPeer as? TelegramChannel, (!mainChannel.hasPermission(.manageDirect) || chatPresentationInterfaceState.chatLocation.threadId != nil) {
if chatPresentationInterfaceState.interfaceState.postSuggestionState == nil {
accessoryItems.append(.suggestPost)
}
}
if case let .customChatContents(customChatContents) = chatPresentationInterfaceState.subject {
switch customChatContents.kind {
case .hashTagSearch:
break
case .quickReplyMessageInput:
break
case .businessLinkSetup:
stickersEnabled = false
}
}
if isTextEmpty && chatPresentationInterfaceState.hasBots && chatPresentationInterfaceState.hasBotCommands && !hasForward {
accessoryItems.append(.commands)
}
if !canSendTextMessages {
if stickersEnabled && !stickersAreEmoji && !hasForward {
accessoryItems.append(.input(isEnabled: true, inputMode: .stickers))
}
} else {
stickersAreEmoji = stickersAreEmoji || hasForward
if stickersEnabled {
accessoryItems.append(.input(isEnabled: true, inputMode: stickersAreEmoji ? .emoji : .stickers))
} else {
accessoryItems.append(.input(isEnabled: true, inputMode: .emoji))
}
}
if isTextEmpty, let message = chatPresentationInterfaceState.keyboardButtonsMessage, let _ = message.visibleButtonKeyboardMarkup, chatPresentationInterfaceState.interfaceState.messageActionsState.dismissedButtonKeyboardMessageId != message.id {
accessoryItems.append(.botInput(isEnabled: true, inputMode: .bot))
}
return ChatTextInputPanelState(accessoryItems: accessoryItems, contextPlaceholder: contextPlaceholder, mediaRecordingState: chatPresentationInterfaceState.inputTextPanelState.mediaRecordingState)
}
}
}
@@ -0,0 +1,44 @@
import Foundation
import UIKit
import AsyncDisplayKit
import TelegramCore
import Postbox
import AccountContext
import ChatPresentationInterfaceState
import ChatControllerInteraction
import ChatInputNode
import ChatEntityKeyboardInputNode
import ChatInputPanelNode
import ChatButtonKeyboardInputNode
import ChatTextInputPanelNode
func inputNodeForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentNode: ChatInputNode?, interfaceInteraction: ChatPanelInterfaceInteraction?, controllerInteraction: ChatControllerInteraction, inputPanelNode: ChatInputPanelNode?, makeMediaInputNode: () -> ChatInputNode?) -> ChatInputNode? {
if let inputPanelNode = inputPanelNode, !(inputPanelNode is ChatTextInputPanelNode) {
return nil
}
switch chatPresentationInterfaceState.inputMode {
case .media:
if let currentNode = currentNode as? ChatEntityKeyboardInputNode {
return currentNode
} else if let inputMediaNode = makeMediaInputNode() {
inputMediaNode.interfaceInteraction = interfaceInteraction
return inputMediaNode
} else {
return nil
}
case .inputButtons:
if chatPresentationInterfaceState.forceInputCommandsHidden {
return nil
} else {
if let currentNode = currentNode as? ChatButtonKeyboardInputNode {
return currentNode
} else {
let inputNode = ChatButtonKeyboardInputNode(context: context, controllerInteraction: controllerInteraction)
inputNode.interfaceInteraction = interfaceInteraction
return inputNode
}
}
case .none, .text:
return nil
}
}
@@ -0,0 +1,323 @@
import Foundation
import UIKit
import AsyncDisplayKit
import TelegramCore
import AccountContext
import ChatPresentationInterfaceState
import ChatControllerInteraction
import AccessoryPanelNode
import ForwardAccessoryPanelNode
import ReplyAccessoryPanelNode
import SuggestPostAccessoryPanelNode
import ChatInputAccessoryPanel
import ChatInputMessageAccessoryPanel
import ComponentFlow
import TelegramNotices
import PresentationDataUtils
import Display
import Markdown
import TextFormat
import TelegramPresentationData
func textInputAccessoryPanel(
context: AccountContext,
chatPresentationInterfaceState: ChatPresentationInterfaceState,
chatControllerInteraction: ChatControllerInteraction?,
interfaceInteraction: ChatPanelInterfaceInteraction?
) -> AnyComponentWithIdentity<ChatInputAccessoryPanelEnvironment>? {
if case .standard(.previewing) = chatPresentationInterfaceState.mode {
return nil
}
if let _ = chatPresentationInterfaceState.interfaceState.selectionState {
return nil
}
if chatPresentationInterfaceState.search != nil {
return nil
}
switch chatPresentationInterfaceState.subject {
case .pinnedMessages, .messageOptions:
return nil
default:
break
}
if let editMessage = chatPresentationInterfaceState.interfaceState.editMessage, chatPresentationInterfaceState.interfaceState.postSuggestionState == nil {
if let editingUrlPreview = chatPresentationInterfaceState.editingUrlPreview, !editMessage.disableUrlPreviews.contains(editingUrlPreview.url) {
var previousTapTimestamp: Double?
return AnyComponentWithIdentity(id: "linkPreview", component: AnyComponent(ChatInputMessageAccessoryPanel(
context: context,
contents: .linkPreview(ChatInputMessageAccessoryPanel.Contents.LinkPreview(
url: editingUrlPreview.url,
webpage: editingUrlPreview.webPage
)),
chatPeerId: chatPresentationInterfaceState.chatLocation.peerId,
action: { sourceView in
let timestamp = CFAbsoluteTimeGetCurrent()
if let previousTapTimestamp, previousTapTimestamp + 1.0 > timestamp {
return
}
previousTapTimestamp = CFAbsoluteTimeGetCurrent()
interfaceInteraction?.presentLinkOptions(sourceView)
},
dismiss: { _ in
interfaceInteraction?.dismissUrlPreview()
}
)))
}
return AnyComponentWithIdentity(id: "edit", component: AnyComponent(ChatInputMessageAccessoryPanel(
context: context,
contents: .edit(ChatInputMessageAccessoryPanel.Contents.Edit(
id: editMessage.messageId,
message: nil
)),
chatPeerId: chatPresentationInterfaceState.chatLocation.peerId,
action: { _ in
},
dismiss: { _ in
interfaceInteraction?.setupEditMessage(nil, { _ in })
}
)))
} else if let urlPreview = chatPresentationInterfaceState.urlPreview, !chatPresentationInterfaceState.interfaceState.composeDisableUrlPreviews.contains(urlPreview.url) {
var previousTapTimestamp: Double?
return AnyComponentWithIdentity(id: "linkPreview", component: AnyComponent(ChatInputMessageAccessoryPanel(
context: context,
contents: .linkPreview(ChatInputMessageAccessoryPanel.Contents.LinkPreview(
url: urlPreview.url,
webpage: urlPreview.webPage
)),
chatPeerId: chatPresentationInterfaceState.chatLocation.peerId,
action: { sourceView in
let timestamp = CFAbsoluteTimeGetCurrent()
if let previousTapTimestamp, previousTapTimestamp + 1.0 > timestamp {
return
}
previousTapTimestamp = CFAbsoluteTimeGetCurrent()
interfaceInteraction?.presentLinkOptions(sourceView)
},
dismiss: { _ in
interfaceInteraction?.dismissUrlPreview()
}
)))
} else if let forwardMessageIds = chatPresentationInterfaceState.interfaceState.forwardMessageIds {
var chatPeerId: EnginePeer.Id?
if let peerId = chatPresentationInterfaceState.chatLocation.peerId {
chatPeerId = peerId
} else if case .customChatContents = chatPresentationInterfaceState.chatLocation {
chatPeerId = context.account.peerId
}
if let chatPeerId {
var previousTapTimestamp: Double?
let theme = chatPresentationInterfaceState.theme
let strings = chatPresentationInterfaceState.strings
let nameDisplayOrder = chatPresentationInterfaceState.nameDisplayOrder
let fontSize = chatPresentationInterfaceState.fontSize
return AnyComponentWithIdentity(id: "forward", component: AnyComponent(ChatInputMessageAccessoryPanel(
context: context,
contents: .forward(ChatInputMessageAccessoryPanel.Contents.Forward(
messageIds: forwardMessageIds,
forwardOptionsState: chatPresentationInterfaceState.interfaceState.forwardOptionsState
)),
chatPeerId: chatPeerId,
action: { sourceView in
let timestamp = CFAbsoluteTimeGetCurrent()
if let previousTapTimestamp, previousTapTimestamp + 1.0 > timestamp {
return
}
previousTapTimestamp = CFAbsoluteTimeGetCurrent()
interfaceInteraction?.presentForwardOptions(sourceView)
let _ = ApplicationSpecificNotice.incrementChatForwardOptionsTip(accountManager: context.sharedContext.accountManager, count: 3).start()
},
dismiss: { sourceView in
Task { @MainActor [weak sourceView] in
guard let messageId = forwardMessageIds.first else {
return
}
guard let message = await context.engine.data.get(
TelegramEngine.EngineData.Item.Messages.Message(id: messageId)
).get() else {
return
}
guard let peer = message.peers[message.id.peerId] else {
return
}
let peerId = peer.id
let peerDisplayTitle = EnginePeer(peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder)
let messageCount = Int32(forwardMessageIds.count)
let messages = strings.Conversation_ForwardOptions_Messages(messageCount)
let string: PresentationStrings.FormattedString
if peerId == context.account.peerId {
string = strings.Conversation_ForwardOptions_TextSaved(messages)
} else if peerId.namespace == Namespaces.Peer.CloudUser {
string = strings.Conversation_ForwardOptions_TextPersonal(messages, peerDisplayTitle)
} else {
string = strings.Conversation_ForwardOptions_Text(messages, peerDisplayTitle)
}
let font = Font.regular(floor(fontSize.baseDisplaySize * 15.0 / 17.0))
let boldFont = Font.semibold(floor(fontSize.baseDisplaySize * 15.0 / 17.0))
let body = MarkdownAttributeSet(font: font, textColor: theme.actionSheet.secondaryTextColor)
let bold = MarkdownAttributeSet(font: boldFont, textColor: theme.actionSheet.secondaryTextColor)
let title = NSAttributedString(string: strings.Conversation_ForwardOptions_Title(messageCount), font: Font.semibold(floor(fontSize.baseDisplaySize)), textColor: theme.actionSheet.primaryTextColor, paragraphAlignment: .center)
let text = addAttributesToStringWithRanges(string._tuple, body: body, argumentAttributes: [0: bold, 1: bold], textAlignment: .center)
let alertController = richTextAlertController(context: context, title: title, text: text, actions: [TextAlertAction(type: .genericAction, title: strings.Conversation_ForwardOptions_ShowOptions, action: {
guard let sourceView else {
return
}
interfaceInteraction?.presentForwardOptions(sourceView)
let _ = ApplicationSpecificNotice.incrementChatForwardOptionsTip(accountManager: context.sharedContext.accountManager, count: 3).start()
}), TextAlertAction(type: .destructiveAction, title: strings.Conversation_ForwardOptions_CancelForwarding, action: {
interfaceInteraction?.dismissForwardMessages()
})], actionLayout: .vertical)
interfaceInteraction?.presentController(alertController, nil)
}
}
)))
} else {
return nil
}
} else if let replyMessageSubject = chatPresentationInterfaceState.interfaceState.replyMessageSubject {
var chatPeerId: EnginePeer.Id?
if let peerId = chatPresentationInterfaceState.chatLocation.peerId {
chatPeerId = peerId
} else if case .customChatContents = chatPresentationInterfaceState.chatLocation {
chatPeerId = context.account.peerId
}
if let chatPeerId {
var previousTapTimestamp: Double?
return AnyComponentWithIdentity(id: "reply", component: AnyComponent(ChatInputMessageAccessoryPanel(
context: context,
contents: .reply(ChatInputMessageAccessoryPanel.Contents.Reply(
id: replyMessageSubject.messageId,
quote: replyMessageSubject.quote,
todoItemId: replyMessageSubject.todoItemId,
message: nil
)),
chatPeerId: chatPeerId,
action: { sourceView in
let timestamp = CFAbsoluteTimeGetCurrent()
if let previousTapTimestamp, previousTapTimestamp + 1.0 > timestamp {
return
}
previousTapTimestamp = CFAbsoluteTimeGetCurrent()
interfaceInteraction?.presentReplyOptions(sourceView)
},
dismiss: { _ in
interfaceInteraction?.setupReplyMessage(nil, nil, { _, f in f() })
}
)))
} else {
return nil
}
} else if let postSuggestionState = chatPresentationInterfaceState.interfaceState.postSuggestionState {
var previousTapTimestamp: Double?
return AnyComponentWithIdentity(id: "suggestPost", component: AnyComponent(ChatInputMessageAccessoryPanel(
context: context,
contents: .suggestPost(ChatInputMessageAccessoryPanel.Contents.SuggestPost(
state: postSuggestionState
)),
chatPeerId: chatPresentationInterfaceState.chatLocation.peerId,
action: { sourceView in
let timestamp = CFAbsoluteTimeGetCurrent()
if let previousTapTimestamp, previousTapTimestamp + 1.0 > timestamp {
return
}
previousTapTimestamp = CFAbsoluteTimeGetCurrent()
interfaceInteraction?.presentSuggestPostOptions()
},
dismiss: { _ in
interfaceInteraction?.dismissSuggestPost()
}
)))
}
return nil
}
func accessoryPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentPanel: AccessoryPanelNode?, chatControllerInteraction: ChatControllerInteraction?, interfaceInteraction: ChatPanelInterfaceInteraction?) -> AccessoryPanelNode? {
if "".isEmpty {
return nil
}
if case .standard(.previewing) = chatPresentationInterfaceState.mode {
return nil
}
if let _ = chatPresentationInterfaceState.interfaceState.selectionState {
return nil
}
if chatPresentationInterfaceState.search != nil {
return nil
}
switch chatPresentationInterfaceState.subject {
case .pinnedMessages, .messageOptions:
return nil
default:
break
}
if let editMessage = chatPresentationInterfaceState.interfaceState.editMessage, chatPresentationInterfaceState.interfaceState.postSuggestionState == nil {
let _ = editMessage
return nil
} else if let urlPreview = chatPresentationInterfaceState.urlPreview, !chatPresentationInterfaceState.interfaceState.composeDisableUrlPreviews.contains(urlPreview.url) {
if let previewPanelNode = currentPanel as? WebpagePreviewAccessoryPanelNode {
previewPanelNode.interfaceInteraction = interfaceInteraction
previewPanelNode.replaceWebpage(url: urlPreview.url, webpage: urlPreview.webPage)
previewPanelNode.updateThemeAndStrings(theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings)
return previewPanelNode
} else {
let panelNode = WebpagePreviewAccessoryPanelNode(context: context, url: urlPreview.url, webpage: urlPreview.webPage, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings)
panelNode.interfaceInteraction = interfaceInteraction
return panelNode
}
} else if let forwardMessageIds = chatPresentationInterfaceState.interfaceState.forwardMessageIds {
if let forwardPanelNode = currentPanel as? ForwardAccessoryPanelNode, forwardPanelNode.messageIds == forwardMessageIds {
forwardPanelNode.interfaceInteraction = interfaceInteraction
forwardPanelNode.updateThemeAndStrings(theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, forwardOptionsState: chatPresentationInterfaceState.interfaceState.forwardOptionsState)
return forwardPanelNode
} else {
let panelNode = ForwardAccessoryPanelNode(context: context, messageIds: forwardMessageIds, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, nameDisplayOrder: chatPresentationInterfaceState.nameDisplayOrder, forwardOptionsState: chatPresentationInterfaceState.interfaceState.forwardOptionsState, animationCache: chatControllerInteraction?.presentationContext.animationCache, animationRenderer: chatControllerInteraction?.presentationContext.animationRenderer)
panelNode.interfaceInteraction = interfaceInteraction
return panelNode
}
} else if let replyMessageSubject = chatPresentationInterfaceState.interfaceState.replyMessageSubject {
if let replyPanelNode = currentPanel as? ReplyAccessoryPanelNode, replyPanelNode.messageId == replyMessageSubject.messageId && replyPanelNode.quote == replyMessageSubject.quote {
replyPanelNode.interfaceInteraction = interfaceInteraction
replyPanelNode.updateThemeAndStrings(theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings)
return replyPanelNode
} else {
var chatPeerId: EnginePeer.Id?
if let peerId = chatPresentationInterfaceState.chatLocation.peerId {
chatPeerId = peerId
} else if case .customChatContents = chatPresentationInterfaceState.chatLocation {
chatPeerId = context.account.peerId
}
if let chatPeerId {
let panelNode = ReplyAccessoryPanelNode(context: context, chatPeerId: chatPeerId, messageId: replyMessageSubject.messageId, quote: replyMessageSubject.quote, todoItemId: replyMessageSubject.todoItemId, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, nameDisplayOrder: chatPresentationInterfaceState.nameDisplayOrder, dateTimeFormat: chatPresentationInterfaceState.dateTimeFormat, animationCache: chatControllerInteraction?.presentationContext.animationCache, animationRenderer: chatControllerInteraction?.presentationContext.animationRenderer)
panelNode.interfaceInteraction = interfaceInteraction
return panelNode
} else {
return nil
}
}
} else if chatPresentationInterfaceState.interfaceState.postSuggestionState != nil {
if let replyPanelNode = currentPanel as? SuggestPostAccessoryPanelNode {
replyPanelNode.interfaceInteraction = interfaceInteraction
replyPanelNode.updateThemeAndStrings(theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings)
return replyPanelNode
} else {
let panelNode = SuggestPostAccessoryPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, nameDisplayOrder: chatPresentationInterfaceState.nameDisplayOrder, dateTimeFormat: chatPresentationInterfaceState.dateTimeFormat, animationCache: chatControllerInteraction?.presentationContext.animationCache, animationRenderer: chatControllerInteraction?.presentationContext.animationRenderer)
panelNode.interfaceInteraction = interfaceInteraction
return panelNode
}
} else {
return nil
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,594 @@
import Foundation
import UIKit
import SwiftSignalKit
import TelegramCore
import Postbox
import TelegramUIPreferences
import LegacyComponents
import TextFormat
import AccountContext
import Emoji
import SearchPeerMembers
import DeviceLocationManager
import TelegramNotices
import ChatPresentationInterfaceState
import ChatContextQuery
func contextQueryResultStateForChatInterfacePresentationState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentQueryStates: inout [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)], requestBotLocationStatus: @escaping (PeerId) -> Void) -> [ChatPresentationInputQueryKind: ChatContextQueryUpdate] {
let inputQueries = inputContextQueriesForChatPresentationIntefaceState(chatPresentationInterfaceState).filter({ query in
if chatPresentationInterfaceState.editMessageState != nil {
switch query {
case .contextRequest, .command, .emoji:
return false
default:
return true
}
} else {
return true
}
})
var updates: [ChatPresentationInputQueryKind: ChatContextQueryUpdate] = [:]
for query in inputQueries {
let previousQuery = currentQueryStates[query.kind]?.0
if previousQuery != query {
let signal = updatedContextQueryResultStateForQuery(context: context, peer: chatPresentationInterfaceState.renderedPeer?.peer, chatLocation: chatPresentationInterfaceState.chatLocation, inputQuery: query, previousQuery: previousQuery, requestBotLocationStatus: requestBotLocationStatus)
updates[query.kind] = .update(query, signal)
}
}
for currentQueryKind in currentQueryStates.keys {
var found = false
inner: for query in inputQueries {
if query.kind == currentQueryKind {
found = true
break inner
}
}
if !found {
updates[currentQueryKind] = .remove
}
}
return updates
}
private func updatedContextQueryResultStateForQuery(context: AccountContext, peer: Peer?, chatLocation: ChatLocation, inputQuery: ChatPresentationInputQuery, previousQuery: ChatPresentationInputQuery?, requestBotLocationStatus: @escaping (PeerId) -> Void) -> Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> {
switch inputQuery {
case let .emoji(query):
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete()
if let previousQuery = previousQuery {
switch previousQuery {
case .emoji:
break
default:
signal = .single({ _ in return .stickers([]) })
}
} else {
signal = .single({ _ in return .stickers([]) })
}
let stickerConfiguration = context.account.postbox.preferencesView(keys: [PreferencesKeys.appConfiguration])
|> map { preferencesView -> StickersSearchConfiguration in
let appConfiguration: AppConfiguration = preferencesView.values[PreferencesKeys.appConfiguration]?.get(AppConfiguration.self) ?? .defaultValue
return StickersSearchConfiguration.with(appConfiguration: appConfiguration)
}
let stickerSettings = context.sharedContext.accountManager.transaction { transaction -> StickerSettings in
let stickerSettings: StickerSettings = transaction.getSharedData(ApplicationSpecificSharedDataKeys.stickerSettings)?.get(StickerSettings.self) ?? .defaultSettings
return stickerSettings
}
let stickers: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = combineLatest(stickerConfiguration, stickerSettings)
|> castError(ChatContextQueryError.self)
|> mapToSignal { stickerConfiguration, stickerSettings -> Signal<[FoundStickerItem], ChatContextQueryError> in
let scope: SearchStickersScope
switch stickerSettings.emojiStickerSuggestionMode {
case .none:
scope = []
case .all:
if stickerConfiguration.disableLocalSuggestions {
scope = [.remote]
} else {
scope = [.installed, .remote]
}
case .installed:
scope = [.installed]
}
return context.engine.stickers.searchStickers(query: nil, emoticon: [query.basicEmoji.0], scope: scope)
|> map { items -> [FoundStickerItem] in
return items.items
}
|> castError(ChatContextQueryError.self)
}
|> map { stickers -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
return { _ in
return .stickers(stickers)
}
}
return signal |> then(stickers)
case let .hashtag(query):
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete()
if let previousQuery = previousQuery {
switch previousQuery {
case .hashtag:
break
default:
signal = .single({ _ in return .hashtags([], query) })
}
} else {
signal = .single({ _ in return .hashtags([], query) })
}
let hashtags: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = context.engine.messages.recentlyUsedHashtags()
|> map { hashtags -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
let normalizedQuery = query.lowercased()
var result: [String] = []
for hashtag in hashtags {
if hashtag.lowercased().hasPrefix(normalizedQuery) {
result.append(hashtag)
}
}
return { _ in return .hashtags(result, query) }
}
|> castError(ChatContextQueryError.self)
return signal |> then(hashtags)
case let .mention(query, types):
let normalizedQuery = query.lowercased()
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete()
if let previousQuery = previousQuery {
switch previousQuery {
case .mention:
break
default:
signal = .single({ _ in return .mentions([]) })
}
} else {
signal = .single({ _ in return .mentions([]) })
}
let inlineBots: Signal<[(EnginePeer, Double)], NoError> = types.contains(.contextBots) ? context.engine.peers.recentlyUsedInlineBots() : .single([])
let strings = context.sharedContext.currentPresentationData.with({ $0 }).strings
let participants: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError>
if let peer {
participants = combineLatest(
inlineBots,
searchPeerMembers(context: context, peerId: peer.id, chatLocation: chatLocation, query: query, scope: .mention)
)
|> map { inlineBots, peers -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
let filteredInlineBots = inlineBots.sorted(by: { $0.1 > $1.1 }).filter { peer, rating in
if rating < 0.14 {
return false
}
if peer.indexName.matchesByTokens(normalizedQuery) {
return true
}
if let addressName = peer.addressName, addressName.lowercased().hasPrefix(normalizedQuery) {
return true
}
return false
}.map { $0.0 }
let inlineBotPeerIds = Set(filteredInlineBots.map { $0.id })
let filteredPeers = peers.filter { peer in
if inlineBotPeerIds.contains(peer.id) {
return false
}
if !types.contains(.accountPeer) && peer.id == context.account.peerId {
return false
}
return true
}
var sortedPeers = filteredInlineBots
sortedPeers.append(contentsOf: filteredPeers.sorted(by: { lhs, rhs in
let result = lhs.indexName.stringRepresentation(lastNameFirst: true).compare(rhs.indexName.stringRepresentation(lastNameFirst: true))
return result == .orderedAscending
}))
sortedPeers = sortedPeers.filter { peer in
return !peer.displayTitle(strings: strings, displayOrder: .firstLast).isEmpty
}
return { _ in return .mentions(sortedPeers) }
}
|> castError(ChatContextQueryError.self)
} else {
participants = .single({ _ in return nil })
}
return signal |> then(participants)
case let .command(query):
guard let peer else {
return .single({ _ in return .commands(ChatInputQueryCommandsResult(
commands: [],
accountPeer: nil,
hasShortcuts: false,
query: ""
)) })
}
let normalizedQuery = query.lowercased()
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete()
if let previousQuery = previousQuery {
switch previousQuery {
case .command:
break
default:
signal = .single({ _ in return .commands(ChatInputQueryCommandsResult(
commands: [],
accountPeer: nil,
hasShortcuts: false,
query: ""
)) })
}
} else {
signal = .single({ _ in return .commands(ChatInputQueryCommandsResult(
commands: [],
accountPeer: nil,
hasShortcuts: false,
query: ""
)) })
}
var shortcuts: Signal<[ShortcutMessageList.Item], NoError> = .single([])
if let user = peer as? TelegramUser, user.botInfo == nil {
context.account.viewTracker.keepQuickRepliesApproximatelyUpdated()
shortcuts = context.engine.accountData.shortcutMessageList(onlyRemote: true)
|> map { shortcutMessageList -> [ShortcutMessageList.Item] in
return shortcutMessageList.items.filter { item in
return item.shortcut.hasPrefix(normalizedQuery)
}
}
}
let commands = combineLatest(
context.engine.peers.peerCommands(id: peer.id),
shortcuts,
context.engine.data.subscribe(
TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)
)
)
|> map { commands, shortcuts, accountPeer -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
let filteredCommands = commands.commands.filter { command in
if command.command.text.hasPrefix(normalizedQuery) {
return true
}
return false
}
var sortedCommands = filteredCommands.map(ChatInputTextCommand.command)
if !shortcuts.isEmpty && sortedCommands.isEmpty {
for shortcut in shortcuts {
sortedCommands.append(.shortcut(shortcut))
}
}
return { _ in return .commands(ChatInputQueryCommandsResult(
commands: sortedCommands,
accountPeer: accountPeer,
hasShortcuts: !shortcuts.isEmpty,
query: normalizedQuery
)) }
}
|> castError(ChatContextQueryError.self)
return signal |> then(commands)
case let .contextRequest(addressName, query):
guard let chatPeerId = chatLocation.peerId else {
return .single({ _ in return .contextRequestResult(nil, nil) })
}
var delayRequest = true
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete()
if let previousQuery = previousQuery {
switch previousQuery {
case let .contextRequest(currentAddressName, currentContextQuery) where currentAddressName == addressName:
if query.isEmpty && !currentContextQuery.isEmpty {
delayRequest = false
}
default:
delayRequest = false
signal = .single({ _ in return .contextRequestResult(nil, nil) })
}
} else {
signal = .single({ _ in return .contextRequestResult(nil, nil) })
}
let contextBot = context.engine.peers.resolvePeerByName(name: addressName, referrer: nil)
|> mapToSignal { result -> Signal<EnginePeer?, NoError> in
guard case let .result(result) = result else {
return .complete()
}
return .single(result)
}
|> castError(ChatContextQueryError.self)
|> mapToSignal { peer -> Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> in
if case let .user(user) = peer, let botInfo = user.botInfo, let _ = botInfo.inlinePlaceholder {
let contextResults = context.engine.messages.requestChatContextResults(botId: user.id, peerId: chatPeerId, query: query, location: context.sharedContext.locationManager.flatMap { locationManager -> Signal<(Double, Double)?, NoError> in
return `deferred` {
Queue.mainQueue().async {
requestBotLocationStatus(user.id)
}
return ApplicationSpecificNotice.inlineBotLocationRequestStatus(accountManager: context.sharedContext.accountManager, peerId: user.id)
|> filter { $0 }
|> take(1)
|> mapToSignal { _ -> Signal<(Double, Double)?, NoError> in
return currentLocationManagerCoordinate(manager: locationManager, timeout: 5.0)
|> flatMap { coordinate -> (Double, Double) in
return (coordinate.latitude, coordinate.longitude)
}
}
}
} ?? .single(nil), offset: "")
|> mapError { error -> ChatContextQueryError in
switch error {
case .generic:
return .generic
case .locationRequired:
return .inlineBotLocationRequest(user.id)
}
}
|> map { results -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
return { _ in
return .contextRequestResult(.user(user), results?.results)
}
}
let botResult: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .single({ previousResult in
var passthroughPreviousResult: ChatContextResultCollection?
if let previousResult = previousResult {
if case let .contextRequestResult(previousUser, previousResults) = previousResult {
if previousUser?.id == user.id {
passthroughPreviousResult = previousResults
}
}
}
return .contextRequestResult(.user(user), passthroughPreviousResult)
})
let maybeDelayedContextResults: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError>
if delayRequest {
maybeDelayedContextResults = contextResults
|> delay(0.4, queue: Queue.concurrentDefaultQueue())
} else {
maybeDelayedContextResults = contextResults
}
return botResult |> then(maybeDelayedContextResults)
} else {
return .single({ _ in return nil })
}
}
return signal |> then(contextBot)
case let .emojiSearch(query, languageCode, range):
let hasPremium = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|> map { peer -> Bool in
guard case let .user(user) = peer else {
return false
}
return user.isPremium
}
|> distinctUntilChanged
if query.isSingleEmoji {
return combineLatest(
context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000),
hasPremium
)
|> map { view, hasPremium -> [(String, TelegramMediaFile?, String)] in
var result: [(String, TelegramMediaFile?, String)] = []
for entry in view.entries {
guard let item = entry.item as? StickerPackItem, !item.file.isPremiumEmoji || hasPremium else {
continue
}
let stringRepresentations = item.getStringRepresentationsOfIndexKeys()
for stringRepresentation in stringRepresentations {
if stringRepresentation == query {
result.append((stringRepresentation, item.file._parse(), stringRepresentation))
break
}
}
}
return result
}
|> map { result -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
return { _ in return .emojis(result, range) }
}
|> castError(ChatContextQueryError.self)
} else {
var signal = context.engine.stickers.searchEmojiKeywords(inputLanguageCode: languageCode, query: query, completeMatch: query.count < 2)
if !languageCode.lowercased().hasPrefix("en") {
signal = signal
|> mapToSignal { keywords in
return .single(keywords)
|> then(
context.engine.stickers.searchEmojiKeywords(inputLanguageCode: "en-US", query: query, completeMatch: query.count < 3)
|> map { englishKeywords in
return keywords + englishKeywords
}
)
}
}
return signal
|> castError(ChatContextQueryError.self)
|> mapToSignal { keywords -> Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> in
return combineLatest(
context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000),
hasPremium
)
|> map { view, hasPremium -> [(String, TelegramMediaFile?, String)] in
var result: [(String, TelegramMediaFile?, String)] = []
var allEmoticons: [String: String] = [:]
for keyword in keywords {
for emoticon in keyword.emoticons {
allEmoticons[emoticon] = keyword.keyword
}
}
for entry in view.entries {
guard let item = entry.item as? StickerPackItem, !item.file.isPremiumEmoji || hasPremium else {
continue
}
let stringRepresentations = item.getStringRepresentationsOfIndexKeys()
for stringRepresentation in stringRepresentations {
if let keyword = allEmoticons[stringRepresentation] {
result.append((stringRepresentation, item.file._parse(), keyword))
break
}
}
}
for keyword in keywords {
for emoticon in keyword.emoticons {
result.append((emoticon, nil, keyword.keyword))
}
}
return result
}
|> map { result -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
return { _ in return .emojis(result, range) }
}
|> castError(ChatContextQueryError.self)
}
}
}
}
func searchQuerySuggestionResultStateForChatInterfacePresentationState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentQuery: ChatPresentationInputQuery?) -> (ChatPresentationInputQuery?, Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, NoError>)? {
var inputQuery: ChatPresentationInputQuery?
if let search = chatPresentationInterfaceState.search {
switch search.domain {
case .members:
inputQuery = .mention(query: search.query, types: [.members, .accountPeer])
default:
break
}
}
if let inputQuery = inputQuery {
if inputQuery == currentQuery {
return nil
} else {
switch inputQuery {
case let .mention(query, _):
if let peer = chatPresentationInterfaceState.renderedPeer?.peer {
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, NoError> = .complete()
if let currentQuery = currentQuery {
switch currentQuery {
case .mention:
break
default:
signal = .single({ _ in return nil })
}
}
let participants = combineLatest(
context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)),
searchPeerMembers(context: context, peerId: peer.id, chatLocation: chatPresentationInterfaceState.chatLocation, query: query, scope: .memberSuggestion)
)
|> map { accountPeer, peers -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
let filteredPeers = peers
var sortedPeers: [EnginePeer] = []
sortedPeers.append(contentsOf: filteredPeers.sorted(by: { lhs, rhs in
let result = lhs.indexName.stringRepresentation(lastNameFirst: true).compare(rhs.indexName.stringRepresentation(lastNameFirst: true))
return result == .orderedAscending
}))
if let accountPeer {
var hasOwnPeer = false
for peer in sortedPeers {
if peer.id == accountPeer.id {
hasOwnPeer = true
break
}
}
if !hasOwnPeer {
sortedPeers.append(accountPeer)
}
}
return { _ in return .mentions(sortedPeers) }
}
return (inputQuery, signal |> then(participants))
} else {
return (nil, .single({ _ in return nil }))
}
default:
return (nil, .single({ _ in return nil }))
}
}
} else {
return (nil, .single({ _ in return nil }))
}
}
private let dataDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType([.link]).rawValue)
func detectUrls(_ inputText: NSAttributedString?) -> [String] {
var detectedUrls: [String] = []
if let text = inputText, let dataDetector = dataDetector {
let utf16 = text.string.utf16
let nsRange = NSRange(location: 0, length: utf16.count)
let matches = dataDetector.matches(in: text.string, options: [], range: nsRange)
for match in matches {
let urlText = (text.string as NSString).substring(with: match.range)
detectedUrls.append(urlText)
}
inputText?.enumerateAttribute(ChatTextInputAttributes.textUrl, in: nsRange, options: [], using: { value, range, stop in
if let value = value as? ChatTextInputTextUrlAttribute {
if !detectedUrls.contains(value.url) {
detectedUrls.append(value.url)
}
}
})
}
return detectedUrls
}
struct UrlPreviewState {
var detectedUrls: [String]
}
func urlPreviewStateForInputText(_ inputText: NSAttributedString?, context: AccountContext, currentQuery: UrlPreviewState?, forPeerId: PeerId?) -> (UrlPreviewState?, Signal<(TelegramMediaWebpage?) -> (TelegramMediaWebpage, String)?, NoError>)? {
guard let _ = inputText else {
if currentQuery != nil {
return (nil, .single({ _ in return nil }))
} else {
return nil
}
}
if let _ = dataDetector {
let detectedUrls = detectUrls(inputText)
if detectedUrls != (currentQuery?.detectedUrls ?? []) {
if !detectedUrls.isEmpty {
return (UrlPreviewState(detectedUrls: detectedUrls), webpagePreview(account: context.account, urls: detectedUrls, forPeerId: forPeerId)
|> mapToSignal { result -> Signal<(TelegramMediaWebpage, String)?, NoError> in
guard case let .result(webpageResult) = result else {
return .complete()
}
if let webpageResult {
return .single((webpageResult.webpage, webpageResult.sourceUrl))
} else {
return .single(nil)
}
}
|> map { value in
return { _ in return value }
})
} else {
return (nil, .single({ _ in return nil }))
}
} else {
return nil
}
} else {
return (nil, .single({ _ in return nil }))
}
}
@@ -0,0 +1,474 @@
import Foundation
import UIKit
import AsyncDisplayKit
import TelegramCore
import AccountContext
import ChatPresentationInterfaceState
import ChatInputPanelNode
import ChatChannelSubscriberInputPanelNode
import ChatMessageSelectionInputPanelNode
import ChatControllerInteraction
import ChatTextInputPanelNode
func inputPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentPanel: ChatInputPanelNode?, currentSecondaryPanel: ChatInputPanelNode?, textInputPanelNode: ChatTextInputPanelNode?, chatControllerInteraction: ChatControllerInteraction?, interfaceInteraction: ChatPanelInterfaceInteraction?) -> (primary: ChatInputPanelNode?, secondary: ChatInputPanelNode?) {
if let renderedPeer = chatPresentationInterfaceState.renderedPeer, renderedPeer.peer?.restrictionText(platform: "ios", contentSettings: context.currentContentSettings.with { $0 }) != nil {
return (nil, nil)
}
if chatPresentationInterfaceState.isNotAccessible {
return (nil, nil)
}
if case .messageOptions = chatPresentationInterfaceState.subject {
return (nil, nil)
}
if context.isFrozen {
var isActuallyFrozen = true
let accountFreezeConfiguration = AccountFreezeConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
if let freezeAppealUrl = accountFreezeConfiguration.freezeAppealUrl {
let components = freezeAppealUrl.components(separatedBy: "/")
if let username = components.last, let peer = chatPresentationInterfaceState.renderedPeer?.peer, peer.addressName == username {
isActuallyFrozen = false
}
}
if isActuallyFrozen {
if let currentPanel = (currentPanel as? ChatRestrictedInputPanelNode) ?? (currentSecondaryPanel as? ChatRestrictedInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatRestrictedInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
}
}
if let _ = chatPresentationInterfaceState.search {
var selectionPanel: ChatMessageSelectionInputPanelNode?
if let selectionState = chatPresentationInterfaceState.interfaceState.selectionState {
if let currentPanel = (currentPanel as? ChatMessageSelectionInputPanelNode) ?? (currentSecondaryPanel as? ChatMessageSelectionInputPanelNode) {
currentPanel.selectedMessages = selectionState.selectedIds
currentPanel.chatControllerInteraction = chatControllerInteraction
currentPanel.interfaceInteraction = interfaceInteraction
currentPanel.updateTheme(theme: chatPresentationInterfaceState.theme)
selectionPanel = currentPanel
} else {
let panel = ChatMessageSelectionInputPanelNode(theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings)
panel.context = context
panel.selectedMessages = selectionState.selectedIds
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
selectionPanel = panel
}
}
if let currentPanel = (currentPanel as? ChatTagSearchInputPanelNode) ?? (currentSecondaryPanel as? ChatTagSearchInputPanelNode) {
currentPanel.chatControllerInteraction = chatControllerInteraction
currentPanel.interfaceInteraction = interfaceInteraction
return (currentPanel, selectionPanel)
} else {
var alwaysShowTotalMessagesCount = false
if case let .customChatContents(contents) = chatPresentationInterfaceState.subject, case .hashTagSearch = contents.kind {
alwaysShowTotalMessagesCount = true
}
let panel = ChatTagSearchInputPanelNode(theme: chatPresentationInterfaceState.theme, alwaysShowTotalMessagesCount: alwaysShowTotalMessagesCount)
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, selectionPanel)
}
}
if case .standard(.embedded) = chatPresentationInterfaceState.mode {
return (nil, nil)
}
if let selectionState = chatPresentationInterfaceState.interfaceState.selectionState {
if let _ = chatPresentationInterfaceState.reportReason {
if let currentPanel = (currentPanel as? ChatMessageReportInputPanelNode) ?? (currentSecondaryPanel as? ChatMessageReportInputPanelNode) {
currentPanel.selectedMessages = selectionState.selectedIds
currentPanel.chatControllerInteraction = chatControllerInteraction
currentPanel.interfaceInteraction = interfaceInteraction
currentPanel.updateThemeAndStrings(theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings)
return (currentPanel, nil)
} else {
let panel = ChatMessageReportInputPanelNode(theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings)
panel.context = context
panel.selectedMessages = selectionState.selectedIds
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
} else {
if let currentPanel = (currentPanel as? ChatMessageSelectionInputPanelNode) ?? (currentSecondaryPanel as? ChatMessageSelectionInputPanelNode) {
currentPanel.selectedMessages = selectionState.selectedIds
currentPanel.chatControllerInteraction = chatControllerInteraction
currentPanel.interfaceInteraction = interfaceInteraction
currentPanel.updateTheme(theme: chatPresentationInterfaceState.theme)
return (currentPanel, nil)
} else {
let panel = ChatMessageSelectionInputPanelNode(theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings)
panel.context = context
panel.selectedMessages = selectionState.selectedIds
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
}
}
if case .pinnedMessages = chatPresentationInterfaceState.subject {
if let currentPanel = (currentPanel as? ChatChannelSubscriberInputPanelNode) ?? (currentSecondaryPanel as? ChatChannelSubscriberInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatChannelSubscriberInputPanelNode()
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
panel.context = context
return (panel, nil)
}
}
if chatPresentationInterfaceState.isPremiumRequiredForMessaging {
if let currentPanel = (currentPanel as? ChatPremiumRequiredInputPanelNode) ?? (currentSecondaryPanel as? ChatPremiumRequiredInputPanelNode) {
currentPanel.chatControllerInteraction = chatControllerInteraction
currentPanel.interfaceInteraction = interfaceInteraction
return (currentPanel, nil)
} else {
let panel = ChatPremiumRequiredInputPanelNode(theme: chatPresentationInterfaceState.theme)
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
}
if chatPresentationInterfaceState.peerIsBlocked, let peer = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramUser, peer.botInfo == nil {
if let currentPanel = (currentPanel as? ChatUnblockInputPanelNode) ?? (currentSecondaryPanel as? ChatUnblockInputPanelNode) {
currentPanel.chatControllerInteraction = chatControllerInteraction
currentPanel.interfaceInteraction = interfaceInteraction
currentPanel.updateThemeAndStrings(theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings)
return (currentPanel, nil)
} else {
let panel = ChatUnblockInputPanelNode(theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings)
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
}
var displayInputTextPanel = false
if let peer = chatPresentationInterfaceState.renderedPeer?.peer {
if peer.id.isRepliesOrVerificationCodes {
if let currentPanel = (currentPanel as? ChatChannelSubscriberInputPanelNode) ?? (currentSecondaryPanel as? ChatChannelSubscriberInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatChannelSubscriberInputPanelNode()
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
panel.context = context
return (panel, nil)
}
}
if case let .replyThread(message) = chatPresentationInterfaceState.chatLocation, message.peerId == context.account.peerId {
if EnginePeer.Id(message.threadId).isAnonymousSavedMessages {
if let currentPanel = (currentPanel as? ChatRestrictedInputPanelNode) ?? (currentSecondaryPanel as? ChatRestrictedInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatRestrictedInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
} else {
if message.threadId == context.account.peerId.toInt64() {
} else {
if let currentPanel = (currentPanel as? ChatChannelSubscriberInputPanelNode) ?? (currentSecondaryPanel as? ChatChannelSubscriberInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatChannelSubscriberInputPanelNode()
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
panel.context = context
return (panel, nil)
}
}
}
}
if let secretChat = peer as? TelegramSecretChat {
switch secretChat.embeddedState {
case .handshake:
if let currentPanel = (currentPanel as? SecretChatHandshakeStatusInputPanelNode) ?? (currentSecondaryPanel as? SecretChatHandshakeStatusInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = SecretChatHandshakeStatusInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
case .terminated:
if let currentPanel = (currentPanel as? DeleteChatInputPanelNode) ?? (currentSecondaryPanel as? DeleteChatInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = DeleteChatInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
case .active:
break
}
} else if let channel = peer as? TelegramChannel {
var isMember: Bool = false
switch channel.participationStatus {
case .kicked:
if let currentPanel = (currentPanel as? DeleteChatInputPanelNode) ?? (currentSecondaryPanel as? DeleteChatInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = DeleteChatInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
case .member:
isMember = true
case .left:
if case let .replyThread(message) = chatPresentationInterfaceState.chatLocation {
if !message.isForumPost && !channel.flags.contains(.joinToSend) {
isMember = true
}
}
}
if channel.flags.contains(.isMonoforum) {
if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = chatPresentationInterfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect), case .peer = chatPresentationInterfaceState.chatLocation {
if chatPresentationInterfaceState.interfaceState.editMessage != nil || chatPresentationInterfaceState.interfaceState.postSuggestionState != nil {
displayInputTextPanel = true
} else if chatPresentationInterfaceState.interfaceState.replyMessageSubject == nil {
displayInputTextPanel = false
if !isMember {
if let currentPanel = (currentPanel as? ChatChannelSubscriberInputPanelNode) ?? (currentSecondaryPanel as? ChatChannelSubscriberInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatChannelSubscriberInputPanelNode()
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
panel.context = context
return (panel, nil)
}
} else {
if let currentPanel = (currentPanel as? ChatRestrictedInputPanelNode) ?? (currentSecondaryPanel as? ChatRestrictedInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatRestrictedInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
}
}
} else {
displayInputTextPanel = true
}
} else if channel.flags.contains(.isForum) && isMember {
var canManage = false
if channel.flags.contains(.isCreator) {
canManage = true
} else if channel.hasPermission(.manageTopics) {
canManage = true
}
if let threadData = chatPresentationInterfaceState.threadData {
if threadData.isClosed {
if threadData.isOwnedByMe {
canManage = true
}
if !canManage {
if let currentPanel = (currentPanel as? ChatRestrictedInputPanelNode) ?? (currentSecondaryPanel as? ChatRestrictedInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatRestrictedInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
}
}
} else if let isGeneralThreadClosed = chatPresentationInterfaceState.isGeneralThreadClosed, isGeneralThreadClosed && chatPresentationInterfaceState.interfaceState.replyMessageSubject == nil {
if !canManage {
if let currentPanel = (currentPanel as? ChatRestrictedInputPanelNode) ?? (currentSecondaryPanel as? ChatRestrictedInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatRestrictedInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
}
} else if let replyMessage = chatPresentationInterfaceState.replyMessage, let threadInfo = replyMessage.associatedThreadInfo, threadInfo.isClosed {
if let currentPanel = (currentPanel as? ChatRestrictedInputPanelNode) ?? (currentSecondaryPanel as? ChatRestrictedInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatRestrictedInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
}
}
if case .group = channel.info, isMember && !channel.hasPermission(.sendSomething) && !canBypassRestrictions(chatPresentationInterfaceState: chatPresentationInterfaceState) && !channel.flags.contains(.isGigagroup) {
if let currentPanel = (currentPanel as? ChatRestrictedInputPanelNode) ?? (currentSecondaryPanel as? ChatRestrictedInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatRestrictedInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
}
switch channel.info {
case .broadcast:
if chatPresentationInterfaceState.interfaceState.editMessage != nil, channel.hasPermission(.editAllMessages) {
displayInputTextPanel = true
} else if !channel.hasPermission(.sendSomething) || !isMember {
if let currentPanel = (currentPanel as? ChatChannelSubscriberInputPanelNode) ?? (currentSecondaryPanel as? ChatChannelSubscriberInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatChannelSubscriberInputPanelNode()
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
panel.context = context
return (panel, nil)
}
}
case .group:
switch channel.participationStatus {
case .kicked, .left:
if !channel.flags.contains(.isMonoforum) && !isMember {
if let currentPanel = (currentPanel as? ChatChannelSubscriberInputPanelNode) ?? (currentSecondaryPanel as? ChatChannelSubscriberInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatChannelSubscriberInputPanelNode()
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
panel.context = context
return (panel, nil)
}
}
case .member:
if channel.flags.contains(.isGigagroup) && !channel.hasPermission(.sendSomething) {
if let currentPanel = (currentPanel as? ChatChannelSubscriberInputPanelNode) ?? (currentSecondaryPanel as? ChatChannelSubscriberInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatChannelSubscriberInputPanelNode()
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
panel.context = context
return (panel, nil)
}
} else {
break
}
}
}
} else if let group = peer as? TelegramGroup {
switch group.membership {
case .Removed, .Left:
if let currentPanel = (currentPanel as? DeleteChatInputPanelNode) ?? (currentSecondaryPanel as? DeleteChatInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = DeleteChatInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
case .Member:
break
}
if !group.hasPermission(.sendSomething) {
if let currentPanel = (currentPanel as? ChatRestrictedInputPanelNode) ?? (currentSecondaryPanel as? ChatRestrictedInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatRestrictedInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
}
}
displayInputTextPanel = true
}
if case let .customChatContents(customChatContents) = chatPresentationInterfaceState.subject {
switch customChatContents.kind {
case .hashTagSearch:
displayInputTextPanel = false
case .quickReplyMessageInput, .businessLinkSetup:
displayInputTextPanel = true
}
if let chatHistoryState = chatPresentationInterfaceState.chatHistoryState, case .loaded(_, true) = chatHistoryState {
if let currentPanel = (currentPanel as? ChatRestrictedInputPanelNode) ?? (currentSecondaryPanel as? ChatRestrictedInputPanelNode) {
return (currentPanel, nil)
} else {
let panel = ChatRestrictedInputPanelNode()
panel.context = context
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
return (panel, nil)
}
}
}
if case .inline = chatPresentationInterfaceState.mode {
displayInputTextPanel = false
}
if displayInputTextPanel {
if let currentPanel = (currentPanel as? ChatTextInputPanelNode) ?? (currentSecondaryPanel as? ChatTextInputPanelNode) {
currentPanel.chatControllerInteraction = chatControllerInteraction
currentPanel.interfaceInteraction = interfaceInteraction
return (currentPanel, nil)
} else {
if let textInputPanelNode = textInputPanelNode {
textInputPanelNode.interfaceInteraction = interfaceInteraction
textInputPanelNode.context = context
return (textInputPanelNode, nil)
} else {
let panel = ChatTextInputPanelNode(context: context, presentationInterfaceState: chatPresentationInterfaceState, presentationContext: nil, presentController: { [weak interfaceInteraction] controller in
interfaceInteraction?.presentController(controller, nil)
})
panel.textInputAccessoryPanel = textInputAccessoryPanel
panel.textInputContextPanel = textInputContextPanel
panel.chatControllerInteraction = chatControllerInteraction
panel.interfaceInteraction = interfaceInteraction
panel.context = context
return (panel, nil)
}
}
} else {
return (nil, nil)
}
}
@@ -0,0 +1,280 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Postbox
import TelegramCore
import TelegramPresentationData
import AccountContext
import ChatPresentationInterfaceState
import ChatNavigationButton
func leftNavigationButtonForChatInterfaceState(_ presentationInterfaceState: ChatPresentationInterfaceState, subject: ChatControllerSubject?, strings: PresentationStrings, currentButton: ChatNavigationButton?, target: Any?, selector: Selector?) -> ChatNavigationButton? {
if let _ = presentationInterfaceState.interfaceState.selectionState {
if case .messageOptions = presentationInterfaceState.subject {
return nil
}
if let _ = presentationInterfaceState.reportReason {
return ChatNavigationButton(action: .spacer, buttonItem: UIBarButtonItem(title: " ", style: .plain, target: nil, action: nil))
}
if case .replyThread = presentationInterfaceState.chatLocation {
return nil
}
if let currentButton = currentButton, currentButton.action == .clearHistory {
return currentButton
} else if let peer = presentationInterfaceState.renderedPeer?.peer {
let canClear: Bool
var title = strings.Conversation_ClearAll
if case .scheduledMessages = presentationInterfaceState.subject {
canClear = true
title = strings.ScheduledMessages_ClearAll
} else {
if peer is TelegramUser || peer is TelegramGroup || peer is TelegramSecretChat {
canClear = true
} else if let peer = peer as? TelegramChannel, case .group = peer.info, peer.addressName == nil && presentationInterfaceState.peerGeoLocation == nil {
canClear = true
} else if let peer = peer as? TelegramChannel {
if case .broadcast = peer.info {
title = strings.Conversation_ClearChannel
}
if peer.hasPermission(.changeInfo) {
canClear = true
} else {
canClear = false
}
} else {
canClear = false
}
}
if canClear {
return ChatNavigationButton(action: .clearHistory, buttonItem: UIBarButtonItem(title: title, style: .plain, target: target, action: selector))
} else {
title = strings.Conversation_ClearCache
return ChatNavigationButton(action: .clearCache, buttonItem: UIBarButtonItem(title: title, style: .plain, target: target, action: selector))
}
}
}
if case let .customChatContents(customChatContents) = presentationInterfaceState.subject {
switch customChatContents.kind {
case .hashTagSearch:
break
case .quickReplyMessageInput, .businessLinkSetup:
if let currentButton = currentButton, currentButton.action == .dismiss {
return currentButton
} else {
let buttonItem = UIBarButtonItem(title: strings.Common_Close, style: .plain, target: target, action: selector)
buttonItem.accessibilityLabel = strings.Common_Close
return ChatNavigationButton(action: .dismiss, buttonItem: buttonItem)
}
}
}
return nil
}
func rightNavigationButtonForChatInterfaceState(context: AccountContext, presentationInterfaceState: ChatPresentationInterfaceState, strings: PresentationStrings, currentButton: ChatNavigationButton?, target: Any?, selector: Selector?, chatInfoNavigationButton: ChatNavigationButton?, moreInfoNavigationButton: ChatNavigationButton?) -> ChatNavigationButton? {
var hasMessages = false
if let chatHistoryState = presentationInterfaceState.chatHistoryState {
if case .loaded(false, _) = chatHistoryState {
hasMessages = true
}
}
if let _ = presentationInterfaceState.interfaceState.selectionState {
if case .messageOptions = presentationInterfaceState.subject {
return nil
}
if let currentButton = currentButton, currentButton.action == .cancelMessageSelection {
return currentButton
} else {
let buttonItem = UIBarButtonItem(title: strings.Common_Cancel, style: .plain, target: target, action: selector)
buttonItem.accessibilityLabel = strings.Common_Cancel
return ChatNavigationButton(action: .cancelMessageSelection, buttonItem: buttonItem)
}
}
if case let .replyThread(message) = presentationInterfaceState.chatLocation, message.peerId == context.account.peerId {
let isTags = presentationInterfaceState.hasSearchTags
if case .search(isTags) = currentButton?.action {
return currentButton
} else {
let buttonItem = UIBarButtonItem(image: isTags ? PresentationResourcesRootController.navigationCompactTagsSearchIcon(presentationInterfaceState.theme) : PresentationResourcesRootController.navigationCompactSearchIcon(presentationInterfaceState.theme), style: .plain, target: target, action: selector)
buttonItem.accessibilityLabel = strings.Conversation_Search
return ChatNavigationButton(action: .search(hasTags: isTags), buttonItem: buttonItem)
}
}
if let channel = presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum, case .peer = presentationInterfaceState.chatLocation {
let displaySearch = hasMessages
if displaySearch {
if case .search(false) = currentButton?.action {
return currentButton
} else {
let buttonItem = UIBarButtonItem(image: PresentationResourcesRootController.navigationCompactSearchIcon(presentationInterfaceState.theme), style: .plain, target: target, action: selector)
buttonItem.accessibilityLabel = strings.Conversation_Search
return ChatNavigationButton(action: .search(hasTags: false), buttonItem: buttonItem)
}
} else {
return nil
}
}
if let user = presentationInterfaceState.renderedPeer?.peer as? TelegramUser, user.isForum, case .peer = presentationInterfaceState.chatLocation {
let displaySearch = hasMessages
if displaySearch {
if case .search(false) = currentButton?.action {
return currentButton
} else {
let buttonItem = UIBarButtonItem(image: PresentationResourcesRootController.navigationCompactSearchIcon(presentationInterfaceState.theme), style: .plain, target: target, action: selector)
buttonItem.accessibilityLabel = strings.Conversation_Search
return ChatNavigationButton(action: .search(hasTags: false), buttonItem: buttonItem)
}
} else {
return nil
}
}
if let channel = presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isForumOrMonoForum, let moreInfoNavigationButton = moreInfoNavigationButton {
if case .replyThread = presentationInterfaceState.chatLocation {
} else {
if case .pinnedMessages = presentationInterfaceState.subject {
} else {
return moreInfoNavigationButton
}
}
}
if let user = presentationInterfaceState.renderedPeer?.peer as? TelegramUser, let botInfo = user.botInfo, botInfo.flags.contains(.hasForum), let moreInfoNavigationButton = moreInfoNavigationButton {
if case .pinnedMessages = presentationInterfaceState.subject {
} else {
return moreInfoNavigationButton
}
}
if case .messageOptions = presentationInterfaceState.subject {
return nil
}
if case .pinnedMessages = presentationInterfaceState.subject {
return nil
}
if case let .customChatContents(customChatContents) = presentationInterfaceState.subject {
switch customChatContents.kind {
case .hashTagSearch:
return nil
case let .quickReplyMessageInput(_, shortcutType):
switch shortcutType {
case .generic:
if let currentButton = currentButton, currentButton.action == .edit {
return currentButton
} else {
let buttonItem = UIBarButtonItem(title: strings.Common_Edit, style: .plain, target: target, action: selector)
buttonItem.accessibilityLabel = strings.Common_Done
return ChatNavigationButton(action: .edit, buttonItem: buttonItem)
}
case .greeting, .away:
return nil
}
case .businessLinkSetup:
if let currentButton = currentButton, currentButton.action == .edit {
return currentButton
} else {
let buttonItem = UIBarButtonItem(title: strings.Common_Edit, style: .plain, target: target, action: selector)
buttonItem.accessibilityLabel = strings.Common_Done
return ChatNavigationButton(action: .edit, buttonItem: buttonItem)
}
}
}
if case .replyThread = presentationInterfaceState.chatLocation {
if let channel = presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isForumOrMonoForum {
} else if hasMessages {
if case .search = currentButton?.action {
return currentButton
} else {
let buttonItem = UIBarButtonItem(image: PresentationResourcesRootController.navigationCompactSearchIcon(presentationInterfaceState.theme), style: .plain, target: target, action: selector)
buttonItem.accessibilityLabel = strings.Conversation_Search
return ChatNavigationButton(action: .search(hasTags: false), buttonItem: buttonItem)
}
} else {
if case .spacer = currentButton?.action {
return currentButton
} else {
return ChatNavigationButton(action: .spacer, buttonItem: UIBarButtonItem(title: "", style: .plain, target: target, action: selector))
}
}
}
if case let .peer(peerId) = presentationInterfaceState.chatLocation {
if peerId.isRepliesOrVerificationCodes {
if hasMessages {
if case .search = currentButton?.action {
return currentButton
} else {
let buttonItem = UIBarButtonItem(image: PresentationResourcesRootController.navigationCompactSearchIcon(presentationInterfaceState.theme), style: .plain, target: target, action: selector)
buttonItem.accessibilityLabel = strings.Conversation_Search
return ChatNavigationButton(action: .search(hasTags: false), buttonItem: buttonItem)
}
} else {
if case .spacer = currentButton?.action {
return currentButton
} else {
return ChatNavigationButton(action: .spacer, buttonItem: UIBarButtonItem(title: "", style: .plain, target: target, action: selector))
}
}
}
}
if case .scheduledMessages = presentationInterfaceState.subject {
return chatInfoNavigationButton
}
if case .standard(.previewing) = presentationInterfaceState.mode {
return chatInfoNavigationButton
} else if let peerId = presentationInterfaceState.chatLocation.peerId {
if presentationInterfaceState.accountPeerId == peerId {
var displaySearchButton = false
if case .replyThread = presentationInterfaceState.chatLocation {
displaySearchButton = true
}
if case .scheduledMessages = presentationInterfaceState.subject {
return chatInfoNavigationButton
} else {
displaySearchButton = true
}
if displaySearchButton {
let isTags = presentationInterfaceState.hasSearchTags
if case .search(isTags) = currentButton?.action {
return currentButton
} else {
let buttonItem = UIBarButtonItem(image: isTags ? PresentationResourcesRootController.navigationCompactTagsSearchIcon(presentationInterfaceState.theme) : PresentationResourcesRootController.navigationCompactSearchIcon(presentationInterfaceState.theme), style: .plain, target: target, action: selector)
buttonItem.accessibilityLabel = strings.Conversation_Search
return ChatNavigationButton(action: .search(hasTags: isTags), buttonItem: buttonItem)
}
}
}
}
return chatInfoNavigationButton
}
func secondaryRightNavigationButtonForChatInterfaceState(context: AccountContext, presentationInterfaceState: ChatPresentationInterfaceState, strings: PresentationStrings, currentButton: ChatNavigationButton?, target: Any?, selector: Selector?, chatInfoNavigationButton: ChatNavigationButton?, moreInfoNavigationButton: ChatNavigationButton?) -> ChatNavigationButton? {
if presentationInterfaceState.interfaceState.selectionState != nil {
return nil
}
if case .standard(.default) = presentationInterfaceState.mode {
if case .peer(context.account.peerId) = presentationInterfaceState.chatLocation, presentationInterfaceState.subject != .scheduledMessages, presentationInterfaceState.hasSavedChats {
return moreInfoNavigationButton
}
}
return nil
}
@@ -0,0 +1,334 @@
import Foundation
import UIKit
import TelegramCore
import AccountContext
import ChatPresentationInterfaceState
import ChatControllerInteraction
import ComponentFlow
import ChatSideTopicsPanel
func titlePanelForChatPresentationInterfaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentPanel: ChatTitleAccessoryPanelNode?, controllerInteraction: ChatControllerInteraction?, interfaceInteraction: ChatPanelInterfaceInteraction?, force: Bool) -> ChatTitleAccessoryPanelNode? {
if !force, case .standard(.embedded) = chatPresentationInterfaceState.mode {
return nil
}
if case .overlay = chatPresentationInterfaceState.mode {
return nil
}
if chatPresentationInterfaceState.renderedPeer?.peer?.restrictionText(platform: "ios", contentSettings: context.currentContentSettings.with { $0 }) != nil {
return nil
}
if let search = chatPresentationInterfaceState.search {
var matches = false
if chatPresentationInterfaceState.chatLocation.peerId == context.account.peerId {
if chatPresentationInterfaceState.hasSearchTags || !chatPresentationInterfaceState.isPremium {
if case .everything = search.domain {
matches = true
} else if case .tag = search.domain, search.query.isEmpty {
matches = true
}
}
}
if case .standard(.embedded) = chatPresentationInterfaceState.mode {
if !chatPresentationInterfaceState.isPremium {
matches = false
}
}
if matches {
if let currentPanel = currentPanel as? ChatSearchTitleAccessoryPanelNode {
return currentPanel
} else {
let panel = ChatSearchTitleAccessoryPanelNode(context: context, chatLocation: chatPresentationInterfaceState.chatLocation)
panel.interfaceInteraction = interfaceInteraction
return panel
}
} else {
return nil
}
}
var inhibitTitlePanelDisplay = false
switch chatPresentationInterfaceState.subject {
case .messageOptions:
return nil
case .scheduledMessages, .pinnedMessages:
inhibitTitlePanelDisplay = true
case let .customChatContents(customChatContents):
switch customChatContents.kind {
case .hashTagSearch:
break
case .quickReplyMessageInput:
break
case .businessLinkSetup:
if let currentPanel = currentPanel as? ChatBusinessLinkTitlePanelNode {
return currentPanel
} else {
let panel = ChatBusinessLinkTitlePanelNode(context: context)
panel.interfaceInteraction = interfaceInteraction
return panel
}
}
default:
break
}
if case .peer = chatPresentationInterfaceState.chatLocation {
} else {
inhibitTitlePanelDisplay = true
}
var selectedContext: ChatTitlePanelContext?
if !chatPresentationInterfaceState.titlePanelContexts.isEmpty {
loop: for context in chatPresentationInterfaceState.titlePanelContexts.reversed() {
switch context {
case .pinnedMessage:
if case .pinnedMessages = chatPresentationInterfaceState.subject {
} else {
if let pinnedMessage = chatPresentationInterfaceState.pinnedMessage, pinnedMessage.topMessageId != chatPresentationInterfaceState.interfaceState.messageActionsState.closedPinnedMessageId, !chatPresentationInterfaceState.pendingUnpinnedAllMessages {
selectedContext = context
break loop
}
}
case .requestInProgress, .toastAlert, .inviteRequests:
selectedContext = context
break loop
}
}
}
if inhibitTitlePanelDisplay, let selectedContextValue = selectedContext {
switch selectedContextValue {
case .pinnedMessage:
if case .peer = chatPresentationInterfaceState.chatLocation {
selectedContext = nil
}
break
default:
selectedContext = nil
}
}
if let _ = chatPresentationInterfaceState.peerVerification {
if let currentPanel = currentPanel as? ChatVerifiedPeerTitlePanelNode {
return currentPanel
} else if let controllerInteraction = controllerInteraction {
let panel = ChatVerifiedPeerTitlePanelNode(context: context, animationCache: controllerInteraction.presentationContext.animationCache, animationRenderer: controllerInteraction.presentationContext.animationRenderer)
panel.interfaceInteraction = interfaceInteraction
return panel
}
}
if let channel = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isForumOrMonoForum {
if let threadData = chatPresentationInterfaceState.threadData {
if threadData.isClosed {
var canManage = false
if channel.flags.contains(.isCreator) {
canManage = true
} else if channel.hasPermission(.manageTopics) {
canManage = true
} else if threadData.isOwnedByMe {
canManage = true
}
if canManage {
if let currentPanel = currentPanel as? ChatReportPeerTitlePanelNode {
return currentPanel
} else if let controllerInteraction = controllerInteraction {
let panel = ChatReportPeerTitlePanelNode(context: context, animationCache: controllerInteraction.presentationContext.animationCache, animationRenderer: controllerInteraction.presentationContext.animationRenderer)
panel.interfaceInteraction = interfaceInteraction
return panel
}
}
}
}
}
var displayActionsPanel = false
if !chatPresentationInterfaceState.peerIsBlocked && !inhibitTitlePanelDisplay, let contactStatus = chatPresentationInterfaceState.contactStatus {
if let peerStatusSettings = contactStatus.peerStatusSettings {
if !peerStatusSettings.flags.isEmpty {
if contactStatus.canAddContact && peerStatusSettings.contains(.canAddContact) {
displayActionsPanel = true
} else if peerStatusSettings.contains(.canReport) || peerStatusSettings.contains(.canBlock) || peerStatusSettings.contains(.autoArchived) {
displayActionsPanel = true
} else if peerStatusSettings.contains(.canShareContact) {
displayActionsPanel = true
} else if peerStatusSettings.contains(.suggestAddMembers) {
displayActionsPanel = true
}
}
if peerStatusSettings.requestChatTitle != nil {
displayActionsPanel = true
}
}
}
if (selectedContext == nil || selectedContext! <= .pinnedMessage) {
if displayActionsPanel {
if let currentPanel = currentPanel as? ChatReportPeerTitlePanelNode {
return currentPanel
} else if let controllerInteraction = controllerInteraction {
let panel = ChatReportPeerTitlePanelNode(context: context, animationCache: controllerInteraction.presentationContext.animationCache, animationRenderer: controllerInteraction.presentationContext.animationRenderer)
panel.interfaceInteraction = interfaceInteraction
return panel
}
} else if !chatPresentationInterfaceState.peerIsBlocked && !inhibitTitlePanelDisplay, let contactStatus = chatPresentationInterfaceState.contactStatus, contactStatus.managingBot != nil {
if let currentPanel = currentPanel as? ChatManagingBotTitlePanelNode {
return currentPanel
} else {
let panel = ChatManagingBotTitlePanelNode(context: context)
panel.interfaceInteraction = interfaceInteraction
return panel
}
}
}
if let selectedContext = selectedContext {
switch selectedContext {
case .pinnedMessage:
if let currentPanel = currentPanel as? ChatPinnedMessageTitlePanelNode {
return currentPanel
} else {
let panel = ChatPinnedMessageTitlePanelNode(context: context, animationCache: controllerInteraction?.presentationContext.animationCache, animationRenderer: controllerInteraction?.presentationContext.animationRenderer)
panel.interfaceInteraction = interfaceInteraction
return panel
}
case .requestInProgress:
if let currentPanel = currentPanel as? ChatRequestInProgressTitlePanelNode {
return currentPanel
} else {
let panel = ChatRequestInProgressTitlePanelNode()
panel.interfaceInteraction = interfaceInteraction
return panel
}
case let .toastAlert(text):
if let currentPanel = currentPanel as? ChatToastAlertPanelNode {
currentPanel.text = text
return currentPanel
} else {
let panel = ChatToastAlertPanelNode()
panel.text = text
panel.interfaceInteraction = interfaceInteraction
return panel
}
case let .inviteRequests(peers, count):
if let peerId = chatPresentationInterfaceState.renderedPeer?.peerId {
if let currentPanel = currentPanel as? ChatInviteRequestsTitlePanelNode {
currentPanel.update(peerId: peerId, peers: peers, count: count)
return currentPanel
} else {
let panel = ChatInviteRequestsTitlePanelNode(context: context)
panel.interfaceInteraction = interfaceInteraction
panel.update(peerId: peerId, peers: peers, count: count)
return panel
}
}
}
}
return nil
}
func floatingTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, controllerInteraction: ChatControllerInteraction?, interfaceInteraction: ChatPanelInterfaceInteraction?, force: Bool) -> ChatFloatingTopicsPanel? {
guard let peerId = chatPresentationInterfaceState.chatLocation.peerId else {
return nil
}
if chatPresentationInterfaceState.subject?.isService ?? false {
return nil
}
if peerId.namespace == Namespaces.Peer.CloudUser {
guard let chatHistoryState = chatPresentationInterfaceState.chatHistoryState else {
return nil
}
switch chatHistoryState {
case .loading:
return nil
case let .loaded(isEmpty, _):
if isEmpty && chatPresentationInterfaceState.chatLocation.threadId == nil {
return nil
}
}
}
if let channel = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum, let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = chatPresentationInterfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect), chatPresentationInterfaceState.search == nil {
let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation
return ChatFloatingTopicsPanel(
context: context,
theme: chatPresentationInterfaceState.theme,
strings: chatPresentationInterfaceState.strings,
location: topicListDisplayModeOnTheSide ? .side : .top,
peerId: peerId,
kind: .monoforum,
topicId: chatPresentationInterfaceState.chatLocation.threadId,
controller: { [weak interfaceInteraction] in
return interfaceInteraction?.chatController()
},
togglePanel: { [weak interfaceInteraction] in
interfaceInteraction?.toggleChatSidebarMode()
},
updateTopicId: { [weak interfaceInteraction] topicId, direction in
interfaceInteraction?.updateChatLocationThread(topicId, direction)
},
openDeletePeer: { [weak interfaceInteraction] threadId in
guard let controller = interfaceInteraction?.chatController() as? ChatControllerImpl else {
return
}
controller.openDeleteMonoforumPeer(peerId: EnginePeer.Id(threadId))
}
)
} else if let channel = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isForum, chatPresentationInterfaceState.search == nil {
let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation
return ChatFloatingTopicsPanel(
context: context,
theme: chatPresentationInterfaceState.theme,
strings: chatPresentationInterfaceState.strings,
location: topicListDisplayModeOnTheSide ? .side : .top,
peerId: peerId,
kind: .forum,
topicId: chatPresentationInterfaceState.chatLocation.threadId,
controller: { [weak interfaceInteraction] in
return interfaceInteraction?.chatController()
},
togglePanel: { [weak interfaceInteraction] in
interfaceInteraction?.toggleChatSidebarMode()
},
updateTopicId: { [weak interfaceInteraction] topicId, direction in
interfaceInteraction?.updateChatLocationThread(topicId, direction)
},
openDeletePeer: { [weak interfaceInteraction] threadId in
guard let controller = interfaceInteraction?.chatController() as? ChatControllerImpl else {
return
}
controller.openDeleteMonoforumPeer(peerId: EnginePeer.Id(threadId))
}
)
} else if let user = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramUser, user.isForum, chatPresentationInterfaceState.search == nil {
let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation
return ChatFloatingTopicsPanel(
context: context,
theme: chatPresentationInterfaceState.theme,
strings: chatPresentationInterfaceState.strings,
location: topicListDisplayModeOnTheSide ? .side : .top,
peerId: peerId,
kind: .botForum,
topicId: chatPresentationInterfaceState.chatLocation.threadId,
controller: { [weak interfaceInteraction] in
return interfaceInteraction?.chatController()
},
togglePanel: { [weak interfaceInteraction] in
interfaceInteraction?.toggleChatSidebarMode()
},
updateTopicId: { [weak interfaceInteraction] topicId, direction in
interfaceInteraction?.updateChatLocationThread(topicId, direction)
},
openDeletePeer: { [weak interfaceInteraction] threadId in
guard let controller = interfaceInteraction?.chatController() as? ChatControllerImpl else {
return
}
controller.openDeleteMonoforumPeer(peerId: EnginePeer.Id(threadId))
}
)
}
return nil
}
@@ -0,0 +1,258 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import TelegramCore
import TelegramPresentationData
import LocalizedPeerData
import TelegramStringFormatting
import TelegramNotices
import AnimatedAvatarSetNode
import AccountContext
import ChatPresentationInterfaceState
private final class ChatInfoTitlePanelPeerNearbyInfoNode: ASDisplayNode {
private var theme: PresentationTheme?
private let labelNode: ImmediateTextNode
private let filledBackgroundNode: LinkHighlightingNode
private let openPeersNearby: () -> Void
init(openPeersNearby: @escaping () -> Void) {
self.openPeersNearby = openPeersNearby
self.labelNode = ImmediateTextNode()
self.labelNode.maximumNumberOfLines = 1
self.labelNode.textAlignment = .center
self.filledBackgroundNode = LinkHighlightingNode(color: .clear)
super.init()
self.addSubnode(self.filledBackgroundNode)
self.addSubnode(self.labelNode)
}
override func didLoad() {
super.didLoad()
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.view.addGestureRecognizer(tapRecognizer)
}
@objc private func tapGesture(_ gestureRecognizer: UITapGestureRecognizer) {
self.openPeersNearby()
}
func update(width: CGFloat, theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper, chatPeer: Peer, distance: Int32, transition: ContainedViewLayoutTransition) -> CGFloat {
let primaryTextColor = serviceMessageColorComponents(theme: theme, wallpaper: wallpaper).primaryText
if self.theme !== theme {
self.theme = theme
self.labelNode.linkHighlightColor = primaryTextColor.withAlphaComponent(0.3)
}
let topInset: CGFloat = 6.0
let bottomInset: CGFloat = 6.0
let sideInset: CGFloat = 16.0
let stringAndRanges = strings.Conversation_PeerNearbyDistance(EnginePeer(chatPeer).compactDisplayTitle, shortStringForDistance(strings: strings, distance: distance))
let attributedString = NSMutableAttributedString(string: stringAndRanges.string, font: Font.regular(13.0), textColor: primaryTextColor)
let boldAttributes = [NSAttributedString.Key.font: Font.semibold(13.0), NSAttributedString.Key(rawValue: "_Link"): true as NSNumber]
for range in stringAndRanges.ranges.prefix(1) {
attributedString.addAttributes(boldAttributes, range: range.range)
}
self.labelNode.attributedText = attributedString
let labelLayout = self.labelNode.updateLayoutFullInfo(CGSize(width: width - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
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 backgroundLayout = self.filledBackgroundNode.asyncLayout()
let serviceColor = serviceMessageColorComponents(theme: theme, wallpaper: wallpaper)
let backgroundApply = backgroundLayout(serviceColor.fill, labelRects, 10.0, 10.0, 0.0)
backgroundApply()
let backgroundSize = CGSize(width: labelLayout.size.width + 8.0 + 8.0, height: labelLayout.size.height + 4.0)
let labelFrame = CGRect(origin: CGPoint(x: floor((width - labelLayout.size.width) / 2.0), y: topInset + floorToScreenPixels((backgroundSize.height - labelLayout.size.height) / 2.0) - 1.0), size: labelLayout.size)
self.labelNode.frame = labelFrame
self.filledBackgroundNode.frame = labelFrame.offsetBy(dx: 0.0, dy: -11.0)
return topInset + backgroundSize.height + bottomInset
}
}
final class ChatInviteRequestsTitlePanelNode: ChatTitleAccessoryPanelNode {
private final class Params {
let width: CGFloat
let leftInset: CGFloat
let rightInset: CGFloat
let interfaceState: ChatPresentationInterfaceState
init(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, interfaceState: ChatPresentationInterfaceState) {
self.width = width
self.leftInset = leftInset
self.rightInset = rightInset
self.interfaceState = interfaceState
}
}
private let context: AccountContext
private let separatorNode: ASDisplayNode
private let closeButton: HighlightableButtonNode
private let button: HighlightableButtonNode
private let buttonTitle: ImmediateTextNode
private let avatarsContext: AnimatedAvatarSetContext
private var avatarsContent: AnimatedAvatarSetContext.Content?
private let avatarsNode: AnimatedAvatarSetNode
private let activateAreaNode: AccessibilityAreaNode
private var theme: PresentationTheme?
private var peerId: PeerId?
private var peers: [EnginePeer] = []
private var count: Int32 = 0
private var params: Params?
init(context: AccountContext) {
self.context = context
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.closeButton = HighlightableButtonNode()
self.closeButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0)
self.closeButton.displaysAsynchronously = false
self.button = HighlightableButtonNode()
self.buttonTitle = ImmediateTextNode()
self.buttonTitle.anchorPoint = CGPoint()
self.avatarsContext = AnimatedAvatarSetContext()
self.avatarsNode = AnimatedAvatarSetNode()
self.activateAreaNode = AccessibilityAreaNode()
self.activateAreaNode.accessibilityTraits = .button
super.init()
self.addSubnode(self.separatorNode)
self.closeButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: [.touchUpInside])
self.addSubnode(self.closeButton)
self.button.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
self.addSubnode(self.button)
self.buttonTitle.isUserInteractionEnabled = false
self.button.addSubnode(self.buttonTitle)
self.addSubnode(self.avatarsNode)
self.addSubnode(self.activateAreaNode)
}
func update(peerId: PeerId, peers: [EnginePeer], count: Int32) {
self.peerId = peerId
self.peers = peers
self.count = count
self.avatarsContent = self.avatarsContext.update(peers: peers, animated: false)
if let params = self.params {
let _ = self.updateLayout(width: params.width, leftInset: params.leftInset, rightInset: params.rightInset, transition: .immediate, interfaceState: params.interfaceState)
}
}
override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> LayoutResult {
self.params = Params(width: width, leftInset: leftInset, rightInset: rightInset, interfaceState: interfaceState)
if interfaceState.theme !== self.theme {
self.theme = interfaceState.theme
self.closeButton.setImage(PresentationResourcesChat.chatInputPanelEncircledCloseIconImage(interfaceState.theme), for: [])
self.separatorNode.backgroundColor = interfaceState.theme.rootController.navigationBar.separatorColor
}
let panelHeight: CGFloat = 40.0
let contentRightInset: CGFloat = 14.0 + rightInset
let closeButtonSize = self.closeButton.measure(CGSize(width: 100.0, height: 100.0))
transition.updateFrame(node: self.closeButton, frame: CGRect(origin: CGPoint(x: width - contentRightInset - closeButtonSize.width, y: floorToScreenPixels((panelHeight - closeButtonSize.height) / 2.0)), size: closeButtonSize))
self.buttonTitle.attributedText = NSAttributedString(string: interfaceState.strings.Conversation_RequestsToJoin(self.count), font: Font.regular(16.0), textColor: interfaceState.theme.rootController.navigationBar.accentTextColor)
transition.updateFrame(node: self.button, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: panelHeight)))
let titleSize = self.buttonTitle.updateLayout(CGSize(width: width - leftInset - 90.0 - contentRightInset, height: 100.0))
var buttonTitleFrame = CGRect(origin: CGPoint(x: leftInset + floor((width - leftInset - titleSize.width) * 0.5), y: floor((panelHeight - titleSize.height) * 0.5)), size: titleSize)
buttonTitleFrame.origin.x = max(buttonTitleFrame.minX, leftInset + 90.0)
transition.updatePosition(node: self.buttonTitle, position: buttonTitleFrame.origin)
self.buttonTitle.bounds = CGRect(origin: CGPoint(), size: buttonTitleFrame.size)
let initialPanelHeight = panelHeight
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)))
if let avatarsContent = self.avatarsContent {
let avatarsSize = self.avatarsNode.update(context: self.context, content: avatarsContent, itemSize: CGSize(width: 32.0, height: 32.0), animated: true, synchronousLoad: true)
transition.updateFrame(node: self.avatarsNode, frame: CGRect(origin: CGPoint(x: leftInset + 8.0, y: floor((panelHeight - avatarsSize.height) / 2.0)), size: avatarsSize))
}
self.activateAreaNode.frame = CGRect(origin: .zero, size: CGSize(width: width, height: panelHeight))
self.activateAreaNode.accessibilityLabel = interfaceState.strings.Conversation_RequestsToJoin(self.count)
return LayoutResult(backgroundHeight: initialPanelHeight, insetHeight: panelHeight, hitTestSlop: 0.0)
}
@objc func buttonPressed() {
self.interfaceInteraction?.openInviteRequests()
}
@objc func closePressed() {
guard let peerId = self.peerId else {
return
}
let ids = peers.map { $0.id.toInt64() }
let _ = ApplicationSpecificNotice.setDismissedInvitationRequests(accountManager: context.sharedContext.accountManager, peerId: peerId, values: ids).startStandalone()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let result = self.closeButton.hitTest(CGPoint(x: point.x - self.closeButton.frame.minX, y: point.y - self.closeButton.frame.minY), with: event) {
return result
}
return super.hitTest(point, with: event)
}
}
@@ -0,0 +1,33 @@
import Foundation
import Postbox
import TelegramCore
import SwiftSignalKit
import TelegramNotices
final class InteractiveChatLinkPreviewsResult {
let f: (Bool) -> Void
init(_ f: @escaping (Bool) -> Void) {
self.f = f
}
}
func interactiveChatLinkPreviewsEnabled(accountManager: AccountManager<TelegramAccountManagerTypes>, displayAlert: @escaping (InteractiveChatLinkPreviewsResult) -> Void) -> Signal<Bool, NoError> {
return ApplicationSpecificNotice.getSecretChatLinkPreviews(accountManager: accountManager)
|> mapToSignal { value -> Signal<Bool, NoError> in
if let value = value {
return .single(value)
} else {
return Signal { subscriber in
Queue.mainQueue().async {
displayAlert(InteractiveChatLinkPreviewsResult({ result in
let _ = ApplicationSpecificNotice.setSecretChatLinkPreviews(accountManager: accountManager, value: result).startStandalone()
subscriber.putNext(result)
subscriber.putCompletion()
}))
}
return EmptyDisposable
}
}
}
}
@@ -0,0 +1,452 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramPresentationData
import ChatPresentationInterfaceState
import ComponentFlow
import AvatarNode
import MultilineTextComponent
import PlainButtonComponent
import ComponentDisplayAdapters
import AccountContext
import TelegramCore
import BundleIconComponent
import ContextUI
import SwiftSignalKit
private final class ChatManagingBotTitlePanelComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let insets: UIEdgeInsets
let peer: EnginePeer
let managesChat: Bool
let isPaused: Bool
let toggleIsPaused: () -> Void
let openSettings: (UIView) -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
insets: UIEdgeInsets,
peer: EnginePeer,
managesChat: Bool,
isPaused: Bool,
toggleIsPaused: @escaping () -> Void,
openSettings: @escaping (UIView) -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.insets = insets
self.peer = peer
self.managesChat = managesChat
self.isPaused = isPaused
self.toggleIsPaused = toggleIsPaused
self.openSettings = openSettings
}
static func ==(lhs: ChatManagingBotTitlePanelComponent, rhs: ChatManagingBotTitlePanelComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings != rhs.strings {
return false
}
if lhs.insets != rhs.insets {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.managesChat != rhs.managesChat {
return false
}
if lhs.isPaused != rhs.isPaused {
return false
}
return true
}
final class View: UIView {
private let title = ComponentView<Empty>()
private let text = ComponentView<Empty>()
private var avatarNode: AvatarNode?
private let actionButton = ComponentView<Empty>()
private let settingsButton = ComponentView<Empty>()
private var component: ChatManagingBotTitlePanelComponent?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: ChatManagingBotTitlePanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
let topInset: CGFloat = 6.0
let bottomInset: CGFloat = 6.0
let avatarDiameter: CGFloat = 36.0
let avatarTextSpacing: CGFloat = 10.0
let titleTextSpacing: CGFloat = 1.0
let leftInset: CGFloat = component.insets.left + 12.0
let rightInset: CGFloat = component.insets.right + 10.0
let actionAndSettingsButtonsSpacing: CGFloat = 8.0
let actionButtonSize = self.actionButton.update(
transition: transition,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.isPaused ? component.strings.Chat_BusinessBotPanel_ActionStart : component.strings.Chat_BusinessBotPanel_ActionStop, font: Font.semibold(15.0), textColor: component.theme.list.itemCheckColors.foregroundColor))
)),
background: AnyComponent(RoundedRectangle(
color: component.theme.list.itemCheckColors.fillColor,
cornerRadius: nil
)),
effectAlignment: .center,
contentInsets: UIEdgeInsets(top: 5.0, left: 12.0, bottom: 5.0, right: 12.0),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.toggleIsPaused()
},
animateAlpha: true,
animateScale: false,
animateContents: false
)),
environment: {},
containerSize: CGSize(width: 150.0, height: 100.0)
)
let settingsButtonSize = self.settingsButton.update(
transition: transition,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(BundleIconComponent(
name: "Chat/Context Menu/Customize",
tintColor: component.theme.rootController.navigationBar.controlColor
)),
effectAlignment: .center,
minSize: CGSize(width: 1.0, height: 40.0),
contentInsets: UIEdgeInsets(top: 0.0, left: 2.0, bottom: 0.0, right: 2.0),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
guard let settingsButtonView = self.settingsButton.view else {
return
}
component.openSettings(settingsButtonView)
},
animateAlpha: true,
animateScale: false,
animateContents: false
)),
environment: {},
containerSize: CGSize(width: 150.0, height: 100.0)
)
var maxTextWidth: CGFloat = availableSize.width - leftInset - avatarDiameter - avatarTextSpacing - rightInset - settingsButtonSize.width - 8.0
if component.managesChat {
maxTextWidth -= actionButtonSize.width - actionAndSettingsButtonsSpacing
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.peer.displayTitle(strings: component.strings, displayOrder: .firstLast), font: Font.semibold(16.0), textColor: component.theme.rootController.navigationBar.primaryTextColor))
)),
environment: {},
containerSize: CGSize(width: maxTextWidth, height: 100.0)
)
let textValue: String
if component.isPaused {
textValue = component.strings.Chat_BusinessBotPanel_StatusPaused
} else {
textValue = component.managesChat ? component.strings.Chat_BusinessBotPanel_StatusManages : component.strings.Chat_BusinessBotPanel_StatusHasAccess
}
let textSize = self.text.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: textValue, font: Font.regular(15.0), textColor: component.theme.rootController.navigationBar.secondaryTextColor))
)),
environment: {},
containerSize: CGSize(width: maxTextWidth, height: 100.0)
)
let size = CGSize(width: availableSize.width, height: topInset + titleSize.height + titleTextSpacing + textSize.height + bottomInset)
let titleFrame = CGRect(origin: CGPoint(x: leftInset + avatarDiameter + avatarTextSpacing, y: topInset), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.layer.anchorPoint = CGPoint()
self.addSubview(titleView)
}
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
transition.setPosition(view: titleView, position: titleFrame.origin)
}
let textFrame = CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY + titleTextSpacing), size: textSize)
if let textView = self.text.view {
if textView.superview == nil {
textView.layer.anchorPoint = CGPoint()
self.addSubview(textView)
}
textView.bounds = CGRect(origin: CGPoint(), size: textFrame.size)
transition.setPosition(view: textView, position: textFrame.origin)
}
let avatarFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - avatarDiameter) * 0.5)), size: CGSize(width: avatarDiameter, height: avatarDiameter))
let avatarNode: AvatarNode
if let current = self.avatarNode {
avatarNode = current
} else {
avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 15.0))
self.avatarNode = avatarNode
self.addSubview(avatarNode.view)
}
avatarNode.frame = avatarFrame
avatarNode.updateSize(size: avatarFrame.size)
avatarNode.setPeer(context: component.context, theme: component.theme, peer: component.peer)
let settingsButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - rightInset - settingsButtonSize.width, y: floor((size.height - settingsButtonSize.height) * 0.5)), size: settingsButtonSize)
if let settingsButtonView = self.settingsButton.view {
if settingsButtonView.superview == nil {
self.addSubview(settingsButtonView)
}
transition.setFrame(view: settingsButtonView, frame: settingsButtonFrame)
}
let actionButtonFrame = CGRect(origin: CGPoint(x: settingsButtonFrame.minX - actionAndSettingsButtonsSpacing - actionButtonSize.width, y: floor((size.height - actionButtonSize.height) * 0.5)), size: actionButtonSize)
if let actionButtonView = self.actionButton.view {
if actionButtonView.superview == nil {
self.addSubview(actionButtonView)
}
transition.setFrame(view: actionButtonView, frame: actionButtonFrame)
transition.setAlpha(view: actionButtonView, alpha: component.managesChat ? 1.0 : 0.0)
}
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
final class ChatManagingBotTitlePanelNode: ChatTitleAccessoryPanelNode {
private let context: AccountContext
private let separatorNode: ASDisplayNode
private let content = ComponentView<Empty>()
private var chatLocation: ChatLocation?
private var theme: PresentationTheme?
private var managingBot: ChatManagingBot?
init(context: AccountContext) {
self.context = context
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
super.init()
self.addSubnode(self.separatorNode)
}
private func toggleIsPaused() {
guard let chatPeerId = self.chatLocation?.peerId else {
return
}
let _ = self.context.engine.peers.toggleChatManagingBotIsPaused(chatId: chatPeerId)
}
private func openSettingsMenu(sourceView: UIView) {
guard let interfaceInteraction = self.interfaceInteraction else {
return
}
guard let chatController = interfaceInteraction.chatController() else {
return
}
guard let chatPeerId = self.chatLocation?.peerId else {
return
}
guard let managingBot = self.managingBot else {
return
}
let strings = self.context.sharedContext.currentPresentationData.with { $0 }.strings
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: strings.Chat_BusinessBotPanel_Menu_RemoveBot, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.contextMenu.destructiveColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
self.context.engine.peers.removeChatManagingBot(chatId: chatPeerId)
})))
if let url = managingBot.settingsUrl {
items.append(.action(ContextMenuActionItem(text: strings.Chat_BusinessBotPanel_Menu_ManageBot, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Settings"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
let _ = (self.context.sharedContext.resolveUrl(context: self.context, peerId: nil, url: url, skipUrlAuth: false)
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let self else {
return
}
guard let chatController = interfaceInteraction.chatController() else {
return
}
self.context.sharedContext.openResolvedUrl(
result,
context: self.context,
urlContext: .generic,
navigationController: chatController.navigationController as? NavigationController,
forceExternal: false,
forceUpdate: false,
openPeer: { [weak self] peer, navigation in
guard let self, let chatController = interfaceInteraction.chatController() else {
return
}
guard let navigationController = chatController.navigationController as? NavigationController else {
return
}
switch navigation {
case let .chat(_, subject, peekData):
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), subject: subject, peekData: peekData))
case let .withBotStartPayload(botStart):
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), botStart: botStart, keepStack: .always))
case let .withAttachBot(attachBotStart):
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), attachBotStart: attachBotStart))
case let .withBotApp(botAppStart):
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), botAppStart: botAppStart))
case .info:
let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peer.id))
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let self, let peer, let chatController = interfaceInteraction.chatController() else {
return
}
guard let navigationController = chatController.navigationController as? NavigationController else {
return
}
if let controller = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) {
navigationController.pushViewController(controller)
}
})
default:
break
}
},
sendFile: nil,
sendSticker: nil,
sendEmoji: nil,
requestMessageActionUrlAuth: nil,
joinVoiceChat: nil,
present: { [weak chatController] c, a in
chatController?.present(c, in: .window(.root), with: a)
},
dismissInput: {
},
contentContext: nil,
progress: nil,
completion: nil
)
})
})))
}
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let contextController = ContextController(presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: chatController, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil)
interfaceInteraction.presentController(contextController, nil)
}
override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> LayoutResult {
self.chatLocation = interfaceState.chatLocation
self.managingBot = interfaceState.contactStatus?.managingBot
if interfaceState.theme !== self.theme {
self.theme = interfaceState.theme
self.separatorNode.backgroundColor = interfaceState.theme.rootController.navigationBar.separatorColor
}
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)))
if let managingBot = interfaceState.contactStatus?.managingBot {
let contentSize = self.content.update(
transition: ComponentTransition(transition),
component: AnyComponent(ChatManagingBotTitlePanelComponent(
context: self.context,
theme: interfaceState.theme,
strings: interfaceState.strings,
insets: UIEdgeInsets(top: 0.0, left: leftInset, bottom: 0.0, right: rightInset),
peer: managingBot.bot,
managesChat: managingBot.canReply || managingBot.isPaused,
isPaused: managingBot.isPaused,
toggleIsPaused: { [weak self] in
guard let self else {
return
}
self.toggleIsPaused()
},
openSettings: { [weak self] sourceView in
guard let self else {
return
}
self.openSettingsMenu(sourceView: sourceView)
}
)),
environment: {},
containerSize: CGSize(width: width, height: 1000.0)
)
if let contentView = self.content.view {
if contentView.superview == nil {
self.view.addSubview(contentView)
}
transition.updateFrame(view: contentView, frame: CGRect(origin: CGPoint(), size: contentSize))
}
return LayoutResult(backgroundHeight: contentSize.height, insetHeight: contentSize.height, hitTestSlop: 0.0)
} else {
return LayoutResult(backgroundHeight: 0.0, insetHeight: 0.0, hitTestSlop: 0.0)
}
}
}
private final class HeaderContextReferenceContentSource: ContextReferenceContentSource {
private let controller: ViewController
private let sourceView: UIView
init(controller: ViewController, sourceView: UIView) {
self.controller = controller
self.sourceView = sourceView
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
@@ -0,0 +1,38 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramPresentationData
final class ChatMessageActionSheetController: ViewController {
var controllerNode: ChatMessageActionSheetControllerNode {
return self.displayNode as! ChatMessageActionSheetControllerNode
}
private let theme: PresentationTheme
private let actions: [ChatMessageContextMenuSheetAction]
private let dismissed: () -> Void
private weak var associatedController: ViewController?
init(theme: PresentationTheme, actions: [ChatMessageContextMenuSheetAction], dismissed: @escaping () -> Void, associatedController: ViewController?) {
self.theme = theme
self.actions = actions
self.dismissed = dismissed
self.associatedController = associatedController
super.init(navigationBarPresentationData: nil)
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadDisplayNode() {
self.displayNode = ChatMessageActionSheetControllerNode(theme: self.theme, actions: self.actions, dismissed: self.dismissed, associatedController: self.associatedController)
self.displayNodeDidLoad()
}
override func dismiss(completion: (() -> Void)? = nil) {
self.presentingViewController?.dismiss(animated: false, completion: nil)
}
}
@@ -0,0 +1,245 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramPresentationData
private let shadowInset: CGFloat = 8.0
private func generateShadowImage(theme: PresentationTheme) -> UIImage? {
return generateImage(CGSize(width: 32.0 + shadowInset * 2.0, height: 32.0 + shadowInset * 2.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setShadow(offset: CGSize(width: 0.0, height: 0.0), blur: 20.0, color: UIColor(white: 0.0, alpha: 0.2).cgColor)
context.setFillColor(theme.actionSheet.opaqueItemBackgroundColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowInset, y: shadowInset), size: CGSize(width: size.width - shadowInset * 2.0, height: size.height - shadowInset * 2.0)))
})?.stretchableImage(withLeftCapWidth: 16 + Int(shadowInset) / 2, topCapHeight: 16 + Int(shadowInset) / 2)
}
private final class MessageActionButtonNode: HighlightableButtonNode {
let theme: PresentationTheme
let separatorNode: ASDisplayNode
let backgroundNode: ASDisplayNode
init(theme: PresentationTheme) {
self.theme = theme
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.separatorNode.backgroundColor = theme.actionSheet.opaqueItemSeparatorColor
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.backgroundNode.alpha = 0.0
self.backgroundNode.backgroundColor = theme.actionSheet.opaqueItemHighlightedBackgroundColor
super.init()
self.setAttributedTitle(NSAttributedString(string: " "), for: [])
self.insertSubnode(self.separatorNode, at: 0)
self.insertSubnode(self.backgroundNode, at: 1)
self.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
if let supernode = strongSelf.titleNode.supernode {
strongSelf.titleNode.removeFromSupernode()
supernode.addSubnode(strongSelf.titleNode)
}
strongSelf.backgroundNode.layer.removeAnimation(forKey: "opacity")
strongSelf.backgroundNode.alpha = 1.0
} else {
strongSelf.backgroundNode.alpha = 0.0
strongSelf.backgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25)
}
}
}
}
override func layout() {
super.layout()
self.separatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.bounds.size.height - UIScreenPixel), size: CGSize(width: self.bounds.size.width, height: UIScreenPixel))
self.backgroundNode.frame = self.bounds
}
}
final class ChatMessageActionSheetControllerNode: ViewControllerTracingNode {
private let theme: PresentationTheme
private let sideDimNode: ASDisplayNode
private let sideInputDimNode: ASDisplayNode
private let inputDimNode: ASDisplayNode
private let itemsShadowNode: ASImageNode
private let itemsContainerNode: ASDisplayNode
private let actions: [ChatMessageContextMenuSheetAction]
private let dismissed: () -> Void
private weak var associatedController: ViewController?
private let actionNodes: [MessageActionButtonNode]
private let feedback = HapticFeedback()
private var validLayout: ContainerViewLayout?
init(theme: PresentationTheme, actions: [ChatMessageContextMenuSheetAction], dismissed: @escaping () -> Void, associatedController: ViewController?) {
self.theme = theme
self.actions = actions
self.dismissed = dismissed
self.associatedController = associatedController
self.sideDimNode = ASDisplayNode()
self.sideDimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
self.sideInputDimNode = ASDisplayNode()
self.sideInputDimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
self.inputDimNode = ASDisplayNode()
self.inputDimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
self.itemsShadowNode = ASImageNode()
self.itemsShadowNode.isLayerBacked = true
self.itemsShadowNode.displayWithoutProcessing = true
self.itemsShadowNode.displaysAsynchronously = false
self.itemsShadowNode.image = generateShadowImage(theme: theme)
self.itemsContainerNode = ASDisplayNode()
self.itemsContainerNode.backgroundColor = theme.actionSheet.opaqueItemBackgroundColor
self.itemsContainerNode.cornerRadius = 16.0
self.itemsContainerNode.clipsToBounds = true
self.actionNodes = actions.map { action in
let node = MessageActionButtonNode(theme: theme)
node.setAttributedTitle(NSAttributedString(string: action.title, font: Font.regular(20.0), textColor: action.color == .destructive ? theme.actionSheet.destructiveActionTextColor : theme.actionSheet.controlAccentColor), for: [])
return node
}
super.init()
self.addSubnode(self.sideDimNode)
self.addSubnode(self.sideInputDimNode)
self.addSubnode(self.inputDimNode)
self.addSubnode(self.itemsShadowNode)
self.addSubnode(self.itemsContainerNode)
for actionNode in actionNodes {
self.itemsContainerNode.addSubnode(actionNode)
actionNode.addTarget(self, action: #selector(actionPressed(_:)), forControlEvents: .touchUpInside)
}
self.feedback.prepareImpact(.light)
}
override func didLoad() {
super.didLoad()
self.sideDimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTap(_:))))
self.sideInputDimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTap(_:))))
self.inputDimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTap(_:))))
}
func animateIn(transition: ContainedViewLayoutTransition) {
self.inputDimNode.alpha = 0.0
self.sideInputDimNode.alpha = 0.0
self.sideDimNode.alpha = 0.0
transition.updateAlpha(node: self.inputDimNode, alpha: 1.0)
transition.updateAlpha(node: self.sideInputDimNode, alpha: 1.0)
transition.updateAlpha(node: self.sideDimNode, alpha: 1.0)
transition.animatePositionAdditive(node: self.itemsContainerNode, offset: CGPoint(x: 0.0, y: self.bounds.size.height))
transition.animatePositionAdditive(node: self.itemsShadowNode, offset: CGPoint(x: 0.0, y: self.bounds.size.height))
self.feedback.impact(.light)
}
func animateOut(transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
transition.updateAlpha(node: self.sideInputDimNode, alpha: 0.0)
transition.updateAlpha(node: self.sideDimNode, alpha: 0.0)
transition.updateAlpha(node: self.inputDimNode, alpha: 0.0)
let position = CGPoint(x: self.itemsContainerNode.position.x, y: self.bounds.size.height + self.itemsContainerNode.bounds.height)
transition.updatePosition(node: self.itemsContainerNode, position: position, completion: { _ in
completion()
})
transition.updatePosition(node: self.itemsShadowNode, position: position)
}
func updateLayout(layout: ContainerViewLayout, horizontalOrigin: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
self.validLayout = layout
var height: CGFloat = max(14.0, layout.intrinsicInsets.bottom)
let inputHeight = layout.inputHeight ?? 0.0
var horizontalOffset: CGFloat = horizontalOrigin
if !horizontalOffset.isZero {
horizontalOffset += UIScreenPixel
}
var isSlideOver = false
if case .compact = layout.metrics.widthClass, case .regular = layout.metrics.heightClass {
isSlideOver = true
}
transition.updateFrame(node: self.sideDimNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: max(0.0, horizontalOffset), height: max(0.0, layout.size.height - inputHeight))))
transition.updateFrame(node: self.sideInputDimNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - inputHeight), size: CGSize(width: max(0.0, horizontalOrigin), height: max(0.0, inputHeight))))
transition.updateFrame(node: self.inputDimNode, frame: CGRect(origin: CGPoint(x: horizontalOrigin, y: layout.size.height - inputHeight), size: CGSize(width: layout.size.width, height: inputHeight)))
height += layout.safeInsets.bottom
let containerWidth = horizontalContainerFillingSizeForLayout(layout: layout, sideInset: 7.0 * 2.0)
var itemsHeight: CGFloat = 0.0
for actionNode in self.actionNodes {
actionNode.frame = CGRect(origin: CGPoint(x: 0.0, y: itemsHeight), size: CGSize(width: containerWidth, height: 57.0))
actionNode.layout()
itemsHeight += actionNode.bounds.height
}
var containerFrame = CGRect(origin: CGPoint(x: horizontalOrigin + floor((layout.size.width - containerWidth) / 2.0), y: layout.size.height - height - itemsHeight), size: CGSize(width: containerWidth, height: itemsHeight))
if isSlideOver {
containerFrame = containerFrame.offsetBy(dx: 0.0, dy: -inputHeight)
}
transition.updateFrame(node: self.itemsContainerNode, frame: containerFrame)
transition.updateFrame(node: self.itemsShadowNode, frame: containerFrame.insetBy(dx: -shadowInset, dy: -shadowInset))
height += itemsHeight
if isSlideOver {
height += inputHeight
}
height += 6.0
return height
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.itemsContainerNode.frame.contains(point) {
let subpoint = self.view.convert(point, to: self.itemsContainerNode.view)
return itemsContainerNode.hitTest(subpoint, with: event)
}
if let validLayout = self.validLayout, let inputHeight = validLayout.inputHeight {
if point.y >= validLayout.size.height - inputHeight {
return self.inputDimNode.view
}
}
if let associatedController = self.associatedController {
let subpoint = self.view.convert(point, to: nil)
if let result = associatedController.view.hitTest(subpoint, with: event) {
return result
}
}
return self.inputDimNode.view
}
@objc func actionPressed(_ node: ASDisplayNode) {
for i in 0 ..< self.actionNodes.count {
if node == self.actionNodes[i] {
self.actions[i].action()
self.dismissed()
break
}
}
}
@objc func dimTap(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.dismissed()
}
}
}
@@ -0,0 +1,330 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import CheckNode
import TextFormat
import AccountContext
import Markdown
private let textFont = Font.regular(13.0)
private let boldTextFont = Font.semibold(13.0)
private func formattedText(_ text: String, color: UIColor, textAlignment: NSTextAlignment = .natural) -> NSAttributedString {
return parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: color), bold: MarkdownAttributeSet(font: boldTextFont, textColor: color), link: MarkdownAttributeSet(font: textFont, textColor: color), linkAttribute: { _ in return nil}), textAlignment: textAlignment)
}
private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode {
private let strings: PresentationStrings
private let nameDisplayOrder: PresentationPersonNameOrder
private let defaultUrl: String
private let domain: String
private let bot: Peer
private let displayName: String
private let titleNode: ASTextNode
private let textNode: ASTextNode
private let authorizeCheckNode: InteractiveCheckNode
private let authorizeLabelNode: ASTextNode
private let allowWriteCheckNode: InteractiveCheckNode
private let allowWriteLabelNode: ASTextNode
private let actionNodesSeparator: ASDisplayNode
private let actionNodes: [TextAlertContentActionNode]
private let actionVerticalSeparators: [ASDisplayNode]
private var validLayout: CGSize?
override var dismissOnOutsideTap: Bool {
return self.isUserInteractionEnabled
}
var authorize: Bool = true {
didSet {
self.authorizeCheckNode.setSelected(self.authorize, animated: true)
self.allowWriteCheckNode.isUserInteractionEnabled = self.authorize
self.allowWriteCheckNode.alpha = self.authorize ? 1.0 : 0.4
self.allowWriteLabelNode.alpha = self.authorize ? 1.0 : 0.4
if !self.authorize && self.allowWriteAccess {
self.allowWriteAccess = false
}
}
}
var allowWriteAccess: Bool = true {
didSet {
self.allowWriteCheckNode.setSelected(self.allowWriteAccess, animated: true)
}
}
init(theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, defaultUrl: String, domain: String, bot: Peer, requestWriteAccess: Bool, displayName: String, actions: [TextAlertAction]) {
self.strings = strings
self.nameDisplayOrder = nameDisplayOrder
self.defaultUrl = defaultUrl
self.domain = domain
self.bot = bot
self.displayName = displayName
self.titleNode = ASTextNode()
self.titleNode.maximumNumberOfLines = 2
self.textNode = ASTextNode()
self.textNode.maximumNumberOfLines = 0
self.authorizeCheckNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: theme.accentColor, strokeColor: theme.contrastColor, borderColor: theme.controlBorderColor, overlayBorder: false, hasInset: false, hasShadow: false))
self.authorizeCheckNode.setSelected(true, animated: false)
self.authorizeLabelNode = ASTextNode()
self.authorizeLabelNode.maximumNumberOfLines = 4
self.authorizeLabelNode.isUserInteractionEnabled = true
self.allowWriteCheckNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: theme.accentColor, strokeColor: theme.contrastColor, borderColor: theme.controlBorderColor, overlayBorder: false, hasInset: false, hasShadow: false))
self.allowWriteCheckNode.setSelected(true, animated: false)
self.allowWriteLabelNode = ASTextNode()
self.allowWriteLabelNode.maximumNumberOfLines = 4
self.allowWriteLabelNode.isUserInteractionEnabled = true
self.actionNodesSeparator = ASDisplayNode()
self.actionNodesSeparator.isLayerBacked = true
self.actionNodes = actions.map { action -> TextAlertContentActionNode in
return TextAlertContentActionNode(theme: theme, action: action)
}
var actionVerticalSeparators: [ASDisplayNode] = []
if actions.count > 1 {
for _ in 0 ..< actions.count - 1 {
let separatorNode = ASDisplayNode()
separatorNode.isLayerBacked = true
actionVerticalSeparators.append(separatorNode)
}
}
self.actionVerticalSeparators = actionVerticalSeparators
super.init()
self.addSubnode(self.titleNode)
self.addSubnode(self.textNode)
self.addSubnode(self.authorizeCheckNode)
self.addSubnode(self.authorizeLabelNode)
if requestWriteAccess {
self.addSubnode(self.allowWriteCheckNode)
self.addSubnode(self.allowWriteLabelNode)
}
self.addSubnode(self.actionNodesSeparator)
for actionNode in self.actionNodes {
self.addSubnode(actionNode)
}
for separatorNode in self.actionVerticalSeparators {
self.addSubnode(separatorNode)
}
self.authorizeCheckNode.valueChanged = { [weak self] value in
if let strongSelf = self {
strongSelf.authorize = !strongSelf.authorize
}
}
self.allowWriteCheckNode.valueChanged = { [weak self] value in
if let strongSelf = self {
strongSelf.allowWriteAccess = !strongSelf.allowWriteAccess
}
}
self.updateTheme(theme)
}
override func didLoad() {
super.didLoad()
self.authorizeLabelNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.authorizeTap(_:))))
self.allowWriteLabelNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.allowWriteTap(_:))))
}
@objc private func authorizeTap(_ gestureRecognizer: UITapGestureRecognizer) {
self.authorize = !self.authorize
}
@objc private func allowWriteTap(_ gestureRecognizer: UITapGestureRecognizer) {
if self.allowWriteCheckNode.isUserInteractionEnabled {
self.allowWriteAccess = !self.allowWriteAccess
}
}
override func updateTheme(_ theme: AlertControllerTheme) {
self.titleNode.attributedText = NSAttributedString(string: strings.Conversation_OpenBotLinkTitle, font: Font.bold(17.0), textColor: theme.primaryColor, paragraphAlignment: .center)
self.textNode.attributedText = formattedText(strings.Conversation_OpenBotLinkText(self.defaultUrl).string, color: theme.primaryColor, textAlignment: .center)
self.authorizeLabelNode.attributedText = formattedText(strings.Conversation_OpenBotLinkLogin(self.domain, self.displayName).string, color: theme.primaryColor)
self.allowWriteLabelNode.attributedText = formattedText(strings.Conversation_OpenBotLinkAllowMessages(EnginePeer(self.bot).displayTitle(strings: self.strings, displayOrder: self.nameDisplayOrder)).string, color: theme.primaryColor)
self.actionNodesSeparator.backgroundColor = theme.separatorColor
for actionNode in self.actionNodes {
actionNode.updateTheme(theme)
}
for separatorNode in self.actionVerticalSeparators {
separatorNode.backgroundColor = theme.separatorColor
}
if let size = self.validLayout {
_ = self.updateLayout(size: size, transition: .immediate)
}
}
override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
var size = size
size.width = min(size.width, 270.0)
let measureSize = CGSize(width: size.width - 16.0 * 2.0, height: CGFloat.greatestFiniteMagnitude)
self.validLayout = size
var origin: CGPoint = CGPoint(x: 0.0, y: 20.0)
let titleSize = self.titleNode.measure(measureSize)
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: origin.y), size: titleSize))
origin.y += titleSize.height + 9.0
let textSize = self.textNode.measure(measureSize)
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: origin.y), size: textSize))
origin.y += textSize.height + 16.0
let checkSize = CGSize(width: 22.0, height: 22.0)
let condensedSize = CGSize(width: size.width - 76.0, height: size.height)
var entriesHeight: CGFloat = 0.0
let authorizeSize = self.authorizeLabelNode.measure(condensedSize)
transition.updateFrame(node: self.authorizeLabelNode, frame: CGRect(origin: CGPoint(x: 46.0, y: origin.y), size: authorizeSize))
transition.updateFrame(node: self.authorizeCheckNode, frame: CGRect(origin: CGPoint(x: 12.0, y: origin.y - 2.0), size: checkSize))
origin.y += authorizeSize.height
entriesHeight += authorizeSize.height
if self.allowWriteLabelNode.supernode != nil {
origin.y += 16.0
entriesHeight += 16.0
let allowWriteSize = self.allowWriteLabelNode.measure(condensedSize)
transition.updateFrame(node: self.allowWriteLabelNode, frame: CGRect(origin: CGPoint(x: 46.0, y: origin.y), size: allowWriteSize))
transition.updateFrame(node: self.allowWriteCheckNode, frame: CGRect(origin: CGPoint(x: 12.0, y: origin.y - 2.0), size: checkSize))
origin.y += allowWriteSize.height
entriesHeight += allowWriteSize.height
}
let actionButtonHeight: CGFloat = 44.0
var minActionsWidth: CGFloat = 0.0
let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count))
let actionTitleInsets: CGFloat = 8.0
var effectiveActionLayout = TextAlertContentActionLayout.horizontal
for actionNode in self.actionNodes {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight))
if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 {
effectiveActionLayout = .vertical
}
switch effectiveActionLayout {
case .horizontal:
minActionsWidth += actionTitleSize.width + actionTitleInsets
case .vertical:
minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets)
}
}
let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 18.0, right: 18.0)
var contentWidth = max(titleSize.width, minActionsWidth)
contentWidth = max(contentWidth, 234.0)
var actionsHeight: CGFloat = 0.0
switch effectiveActionLayout {
case .horizontal:
actionsHeight = actionButtonHeight
case .vertical:
actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count)
}
let resultWidth = contentWidth + insets.left + insets.right
let resultSize = CGSize(width: resultWidth, height: titleSize.height + textSize.height + entriesHeight + actionsHeight + 30.0 + insets.top + insets.bottom)
transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
var actionOffset: CGFloat = 0.0
let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count))
var separatorIndex = -1
var nodeIndex = 0
for actionNode in self.actionNodes {
if separatorIndex >= 0 {
let separatorNode = self.actionVerticalSeparators[separatorIndex]
switch effectiveActionLayout {
case .horizontal:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel)))
case .vertical:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
}
}
separatorIndex += 1
let currentActionWidth: CGFloat
switch effectiveActionLayout {
case .horizontal:
if nodeIndex == self.actionNodes.count - 1 {
currentActionWidth = resultSize.width - actionOffset
} else {
currentActionWidth = actionWidth
}
case .vertical:
currentActionWidth = resultSize.width
}
let actionNodeFrame: CGRect
switch effectiveActionLayout {
case .horizontal:
actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += currentActionWidth
case .vertical:
actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += actionButtonHeight
}
transition.updateFrame(node: actionNode, frame: actionNodeFrame)
nodeIndex += 1
}
return resultSize
}
}
func chatMessageActionUrlAuthController(context: AccountContext, defaultUrl: String, domain: String, bot: Peer, requestWriteAccess: Bool, displayName: String, open: @escaping (Bool, Bool) -> Void) -> AlertController {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let theme = presentationData.theme
let strings = presentationData.strings
var contentNode: ChatMessageActionUrlAuthAlertContentNode?
var dismissImpl: ((Bool) -> Void)?
let actions: [TextAlertAction] = [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {
dismissImpl?(true)
}), TextAlertAction(type: .defaultAction, title: presentationData.strings.Conversation_OpenBotLinkOpen, action: {
dismissImpl?(true)
if let contentNode = contentNode {
open(contentNode.authorize, contentNode.allowWriteAccess)
}
})]
contentNode = ChatMessageActionUrlAuthAlertContentNode(theme: AlertControllerTheme(presentationData: presentationData), ptheme: theme, strings: strings, nameDisplayOrder: presentationData.nameDisplayOrder, defaultUrl: defaultUrl, domain: domain, bot: bot, requestWriteAccess: requestWriteAccess, displayName: displayName, actions: actions)
let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode!)
dismissImpl = { [weak controller] animated in
if animated {
controller?.dismissAnimated()
} else {
controller?.dismiss()
}
}
return controller
}
@@ -0,0 +1,525 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import ContextUI
import Postbox
import TelegramCore
import SwiftSignalKit
import ChatMessageItemView
import AccountContext
import WallpaperBackgroundNode
import TelegramPresentationData
import DustEffect
import TooltipUI
import TelegramNotices
final class ChatMessageContextLocationContentSource: ContextLocationContentSource {
private let controller: ViewController
private let location: CGPoint
init(controller: ViewController, location: CGPoint) {
self.controller = controller
self.location = location
}
func transitionInfo() -> ContextControllerLocationViewInfo? {
return ContextControllerLocationViewInfo(location: self.location, contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
final class ChatMessageContextExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool = false
let ignoreContentTouches: Bool = false
let blurBackground: Bool = true
let centerVertically: Bool
let keepDefaultContentTouches: Bool
private weak var chatController: ChatControllerImpl?
private weak var chatNode: ChatControllerNode?
private let engine: TelegramEngine
private let message: Message
private let selectAll: Bool
private let snapshot: Bool
var shouldBeDismissed: Signal<Bool, NoError> {
if self.message.adAttribute != nil {
return .single(false)
}
if let chatController = self.chatController, case .customChatContents = chatController.subject {
return .single(false)
}
return self.engine.data.subscribe(TelegramEngine.EngineData.Item.Messages.Message(id: self.message.id))
|> map { message -> Bool in
if let _ = message {
return false
} else {
return true
}
}
|> distinctUntilChanged
}
init(chatController: ChatControllerImpl, chatNode: ChatControllerNode, engine: TelegramEngine, message: Message, selectAll: Bool, centerVertically: Bool = false, keepDefaultContentTouches: Bool = false, snapshot: Bool = false) {
self.chatController = chatController
self.chatNode = chatNode
self.engine = engine
self.message = message
self.selectAll = selectAll
self.centerVertically = centerVertically
self.keepDefaultContentTouches = keepDefaultContentTouches
self.snapshot = snapshot
}
private(set) var snapshotView: UIView?
func takeView() -> ContextControllerTakeViewInfo? {
guard let chatNode = self.chatNode else {
return nil
}
var result: ContextControllerTakeViewInfo?
chatNode.historyNode.forEachItemNode { itemNode in
guard let itemNode = itemNode as? ChatMessageItemView else {
return
}
guard let item = itemNode.item else {
return
}
if item.content.contains(where: { $0.0.stableId == self.message.stableId }), let contentNode = itemNode.getMessageContextSourceNode(stableId: self.selectAll ? nil : self.message.stableId) {
result = ContextControllerTakeViewInfo(containingItem: .node(contentNode), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
if self.snapshot, let snapshotView = contentNode.contentNode.view.snapshotContentTree(unhide: false, keepPortals: true, keepTransform: true) {
contentNode.view.superview?.addSubview(snapshotView)
self.snapshotView = snapshotView
}
}
}
return result
}
func putBack() -> ContextControllerPutBackViewInfo? {
guard let chatNode = self.chatNode else {
return nil
}
var result: ContextControllerPutBackViewInfo?
chatNode.historyNode.forEachItemNode { itemNode in
guard let itemNode = itemNode as? ChatMessageItemView else {
return
}
guard let item = itemNode.item else {
return
}
if item.content.contains(where: { $0.0.stableId == self.message.stableId }) {
result = ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
}
}
return result
}
}
final class ChatViewOnceMessageContextExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool = false
let ignoreContentTouches: Bool = false
let blurBackground: Bool = true
let centerVertically: Bool = true
var initialAppearanceOffset: CGPoint = .zero
private let context: AccountContext
private let presentationData: PresentationData
private weak var chatNode: ChatControllerNode?
private weak var backgroundNode: WallpaperBackgroundNode?
private let engine: TelegramEngine
private let message: Message
private let present: (ViewController) -> Void
private var messageNodeCopy: ChatMessageItemView?
private weak var tooltipController: TooltipScreen?
private let idleTimerExtensionDisposable = MetaDisposable()
var shouldBeDismissed: Signal<Bool, NoError> {
return self.context.sharedContext.mediaManager.globalMediaPlayerState
|> filter { playlistStateAndType in
if let (_, state, _) = playlistStateAndType, case .state = state {
return true
} else {
return false
}
}
|> take(1)
|> map { _ in
return false
}
|> then(
self.context.sharedContext.mediaManager.globalMediaPlayerState
|> filter { playlistStateAndType in
return playlistStateAndType == nil
}
|> take(1)
|> map { _ in
return true
}
)
}
init(context: AccountContext, presentationData: PresentationData, chatNode: ChatControllerNode, backgroundNode: WallpaperBackgroundNode, engine: TelegramEngine, message: Message, present: @escaping (ViewController) -> Void) {
self.context = context
self.presentationData = presentationData
self.chatNode = chatNode
self.backgroundNode = backgroundNode
self.engine = engine
self.message = message
self.present = present
}
deinit {
self.idleTimerExtensionDisposable.dispose()
}
func takeView() -> ContextControllerTakeViewInfo? {
guard let chatNode = self.chatNode, let backgroundNode = self.backgroundNode, let validLayout = chatNode.validLayout?.0 else {
return nil
}
let context = self.context
self.idleTimerExtensionDisposable.set(self.context.sharedContext.applicationBindings.pushIdleTimerExtension())
let isIncoming = self.message.effectivelyIncoming(self.context.account.peerId)
let isVideo = (self.message.media.first(where: { $0 is TelegramMediaFile }) as? TelegramMediaFile)?.isInstantVideo ?? false
var result: ContextControllerTakeViewInfo?
var sourceNode: ContextExtractedContentContainingNode?
var sourceRect: CGRect = .zero
chatNode.historyNode.forEachItemNode { itemNode in
guard let itemNode = itemNode as? ChatMessageItemView else {
return
}
guard let item = itemNode.item else {
return
}
if item.content.contains(where: { $0.0.stableId == self.message.stableId }), let contentNode = itemNode.getMessageContextSourceNode(stableId: self.message.stableId) {
sourceNode = contentNode
sourceRect = contentNode.view.convert(contentNode.bounds, to: chatNode.view)
if !isVideo {
sourceRect.origin.y -= 2.0 + UIScreenPixel
}
}
}
var tooltipSourceRect: CGRect = .zero
if let sourceNode {
let videoWidth = min(404.0, chatNode.frame.width - 2.0)
var bubbleWidth: CGFloat = 0.0
if (isIncoming || "".isEmpty) {
let messageItem = self.context.sharedContext.makeChatMessagePreviewItem(
context: self.context,
messages: [self.message],
theme: self.presentationData.theme,
strings: self.presentationData.strings,
wallpaper: self.presentationData.chatWallpaper,
fontSize: self.presentationData.chatFontSize,
chatBubbleCorners: self.presentationData.chatBubbleCorners,
dateTimeFormat: self.presentationData.dateTimeFormat,
nameOrder: self.presentationData.nameDisplayOrder,
forcedResourceStatus: nil,
tapMessage: nil,
clickThroughMessage: nil,
backgroundNode: backgroundNode,
availableReactions: nil,
accountPeer: nil,
isCentered: false,
isPreview: false,
isStandalone: true
)
let width = chatNode.historyNode.frame.width
let params = ListViewItemLayoutParams(width: width, leftInset: validLayout.safeInsets.left, rightInset: validLayout.safeInsets.right, availableHeight: chatNode.historyNode.frame.height, isStandalone: false)
var node: ListViewItemNode?
messageItem.nodeConfiguredForParams(async: { $0() }, params: params, synchronousLoads: false, previousItem: nil, nextItem: nil, completion: { messageNode, apply in
node = messageNode
apply().1(ListViewItemApply(isOnScreen: true))
})
if let messageNode = node as? ChatMessageItemView, let copyContentNode = messageNode.getMessageContextSourceNode(stableId: self.message.stableId) {
if isVideo {
self.initialAppearanceOffset = CGPoint(x: 0.0, y: min(videoWidth, width - 20.0) - copyContentNode.frame.height)
}
messageNode.frame.origin.y = sourceRect.origin.y
chatNode.addSubnode(messageNode)
result = ContextControllerTakeViewInfo(containingItem: .node(copyContentNode), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
bubbleWidth = copyContentNode.contentNode.subnodes?.first?.frame.width ?? messageNode.frame.width
if isVideo {
messageItem.updateNode(async: { $0() }, node: { return messageNode }, params: params, previousItem: nil, nextItem: nil, animation: .System(duration: 0.4, transition: ControlledTransition(duration: 0.4, curve: .spring, interactive: false)), completion: { (layout, apply) in
apply(ListViewItemApply(isOnScreen: true))
})
}
}
self.messageNodeCopy = node as? ChatMessageItemView
} else {
result = ContextControllerTakeViewInfo(containingItem: .node(sourceNode), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
}
let mappedParentRect = chatNode.view.convert(chatNode.bounds, to: nil)
if isVideo {
tooltipSourceRect = CGRect(x: mappedParentRect.minX + (isIncoming ? videoWidth / 2.0 : chatNode.frame.width - videoWidth / 2.0), y: floorToScreenPixels((chatNode.frame.height - videoWidth) / 2.0) + 8.0, width: 0.0, height: 0.0)
} else {
tooltipSourceRect = CGRect(x: mappedParentRect.minX + (isIncoming ? 22.0 : chatNode.frame.width - bubbleWidth + 10.0), y: floorToScreenPixels((chatNode.frame.height - 75.0) / 2.0) - 43.0, width: 44.0, height: 44.0)
}
}
let displayTooltip = { [weak self] in
guard let self else {
return
}
let absoluteFrame = tooltipSourceRect
let location = CGRect(origin: CGPoint(x: absoluteFrame.midX, y: absoluteFrame.maxY), size: CGSize())
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
var tooltipText: String?
if isIncoming {
if isVideo {
tooltipText = presentationData.strings.Chat_PlayOnceVideoMessageTooltip
} else {
tooltipText = presentationData.strings.Chat_PlayOnceVoiceMessageTooltip
}
} else if let peer = self.message.peers[self.message.id.peerId] {
let peerName = EnginePeer(peer).compactDisplayTitle
if isVideo {
tooltipText = presentationData.strings.Chat_PlayOnceVideoMessageYourTooltip(peerName).string
} else {
tooltipText = presentationData.strings.Chat_PlayOnceVoiceMessageYourTooltip(peerName).string
}
}
if let tooltipText {
let tooltipController = TooltipScreen(
account: self.context.account,
sharedContext: self.context.sharedContext,
text: .markdown(text: tooltipText),
balancedTextLayout: true,
constrainWidth: 240.0,
style: .customBlur(UIColor(rgb: 0x18181a), 0.0),
arrowStyle: .small,
icon: nil,
location: .point(location, .bottom),
displayDuration: .custom(3.0),
inset: 8.0,
cornerRadius: 11.0,
shouldDismissOnTouch: { _, _ in
return .ignore
}
)
self.tooltipController = tooltipController
self.present(tooltipController)
}
}
let tooltipStateSignal: Signal<Int32, NoError>
let updateTooltipState: () -> Void
if isVideo {
if isIncoming {
tooltipStateSignal = ApplicationSpecificNotice.getIncomingVideoMessagePlayOnceTip(accountManager: context.sharedContext.accountManager)
updateTooltipState = {
let _ = ApplicationSpecificNotice.incrementIncomingVideoMessagePlayOnceTip(accountManager: context.sharedContext.accountManager).startStandalone()
}
} else {
tooltipStateSignal = ApplicationSpecificNotice.getOutgoingVideoMessagePlayOnceTip(accountManager: context.sharedContext.accountManager)
updateTooltipState = {
let _ = ApplicationSpecificNotice.incrementOutgoingVideoMessagePlayOnceTip(accountManager: context.sharedContext.accountManager).startStandalone()
}
}
} else {
if isIncoming {
tooltipStateSignal = ApplicationSpecificNotice.getIncomingVoiceMessagePlayOnceTip(accountManager: context.sharedContext.accountManager)
updateTooltipState = {
let _ = ApplicationSpecificNotice.incrementIncomingVoiceMessagePlayOnceTip(accountManager: context.sharedContext.accountManager).startStandalone()
}
} else {
tooltipStateSignal = ApplicationSpecificNotice.getOutgoingVoiceMessagePlayOnceTip(accountManager: context.sharedContext.accountManager)
updateTooltipState = {
let _ = ApplicationSpecificNotice.incrementOutgoingVoiceMessagePlayOnceTip(accountManager: context.sharedContext.accountManager).startStandalone()
}
}
}
let _ = (tooltipStateSignal
|> deliverOnMainQueue).startStandalone(next: { counter in
if counter >= 2 {
return
}
Queue.mainQueue().after(0.3) {
displayTooltip()
}
updateTooltipState()
})
return result
}
private var dustEffectLayer: DustEffectLayer?
func putBack() -> ContextControllerPutBackViewInfo? {
guard let chatNode = self.chatNode else {
return nil
}
self.idleTimerExtensionDisposable.set(nil)
if let tooltipController = self.tooltipController {
tooltipController.dismiss()
}
if let messageNodeCopy = self.messageNodeCopy, let sourceView = messageNodeCopy.supernode?.view, let contentNode = messageNodeCopy.getMessageContextSourceNode(stableId: nil)?.contentNode, let parentNode = contentNode.supernode?.supernode?.supernode {
let dustEffectLayer = DustEffectLayer()
dustEffectLayer.position = sourceView.bounds.center.offsetBy(dx: (parentNode.frame.width - messageNodeCopy.frame.width), dy: 0.0)
dustEffectLayer.bounds = CGRect(origin: CGPoint(), size: sourceView.bounds.size)
dustEffectLayer.zPosition = 10.0
parentNode.layer.addSublayer(dustEffectLayer)
guard let (image, subFrame) = messageNodeCopy.makeContentSnapshot() else {
return nil
}
var itemFrame = subFrame
itemFrame.origin.y = floorToScreenPixels((sourceView.frame.height - subFrame.height) / 2.0)
dustEffectLayer.addItem(frame: itemFrame, image: image)
messageNodeCopy.removeFromSupernode()
contentNode.removeFromSupernode()
return nil
} else {
var result: ContextControllerPutBackViewInfo?
chatNode.historyNode.forEachItemNode { itemNode in
guard let itemNode = itemNode as? ChatMessageItemView else {
return
}
guard let item = itemNode.item else {
return
}
if item.content.contains(where: { $0.0.stableId == self.message.stableId }) {
result = ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
}
}
return result
}
}
}
final class ChatMessageReactionContextExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool = false
let ignoreContentTouches: Bool = true
let blurBackground: Bool = true
let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center
private weak var chatNode: ChatControllerNode?
private let engine: TelegramEngine
private let message: Message
private let contentView: ContextExtractedContentContainingView
var shouldBeDismissed: Signal<Bool, NoError> {
if self.message.adAttribute != nil {
return .single(false)
}
return self.engine.data.subscribe(TelegramEngine.EngineData.Item.Messages.Message(id: self.message.id))
|> map { message -> Bool in
if let _ = message {
return false
} else {
return true
}
}
|> distinctUntilChanged
}
init(chatNode: ChatControllerNode, engine: TelegramEngine, message: Message, contentView: ContextExtractedContentContainingView) {
self.chatNode = chatNode
self.engine = engine
self.message = message
self.contentView = contentView
}
func takeView() -> ContextControllerTakeViewInfo? {
guard let chatNode = self.chatNode else {
return nil
}
var result: ContextControllerTakeViewInfo?
chatNode.historyNode.forEachItemNode { itemNode in
guard let itemNode = itemNode as? ChatMessageItemView else {
return
}
guard let item = itemNode.item else {
return
}
if item.content.contains(where: { $0.0.stableId == self.message.stableId }) {
result = ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
}
}
return result
}
func putBack() -> ContextControllerPutBackViewInfo? {
guard let chatNode = self.chatNode else {
return nil
}
var result: ContextControllerPutBackViewInfo?
chatNode.historyNode.forEachItemNode { itemNode in
guard let itemNode = itemNode as? ChatMessageItemView else {
return
}
guard let item = itemNode.item else {
return
}
if item.content.contains(where: { $0.0.stableId == self.message.stableId }) {
result = ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
}
}
return result
}
}
final class ChatMessageNavigationButtonContextExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool = false
let ignoreContentTouches: Bool = true
let blurBackground: Bool = true
let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center
private weak var chatNode: ChatControllerNode?
private let contentNode: ContextExtractedContentContainingNode
var shouldBeDismissed: Signal<Bool, NoError> {
return .single(false)
}
init(chatNode: ChatControllerNode, contentNode: ContextExtractedContentContainingNode) {
self.chatNode = chatNode
self.contentNode = contentNode
}
func takeView() -> ContextControllerTakeViewInfo? {
guard let chatNode = self.chatNode else {
return nil
}
return ContextControllerTakeViewInfo(containingItem: .node(self.contentNode), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
}
func putBack() -> ContextControllerPutBackViewInfo? {
guard let chatNode = self.chatNode else {
return nil
}
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
}
}
@@ -0,0 +1,105 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import AppBundle
import ChatPresentationInterfaceState
import ChatInputPanelNode
final class ChatMessageReportInputPanelNode: ChatInputPanelNode {
private let reportButton: HighlightableButtonNode
private let separatorNode: ASDisplayNode
private var validLayout: (width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, additionalSideInsets: UIEdgeInsets, maxHeight: CGFloat, maxOverlayHeight: CGFloat, metrics: LayoutMetrics, isSecondary: Bool)?
private var presentationInterfaceState: ChatPresentationInterfaceState?
private var theme: PresentationTheme
private var strings: PresentationStrings
private let peerMedia: Bool
var selectedMessages = Set<MessageId>() {
didSet {
if oldValue != self.selectedMessages {
self.reportButton.isEnabled = self.selectedMessages.count != 0
}
}
}
init(theme: PresentationTheme, strings: PresentationStrings, peerMedia: Bool = false) {
self.theme = theme
self.strings = strings
self.peerMedia = peerMedia
self.reportButton = HighlightableButtonNode(pointerStyle: .default)
self.reportButton.isAccessibilityElement = true
self.reportButton.accessibilityLabel = strings.VoiceOver_MessageContextReport
self.separatorNode = ASDisplayNode()
self.separatorNode.backgroundColor = theme.chat.inputPanel.panelSeparatorColor
super.init()
self.addSubnode(self.reportButton)
self.addSubnode(self.separatorNode)
self.reportButton.addTarget(self, action: #selector(self.reportButtonPressed), forControlEvents: .touchUpInside)
}
func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) {
if self.theme !== theme || self.strings !== strings {
self.theme = theme
self.strings = strings
self.reportButton.setAttributedTitle(NSAttributedString(string: self.reportButton.attributedTitle(for: [])?.string ?? "", font: Font.regular(17.0), textColor: theme.chat.inputPanel.panelControlAccentColor), for: [])
self.reportButton.setAttributedTitle(NSAttributedString(string: self.reportButton.attributedTitle(for: [])?.string ?? "", font: Font.regular(17.0), textColor: theme.chat.inputPanel.panelControlDisabledColor), for: .disabled)
}
}
@objc func reportButtonPressed() {
self.interfaceInteraction?.reportSelectedMessages()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.bounds.contains(point) {
return self.reportButton.view
} else {
return nil
}
}
override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, additionalSideInsets: UIEdgeInsets, maxHeight: CGFloat, maxOverlayHeight: CGFloat, isSecondary: Bool, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics, isMediaInputExpanded: Bool) -> CGFloat {
if self.presentationInterfaceState != interfaceState {
self.presentationInterfaceState = interfaceState
let string = NSAttributedString(string: self.strings.Conversation_ReportMessages, font: Font.regular(17.0), textColor: self.theme.chat.inputPanel.panelControlAccentColor)
let updated: Bool
if let current = self.reportButton.attributedTitle(for: []) {
updated = !current.isEqual(to: string)
} else {
updated = true
}
if updated {
self.reportButton.setAttributedTitle(string, for: [])
self.reportButton.setAttributedTitle(NSAttributedString(string: self.reportButton.attributedTitle(for: [])?.string ?? "", font: Font.regular(17.0), textColor: self.theme.chat.inputPanel.panelControlDisabledColor), for: .disabled)
}
self.reportButton.isEnabled = self.selectedMessages.count != 0
}
let buttonSize = self.reportButton.measure(CGSize(width: width - leftInset - rightInset - 80.0, height: 100.0))
let panelHeight = defaultHeight(metrics: metrics)
self.reportButton.frame = CGRect(origin: CGPoint(x: leftInset + floor((width - leftInset - rightInset - buttonSize.width) / 2.0), y: floor((panelHeight - buttonSize.height) / 2.0)), size: buttonSize)
return panelHeight
}
override func minimalHeight(interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics) -> CGFloat {
return defaultHeight(metrics: metrics)
}
}
@@ -0,0 +1,126 @@
import Foundation
import UIKit
import Postbox
import SwiftSignalKit
final class ChatMessageThrottledProcessingManager {
private let queue = Queue.mainQueue()
private let delay: Double
private let submitInterval: Double?
var process: ((Set<MessageAndThreadId>) -> Void)?
private var timer: SwiftSignalKit.Timer?
private var processedList: [MessageAndThreadId] = []
private var processed: [MessageAndThreadId: Double] = [:]
private var buffer = Set<MessageAndThreadId>()
init(delay: Double = 1.0, submitInterval: Double? = nil) {
self.delay = delay
self.submitInterval = submitInterval
}
func setProcess(process: @escaping (Set<MessageAndThreadId>) -> Void) {
self.queue.async {
self.process = process
}
}
func add(_ messageIds: [MessageAndThreadId]) {
self.queue.async {
let timestamp = CFAbsoluteTimeGetCurrent()
for id in messageIds {
if let processedTimestamp = self.processed[id] {
if let submitInterval = self.submitInterval, (submitInterval.isZero || (timestamp - processedTimestamp) >= submitInterval) {
self.processed[id] = timestamp
self.processedList.append(id)
self.buffer.insert(id)
}
} else {
self.processed[id] = timestamp
self.processedList.append(id)
self.buffer.insert(id)
}
}
if self.processedList.count > 1000 {
for i in 0 ..< 200 {
self.processed.removeValue(forKey: self.processedList[i])
}
self.processedList.removeSubrange(0 ..< 200)
}
if self.timer == nil {
var completionImpl: (() -> Void)?
let timer = SwiftSignalKit.Timer(timeout: self.delay, repeat: false, completion: {
completionImpl?()
}, queue: self.queue)
completionImpl = { [weak self, weak timer] in
if let strongSelf = self {
if let timer = timer, strongSelf.timer === timer {
strongSelf.timer = nil
}
let buffer = strongSelf.buffer
strongSelf.buffer.removeAll()
strongSelf.process?(buffer)
}
}
self.timer = timer
timer.start()
}
}
}
}
final class ChatMessageVisibleThrottledProcessingManager {
private let queue = Queue.mainQueue()
private let interval: Double
private var currentIds = Set<MessageAndThreadId>()
var process: ((Set<MessageAndThreadId>) -> Void)?
private let timer: SwiftSignalKit.Timer
init(interval: Double = 5.0) {
self.interval = interval
var completionImpl: (() -> Void)?
let timer = SwiftSignalKit.Timer(timeout: self.interval, repeat: true, completion: {
completionImpl?()
}, queue: self.queue)
self.timer = timer
timer.start()
completionImpl = { [weak self] in
if let strongSelf = self, !strongSelf.currentIds.isEmpty {
strongSelf.process?(strongSelf.currentIds)
}
}
}
deinit {
self.timer.invalidate()
}
func setProcess(process: @escaping (Set<MessageAndThreadId>) -> Void) {
self.queue.async {
self.process = process
}
}
func update(_ ids: Set<MessageAndThreadId>) {
self.queue.async {
if self.currentIds != ids {
self.currentIds = ids
if !self.currentIds.isEmpty {
self.process?(self.currentIds)
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
private let titleFont = Font.semibold(14.0)
final class ChatOverlayNavigationBar: ASDisplayNode {
private let theme: PresentationTheme
private let strings: PresentationStrings
private let nameDisplayOrder: PresentationPersonNameOrder
private let tapped: () -> Void
private let close: () -> Void
private let separatorNode: ASDisplayNode
private let titleNode: TextNode
private let closeButton: HighlightableButtonNode
private var validLayout: CGSize?
private var peerTitle: String = ""
var title: String? {
didSet {
let title = self.title ?? ""
if self.peerTitle != title {
self.peerTitle = title
if let size = self.validLayout {
self.updateLayout(size: size, transition: .immediate)
}
}
}
}
init(theme: PresentationTheme, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, tapped: @escaping () -> Void, close: @escaping () -> Void) {
self.theme = theme
self.strings = strings
self.nameDisplayOrder = nameDisplayOrder
self.tapped = tapped
self.close = close
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.separatorNode.backgroundColor = theme.inAppNotification.expandedNotification.navigationBar.separatorColor
self.titleNode = TextNode()
self.titleNode.displaysAsynchronously = false
self.titleNode.isUserInteractionEnabled = false
self.closeButton = HighlightableButtonNode()
self.closeButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0)
self.closeButton.displaysAsynchronously = false
let closeImage = generateImage(CGSize(width: 12.0, height: 12.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setStrokeColor(theme.inAppNotification.expandedNotification.navigationBar.controlColor.cgColor)
context.setLineWidth(2.0)
context.setLineCap(.round)
context.move(to: CGPoint(x: 1.0, y: 1.0))
context.addLine(to: CGPoint(x: size.width - 1.0, y: size.height - 1.0))
context.strokePath()
context.move(to: CGPoint(x: size.width - 1.0, y: 1.0))
context.addLine(to: CGPoint(x: 1.0, y: size.height - 1.0))
context.strokePath()
})
self.closeButton.setImage(closeImage, for: [])
super.init()
self.backgroundColor = theme.inAppNotification.expandedNotification.navigationBar.backgroundColor
self.addSubnode(self.separatorNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.closeButton)
self.closeButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: [.touchUpInside])
}
override func didLoad() {
super.didLoad()
let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.handleTap))
self.view.addGestureRecognizer(gestureRecognizer)
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height - UIScreenPixel), size: CGSize(width: size.width, height: UIScreenPixel)))
let sideInset: CGFloat = 10.0
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: self.peerTitle, font: titleFont, textColor: self.theme.inAppNotification.expandedNotification.navigationBar.primaryTextColor), maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: size.width - sideInset * 2.0 - 40.0, height: size.height)))
let _ = titleApply()
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - titleLayout.size.height) / 2.0)), size: titleLayout.size))
let closeButtonSize = CGSize(width: size.height, height: size.height)
transition.updateFrame(node: self.closeButton, frame: CGRect(origin: CGPoint(x: size.width - sideInset - closeButtonSize.width + 10.0, y: 0.0), size: closeButtonSize))
}
@objc private func handleTap() {
self.tapped()
}
@objc private func closePressed() {
self.close()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import Postbox
import SwiftSignalKit
import TelegramNotices
import TelegramPresentationData
import ActivityIndicator
import ChatPresentationInterfaceState
import ChatInputPanelNode
import ComponentFlow
import MultilineTextComponent
import PlainButtonComponent
import ComponentDisplayAdapters
import AccountContext
private let labelFont = Font.regular(15.0)
final class ChatPremiumRequiredInputPanelNode: ChatInputPanelNode {
private struct Params: Equatable {
var width: CGFloat
var leftInset: CGFloat
var rightInset: CGFloat
var bottomInset: CGFloat
var additionalSideInsets: UIEdgeInsets
var maxHeight: CGFloat
var maxOverlayHeight: CGFloat
var isSecondary: Bool
var interfaceState: ChatPresentationInterfaceState
var metrics: LayoutMetrics
var isMediaInputExpanded: Bool
init(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, additionalSideInsets: UIEdgeInsets, maxHeight: CGFloat, maxOverlayHeight: CGFloat, isSecondary: Bool, interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics, isMediaInputExpanded: Bool) {
self.width = width
self.leftInset = leftInset
self.rightInset = rightInset
self.bottomInset = bottomInset
self.additionalSideInsets = additionalSideInsets
self.maxHeight = maxHeight
self.maxOverlayHeight = maxOverlayHeight
self.isSecondary = isSecondary
self.interfaceState = interfaceState
self.metrics = metrics
self.isMediaInputExpanded = isMediaInputExpanded
}
}
private struct Layout {
var params: Params
var height: CGFloat
init(params: Params, height: CGFloat) {
self.params = params
self.height = height
}
}
private let button = ComponentView<Empty>()
private var params: Params?
private var currentLayout: Layout?
override var interfaceInteraction: ChatPanelInterfaceInteraction? {
didSet {
}
}
init(theme: PresentationTheme) {
super.init()
}
deinit {
}
override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, additionalSideInsets: UIEdgeInsets, maxHeight: CGFloat, maxOverlayHeight: CGFloat, isSecondary: Bool, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics, isMediaInputExpanded: Bool) -> CGFloat {
let params = Params(width: width, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, additionalSideInsets: additionalSideInsets, maxHeight: maxHeight, maxOverlayHeight: maxOverlayHeight, isSecondary: isSecondary, interfaceState: interfaceState, metrics: metrics, isMediaInputExpanded: isMediaInputExpanded)
if let currentLayout = self.currentLayout, currentLayout.params == params {
return currentLayout.height
}
let height = self.update(params: params, transition: ComponentTransition(transition))
self.currentLayout = Layout(params: params, height: height)
return height
}
private func update(params: Params, transition: ComponentTransition) -> CGFloat {
let height: CGFloat
if case .regular = params.metrics.widthClass {
height = 49.0
} else {
height = 45.0
}
let peerTitle: String
if let peer = params.interfaceState.renderedPeer?.chatMainPeer {
peerTitle = EnginePeer(peer).compactDisplayTitle
} else {
peerTitle = " "
}
let buttonTitle: String = params.interfaceState.strings.Chat_MessagingRestrictedPlaceholder(peerTitle).string
let buttonSubtitle: String = params.interfaceState.strings.Chat_MessagingRestrictedPlaceholderAction
var buttonContents: [AnyComponentWithIdentity<Empty>] = []
buttonContents.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: buttonTitle, font: Font.regular(13.0), textColor: params.interfaceState.theme.rootController.navigationBar.secondaryTextColor))
))))
if let context = self.context {
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
if !premiumConfiguration.isPremiumDisabled {
buttonContents.append(AnyComponentWithIdentity(id: 1, component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: buttonSubtitle, font: Font.regular(13.0), textColor: params.interfaceState.theme.rootController.navigationBar.accentTextColor))
))))
}
}
let size = CGSize(width: params.width - params.additionalSideInsets.left * 2.0 - params.leftInset * 2.0 - 32.0, height: height)
let buttonSize = self.button.update(
transition: .immediate,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(VStack(buttonContents, spacing: 1.0)),
effectAlignment: .center,
minSize: size,
action: { [weak self] in
guard let self else {
return
}
self.interfaceInteraction?.openPremiumRequiredForMessaging()
}
)),
environment: {},
containerSize: size
)
if let buttonView = self.button.view {
if buttonView.superview == nil {
self.view.addSubview(buttonView)
}
transition.setFrame(view: buttonView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((params.width - buttonSize.width) / 2.0), y: 0.0), size: buttonSize))
}
return height
}
override func minimalHeight(interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics) -> CGFloat {
return defaultHeight(metrics: metrics)
}
}
@@ -0,0 +1,820 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import LocalizedPeerData
import TelegramStringFormatting
import TextFormat
import Markdown
import ChatPresentationInterfaceState
import TextNodeWithEntities
import AnimationCache
import MultiAnimationRenderer
import AccountContext
import PremiumUI
private enum ChatReportPeerTitleButton: Equatable {
case block
case addContact(String?, Bool)
case shareMyPhoneNumber
case reportSpam
case reportUserSpam
case unarchive
case addMembers
case restartTopic
func title(strings: PresentationStrings) -> String {
switch self {
case .block:
return strings.Conversation_BlockUser
case let .addContact(name, long):
if let name = name {
return strings.Conversation_AddNameToContacts(name).string
} else {
if long {
return strings.Conversation_AddToContactsLong
} else {
return strings.Conversation_AddToContacts
}
}
case .shareMyPhoneNumber:
return strings.Conversation_ShareMyPhoneNumber
case .reportSpam:
return strings.Conversation_ReportSpamAndLeave
case .reportUserSpam:
return strings.Conversation_ReportSpam
case .unarchive:
return strings.Conversation_Unarchive
case .addMembers:
return strings.Conversation_AddMembers
case .restartTopic:
return strings.Chat_PanelRestartTopic
}
}
}
private func peerButtons(_ state: ChatPresentationInterfaceState) -> [ChatReportPeerTitleButton] {
var buttons: [ChatReportPeerTitleButton] = []
if let peer = state.renderedPeer?.chatMainPeer as? TelegramUser, let contactStatus = state.contactStatus, let peerStatusSettings = contactStatus.peerStatusSettings {
if peerStatusSettings.contains(.autoArchived) {
if peerStatusSettings.contains(.canBlock) || peerStatusSettings.contains(.canReport) {
if peer.isDeleted {
buttons.append(.reportUserSpam)
} else {
if !state.peerIsBlocked {
buttons.append(.block)
}
}
}
buttons.append(.unarchive)
} else if contactStatus.canAddContact && peerStatusSettings.contains(.canAddContact) {
if peerStatusSettings.contains(.canBlock) || peerStatusSettings.contains(.canReport) {
if !state.peerIsBlocked {
buttons.append(.block)
}
}
if buttons.isEmpty, let phone = peer.phone, !phone.isEmpty {
buttons.append(.addContact(EnginePeer(peer).compactDisplayTitle, buttons.isEmpty))
} else {
buttons.append(.addContact(nil, buttons.isEmpty))
}
} else {
if peerStatusSettings.contains(.canBlock) || peerStatusSettings.contains(.canReport) {
if peer.isDeleted {
buttons.append(.reportUserSpam)
} else {
if !state.peerIsBlocked {
buttons.append(.block)
}
}
}
}
if buttons.isEmpty {
if peerStatusSettings.contains(.canShareContact) {
buttons.append(.shareMyPhoneNumber)
}
}
} else if let peer = state.renderedPeer?.chatMainPeer {
if let channel = peer as? TelegramChannel, channel.isForumOrMonoForum {
if let threadData = state.threadData {
if threadData.isClosed {
var canManage = false
if channel.flags.contains(.isCreator) {
canManage = true
} else if channel.hasPermission(.manageTopics) {
canManage = true
} else if threadData.isOwnedByMe {
canManage = true
}
if canManage {
return [.restartTopic]
}
}
}
}
if case .peer = state.chatLocation {
if let contactStatus = state.contactStatus, let peerStatusSettings = contactStatus.peerStatusSettings, peerStatusSettings.contains(.suggestAddMembers) {
buttons.append(.addMembers)
} else if let contactStatus = state.contactStatus, let peerStatusSettings = contactStatus.peerStatusSettings, peerStatusSettings.contains(.autoArchived) {
buttons.append(.reportUserSpam)
buttons.append(.unarchive)
} else {
buttons.append(.reportSpam)
}
}
}
return buttons
}
private final class ChatInfoTitlePanelInviteInfoNode: ASDisplayNode {
private var theme: PresentationTheme?
private let labelNode: ImmediateTextNode
private let backgroundNode: NavigationBackgroundNode
init(openInvitePeer: @escaping () -> Void) {
self.labelNode = ImmediateTextNode()
self.labelNode.maximumNumberOfLines = 1
self.labelNode.textAlignment = .center
self.backgroundNode = NavigationBackgroundNode(color: .clear)
super.init()
self.addSubnode(self.backgroundNode)
self.addSubnode(self.labelNode)
self.labelNode.highlightAttributeAction = { attributes in
for (key, _) in attributes {
if key.rawValue == "_Link" {
return key
}
}
return nil
}
self.labelNode.tapAttributeAction = { attributes, _ in
for (key, _) in attributes {
if key.rawValue == "_Link" {
openInvitePeer()
}
}
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
if result == self.view {
return nil
}
return result
}
func update(width: CGFloat, theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper, chatPeer: Peer, invitedBy: Peer, transition: ContainedViewLayoutTransition) -> CGFloat {
let primaryTextColor = serviceMessageColorComponents(theme: theme, wallpaper: wallpaper).primaryText
if self.theme !== theme {
self.theme = theme
self.labelNode.linkHighlightColor = primaryTextColor.withAlphaComponent(0.3)
}
let topInset: CGFloat = 6.0
let bottomInset: CGFloat = 6.0
let sideInset: CGFloat = 16.0
let stringAndRanges: PresentationStrings.FormattedString
if let channel = chatPeer as? TelegramChannel, case .broadcast = channel.info {
stringAndRanges = strings.Conversation_NoticeInvitedByInChannel(EnginePeer(invitedBy).compactDisplayTitle)
} else {
stringAndRanges = strings.Conversation_NoticeInvitedByInGroup(EnginePeer(invitedBy).compactDisplayTitle)
}
let attributedString = NSMutableAttributedString(string: stringAndRanges.string, font: Font.regular(13.0), textColor: primaryTextColor)
let boldAttributes = [NSAttributedString.Key.font: Font.semibold(13.0), NSAttributedString.Key(rawValue: "_Link"): true as NSNumber]
for range in stringAndRanges.ranges {
attributedString.addAttributes(boldAttributes, range: range.range)
}
self.labelNode.attributedText = attributedString
let labelLayout = self.labelNode.updateLayoutFullInfo(CGSize(width: width - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
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 backgroundSize = CGSize(width: labelLayout.size.width + 8.0 + 8.0, height: labelLayout.size.height + 4.0)
let labelFrame = CGRect(origin: CGPoint(x: floor((width - labelLayout.size.width) / 2.0), y: topInset + floorToScreenPixels((backgroundSize.height - labelLayout.size.height) / 2.0) - 1.0), size: labelLayout.size)
self.labelNode.frame = labelFrame
let backgroundFrame = labelFrame.offsetBy(dx: 0.0, dy: 1.0).insetBy(dx: -5.0, dy: -2.0)
self.backgroundNode.updateColor(color: selectDateFillStaticColor(theme: theme, wallpaper: wallpaper), enableBlur: dateFillNeedsBlur(theme: theme, wallpaper: wallpaper), transition: .immediate)
transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame)
self.backgroundNode.update(size: self.backgroundNode.bounds.size, cornerRadius: self.backgroundNode.bounds.size.height / 2.0, transition: transition)
return topInset + backgroundSize.height + bottomInset
}
}
private final class ChatInfoTitlePanelPeerNearbyInfoNode: ASDisplayNode {
private var theme: PresentationTheme?
private let labelNode: ImmediateTextNode
private let filledBackgroundNode: LinkHighlightingNode
private let openPeersNearby: () -> Void
init(openPeersNearby: @escaping () -> Void) {
self.openPeersNearby = openPeersNearby
self.labelNode = ImmediateTextNode()
self.labelNode.maximumNumberOfLines = 1
self.labelNode.textAlignment = .center
self.filledBackgroundNode = LinkHighlightingNode(color: .clear)
super.init()
self.addSubnode(self.filledBackgroundNode)
self.addSubnode(self.labelNode)
}
override func didLoad() {
super.didLoad()
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.view.addGestureRecognizer(tapRecognizer)
}
@objc private func tapGesture(_ gestureRecognizer: UITapGestureRecognizer) {
self.openPeersNearby()
}
func update(width: CGFloat, theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper, chatPeer: Peer, distance: Int32, transition: ContainedViewLayoutTransition) -> CGFloat {
let primaryTextColor = serviceMessageColorComponents(theme: theme, wallpaper: wallpaper).primaryText
if self.theme !== theme {
self.theme = theme
self.labelNode.linkHighlightColor = primaryTextColor.withAlphaComponent(0.3)
}
let topInset: CGFloat = 6.0
let bottomInset: CGFloat = 6.0
let sideInset: CGFloat = 16.0
let stringAndRanges = strings.Conversation_PeerNearbyDistance(EnginePeer(chatPeer).compactDisplayTitle, shortStringForDistance(strings: strings, distance: distance))
let attributedString = NSMutableAttributedString(string: stringAndRanges.string, font: Font.regular(13.0), textColor: primaryTextColor)
let boldAttributes = [NSAttributedString.Key.font: Font.semibold(13.0), NSAttributedString.Key(rawValue: "_Link"): true as NSNumber]
for range in stringAndRanges.ranges.prefix(1) {
attributedString.addAttributes(boldAttributes, range: range.range)
}
self.labelNode.attributedText = attributedString
let labelLayout = self.labelNode.updateLayoutFullInfo(CGSize(width: width - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
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 backgroundLayout = self.filledBackgroundNode.asyncLayout()
let serviceColor = serviceMessageColorComponents(theme: theme, wallpaper: wallpaper)
let backgroundApply = backgroundLayout(serviceColor.fill, labelRects, 10.0, 10.0, 0.0)
backgroundApply()
let backgroundSize = CGSize(width: labelLayout.size.width + 8.0 + 8.0, height: labelLayout.size.height + 4.0)
let labelFrame = CGRect(origin: CGPoint(x: floor((width - labelLayout.size.width) / 2.0), y: topInset + floorToScreenPixels((backgroundSize.height - labelLayout.size.height) / 2.0) - 1.0), size: labelLayout.size)
self.labelNode.frame = labelFrame
self.filledBackgroundNode.frame = labelFrame.offsetBy(dx: 0.0, dy: -11.0)
return topInset + backgroundSize.height + bottomInset
}
}
final class ChatReportPeerTitlePanelNode: ChatTitleAccessoryPanelNode {
private let context: AccountContext
private let animationCache: AnimationCache
private let animationRenderer: MultiAnimationRenderer
private let separatorNode: ASDisplayNode
private let closeButton: HighlightableButtonNode
private var buttons: [(ChatReportPeerTitleButton, UIButton)] = []
private let textNode: ImmediateTextNode
private var emojiStatusTextNode: ImmediateTextNodeWithEntities?
private let emojiSeparatorNode: ASDisplayNode
private var theme: PresentationTheme?
private var presentationInterfaceState: ChatPresentationInterfaceState?
private var inviteInfoNode: ChatInfoTitlePanelInviteInfoNode?
private var peerNearbyInfoNode: ChatInfoTitlePanelPeerNearbyInfoNode?
private var cachedChevronImage: (UIImage, PresentationTheme)?
private var emojiStatusPackDisposable = MetaDisposable()
private var emojiStatusFileId: Int64?
private var emojiStatusFileAndPackTitle = Promise<(TelegramMediaFile, LoadedStickerPack)?>()
private var tapGestureRecognizer: UITapGestureRecognizer?
init(context: AccountContext, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer) {
self.context = context
self.animationCache = animationCache
self.animationRenderer = animationRenderer
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.emojiSeparatorNode = ASDisplayNode()
self.emojiSeparatorNode.isLayerBacked = true
self.closeButton = HighlightableButtonNode()
self.closeButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0)
self.closeButton.displaysAsynchronously = false
self.textNode = ImmediateTextNode()
self.textNode.maximumNumberOfLines = 3
self.textNode.truncationType = .middle
self.textNode.textAlignment = .center
super.init()
self.addSubnode(self.separatorNode)
self.addSubnode(self.emojiSeparatorNode)
self.addSubnode(self.textNode)
self.closeButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: [.touchUpInside])
self.addSubnode(self.closeButton)
}
deinit {
self.emojiStatusPackDisposable.dispose()
}
override func didLoad() {
super.didLoad()
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapped))
tapGestureRecognizer.isEnabled = false
self.view.addGestureRecognizer(tapGestureRecognizer)
self.tapGestureRecognizer = tapGestureRecognizer
}
@objc func tapped() {
self.interfaceInteraction?.presentChatRequestAdminInfo()
}
private func openPremiumEmojiStatusDemo() {
guard let navigationController = self.interfaceInteraction?.getNavigationController(), let peerId = self.presentationInterfaceState?.chatLocation.peerId, let emojiStatus = self.presentationInterfaceState?.renderedPeer?.peer?.emojiStatus else {
return
}
let fileId = emojiStatus.fileId
let source: Signal<PremiumSource, NoError> = self.emojiStatusFileAndPackTitle.get()
|> take(1)
|> mapToSignal { emojiStatusFileAndPack -> Signal<PremiumSource, NoError> in
if let (file, pack) = emojiStatusFileAndPack {
return .single(.emojiStatus(peerId, fileId, file, pack))
} else {
return .complete()
}
}
let _ = (source
|> deliverOnMainQueue).startStandalone(next: { [weak self, weak navigationController] source in
guard let self, let navigationController else {
return
}
let controller = PremiumIntroScreen(context: self.context, source: source)
if let textView = self.emojiStatusTextNode?.view {
controller.sourceView = textView
controller.sourceRect = CGRect(origin: .zero, size: CGSize(width: textView.frame.height, height: textView.frame.height))
}
controller.containerView = navigationController.view
navigationController.pushViewController(controller)
})
}
override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> LayoutResult {
if interfaceState.theme !== self.theme {
self.theme = interfaceState.theme
self.closeButton.setImage(PresentationResourcesChat.chatInputPanelEncircledCloseIconImage(interfaceState.theme), for: [])
self.separatorNode.backgroundColor = interfaceState.theme.rootController.navigationBar.separatorColor
self.emojiSeparatorNode.backgroundColor = interfaceState.theme.rootController.navigationBar.separatorColor
}
self.presentationInterfaceState = interfaceState
var panelHeight: CGFloat = 40.0
let contentRightInset: CGFloat = 14.0 + rightInset
let updatedButtons: [ChatReportPeerTitleButton]
if let _ = interfaceState.renderedPeer?.peer {
updatedButtons = peerButtons(interfaceState)
} else {
updatedButtons = []
}
var buttonsUpdated = false
if self.buttons.count != updatedButtons.count {
buttonsUpdated = true
} else {
for i in 0 ..< updatedButtons.count {
if self.buttons[i].0 != updatedButtons[i] {
buttonsUpdated = true
break
}
}
}
if buttonsUpdated {
for (_, view) in self.buttons {
view.removeFromSuperview()
}
self.buttons.removeAll()
for button in updatedButtons {
let view = UIButton()
view.setTitle(button.title(strings: interfaceState.strings), for: [])
view.titleLabel?.font = Font.regular(16.0)
switch button {
case .block, .reportSpam, .reportUserSpam:
view.setTitleColor(interfaceState.theme.chat.inputPanel.panelControlDestructiveColor, for: [])
view.setTitleColor(interfaceState.theme.chat.inputPanel.panelControlDestructiveColor.withAlphaComponent(0.7), for: [.highlighted])
default:
view.setTitleColor(interfaceState.theme.rootController.navigationBar.accentTextColor, for: [])
view.setTitleColor(interfaceState.theme.rootController.navigationBar.accentTextColor.withAlphaComponent(0.7), for: [.highlighted])
}
view.addTarget(self, action: #selector(self.buttonPressed(_:)), for: [.touchUpInside])
self.view.addSubview(view)
self.buttons.append((button, view))
}
}
let additionalRightInset: CGFloat = 36.0
if !self.buttons.isEmpty {
let maxInset = max(contentRightInset, leftInset)
if self.buttons.count == 1 {
let buttonWidth = floor((width - maxInset * 2.0 - additionalRightInset) / CGFloat(self.buttons.count))
var nextButtonOrigin: CGFloat = maxInset
for (_, view) in self.buttons {
view.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((width - buttonWidth) / 2.0), y: 0.0), size: CGSize(width: buttonWidth, height: panelHeight))
nextButtonOrigin += buttonWidth
}
} else {
var areaWidth = width - maxInset * 2.0 - additionalRightInset
let maxButtonWidth = floor(areaWidth / CGFloat(self.buttons.count))
let buttonSizes = self.buttons.map { button -> CGFloat in
return button.1.sizeThatFits(CGSize(width: maxButtonWidth, height: 100.0)).width
}
let buttonsWidth = buttonSizes.reduce(0.0, +)
if buttonsWidth < areaWidth - 20.0 {
areaWidth += additionalRightInset
}
let maxButtonSpacing = floor((areaWidth - buttonsWidth) / CGFloat(self.buttons.count - 1))
let buttonSpacing = min(maxButtonSpacing, 110.0)
let updatedButtonsWidth = buttonsWidth + CGFloat(self.buttons.count - 1) * buttonSpacing
var nextButtonOrigin = maxInset + floor((areaWidth - updatedButtonsWidth) / 2.0)
let buttonWidth = floor(updatedButtonsWidth / CGFloat(self.buttons.count))
var buttonFrames: [CGRect] = []
for _ in 0 ..< self.buttons.count {
buttonFrames.append(CGRect(origin: CGPoint(x: nextButtonOrigin, y: 0.0), size: CGSize(width: buttonWidth, height: panelHeight)))
nextButtonOrigin += buttonWidth
}
if buttonFrames[buttonFrames.count - 1].maxX >= width - 20.0 {
for i in 0 ..< buttonFrames.count {
buttonFrames[i].origin.x -= 16.0
}
}
for i in 0 ..< self.buttons.count {
self.buttons[i].1.frame = buttonFrames[i]
}
}
}
if let requestChatTitle = interfaceState.contactStatus?.peerStatusSettings?.requestChatTitle, let requestChatIsChannel = interfaceState.contactStatus?.peerStatusSettings?.requestChatIsChannel, let renderedPeer = interfaceState.renderedPeer, let peer = renderedPeer.chatMainPeer {
let text: NSAttributedString
let regular = MarkdownAttributeSet(font: Font.regular(15.0), textColor: interfaceState.theme.rootController.navigationBar.primaryTextColor)
let bold = MarkdownAttributeSet(font: Font.bold(15.0), textColor: interfaceState.theme.rootController.navigationBar.primaryTextColor)
if requestChatIsChannel {
text = addAttributesToStringWithRanges(interfaceState.strings.Conversation_InviteRequestAdminChannel(EnginePeer(peer).compactDisplayTitle, requestChatTitle)._tuple, body: regular, argumentAttributes: [0: bold, 1: bold])
} else {
text = addAttributesToStringWithRanges(interfaceState.strings.Conversation_InviteRequestAdminGroup(EnginePeer(peer).compactDisplayTitle, requestChatTitle)._tuple, body: regular, argumentAttributes: [0: bold, 1: bold])
}
self.textNode.attributedText = text
transition.updateAlpha(node: self.textNode, alpha: 1.0)
let textSize = self.textNode.updateLayout(CGSize(width: width - leftInset - rightInset - 80.0, height: 80.0))
self.textNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((width - textSize.width) / 2.0), y: 10.0), size: textSize)
for (_, view) in self.buttons {
transition.updateAlpha(layer: view.layer, alpha: 0.0)
}
self.tapGestureRecognizer?.isEnabled = true
panelHeight += max(15.0, textSize.height - 19.0)
} else {
transition.updateAlpha(node: self.textNode, alpha: 0.0)
for (_, view) in self.buttons {
transition.updateAlpha(layer: view.layer, alpha: 1.0)
}
self.tapGestureRecognizer?.isEnabled = false
}
let closeButtonSize = self.closeButton.measure(CGSize(width: 100.0, height: 100.0))
transition.updateFrame(node: self.closeButton, frame: CGRect(origin: CGPoint(x: width - contentRightInset - closeButtonSize.width, y: floorToScreenPixels((panelHeight - closeButtonSize.height) / 2.0)), size: closeButtonSize))
if updatedButtons.contains(.restartTopic) {
self.closeButton.isHidden = true
} else {
self.closeButton.isHidden = false
}
var emojiStatus: PeerEmojiStatus?
if let user = interfaceState.renderedPeer?.peer as? TelegramUser, let emojiStatusValue = user.emojiStatus {
if user.isFake || user.isScam {
} else {
emojiStatus = emojiStatusValue
}
} else if let channel = interfaceState.renderedPeer?.peer as? TelegramChannel, let emojiStatusValue = channel.emojiStatus {
if channel.isFake || channel.isScam {
} else {
emojiStatus = emojiStatusValue
}
}
if let emojiStatus {
let fileId = emojiStatus.fileId
if self.emojiStatusFileId != fileId {
self.emojiStatusFileId = fileId
let emojiFileAndPack = self.context.engine.stickers.resolveInlineStickers(fileIds: [fileId])
|> mapToSignal { result in
if let emojiFile = result.first?.value {
for attribute in emojiFile.attributes {
if case let .CustomEmoji(_, _, _, packReference) = attribute, let packReference = packReference {
return self.context.engine.stickers.loadedStickerPack(reference: packReference, forceActualized: false)
|> filter { result in
if case .result = result {
return true
} else {
return false
}
}
|> mapToSignal { result -> Signal<(TelegramMediaFile, LoadedStickerPack)?, NoError> in
if case let .result(_, items, _) = result {
return .single(items.first.flatMap { ($0.file._parse(), result) })
} else {
return .complete()
}
}
}
}
}
return .complete()
}
self.emojiStatusPackDisposable.set(emojiFileAndPack.startStrict(next: { [weak self] fileAndPackTitle in
guard let self else {
return
}
self.emojiStatusFileAndPackTitle.set(.single(fileAndPackTitle))
}))
}
self.emojiSeparatorNode.isHidden = false
transition.updateFrame(node: self.emojiSeparatorNode, frame: CGRect(origin: CGPoint(x: leftInset + 12.0, y: 40.0), size: CGSize(width: width - leftInset - rightInset - 24.0, height: UIScreenPixel)))
let emojiStatusTextNode: ImmediateTextNodeWithEntities
if let current = self.emojiStatusTextNode {
emojiStatusTextNode = current
} else {
emojiStatusTextNode = ImmediateTextNodeWithEntities()
emojiStatusTextNode.maximumNumberOfLines = 0
emojiStatusTextNode.textAlignment = .center
emojiStatusTextNode.linkHighlightInset = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: -8.0)
emojiStatusTextNode.highlightAttributeAction = { attributes in
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] {
return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)
} else {
return nil
}
}
emojiStatusTextNode.tapAttributeAction = { [weak self] attributes, _ in
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String {
self?.openPremiumEmojiStatusDemo()
}
}
self.emojiStatusTextNode = emojiStatusTextNode
self.addSubnode(emojiStatusTextNode)
}
if self.cachedChevronImage == nil || self.cachedChevronImage?.1 !== interfaceState.theme {
self.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Item List/InlineTextRightArrow"), color: interfaceState.theme.rootController.navigationBar.accentTextColor)!, interfaceState.theme)
}
let plainText = interfaceState.strings.Chat_PanelCustomStatusShortInfo("#").string
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: Font.regular(12.0), textColor: interfaceState.theme.rootController.navigationBar.secondaryTextColor), bold: MarkdownAttributeSet(font: Font.semibold(12.0), textColor: interfaceState.theme.rootController.navigationBar.secondaryTextColor), link: MarkdownAttributeSet(font: Font.regular(12.0), textColor: interfaceState.theme.rootController.navigationBar.accentTextColor), linkAttribute: { contents in
return (TelegramTextAttributes.URL, contents)
})
let attributedString = parseMarkdownIntoAttributedString(plainText, attributes: markdownAttributes, textAlignment: .center).mutableCopy() as! NSMutableAttributedString
if let range = attributedString.string.range(of: ">"), let chevronImage = self.cachedChevronImage?.0 {
attributedString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: attributedString.string))
}
if let range = attributedString.string.range(of: "#") {
attributedString.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: emojiStatus.fileId, file: nil), range: NSRange(range, in: attributedString.string))
}
emojiStatusTextNode.attributedText = attributedString
emojiStatusTextNode.arguments = TextNodeWithEntities.Arguments(
context: self.context,
cache: self.animationCache,
renderer: self.animationRenderer,
placeholderColor: interfaceState.theme.list.mediaPlaceholderColor,
attemptSynchronous: false
)
emojiStatusTextNode.linkHighlightColor = interfaceState.theme.list.itemAccentColor.withAlphaComponent(0.1)
let emojiStatusTextSize = emojiStatusTextNode.updateLayout(CGSize(width: width - leftInset * 2.0 - 8.0 * 2.0, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: emojiStatusTextNode, frame: CGRect(origin: CGPoint(x: floor((width - emojiStatusTextSize.width) / 2.0), y: panelHeight + 10.0), size: emojiStatusTextSize))
panelHeight += emojiStatusTextSize.height + 20.0
emojiStatusTextNode.visibility = true
} else {
self.emojiSeparatorNode.isHidden = true
if let emojiStatusTextNode = self.emojiStatusTextNode {
self.emojiStatusTextNode = nil
emojiStatusTextNode.removeFromSupernode()
}
}
let initialPanelHeight = panelHeight
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)))
var panelInset: CGFloat = 0.0
if let _ = interfaceState.translationState {
panelInset += 40.0
}
var chatPeer: Peer?
if let renderedPeer = interfaceState.renderedPeer {
chatPeer = renderedPeer.peers[renderedPeer.peerId]
}
var hitTestSlop: CGFloat = 0.0
if let chatPeer = chatPeer, (updatedButtons.contains(.block) || updatedButtons.contains(.reportSpam) || updatedButtons.contains(.reportUserSpam)), let invitedBy = interfaceState.contactStatus?.invitedBy {
var inviteInfoTransition = transition
let inviteInfoNode: ChatInfoTitlePanelInviteInfoNode
if let current = self.inviteInfoNode {
inviteInfoNode = current
} else {
inviteInfoTransition = .immediate
inviteInfoNode = ChatInfoTitlePanelInviteInfoNode(openInvitePeer: { [weak self] in
self?.interfaceInteraction?.navigateToProfile(invitedBy.id)
})
self.addSubnode(inviteInfoNode)
self.inviteInfoNode = inviteInfoNode
inviteInfoNode.alpha = 0.0
transition.updateAlpha(node: inviteInfoNode, alpha: 1.0)
}
if let inviteInfoNode = self.inviteInfoNode {
let inviteHeight = inviteInfoNode.update(width: width, theme: interfaceState.theme, strings: interfaceState.strings, wallpaper: interfaceState.chatWallpaper, chatPeer: chatPeer, invitedBy: invitedBy, transition: inviteInfoTransition)
inviteInfoTransition.updateFrame(node: inviteInfoNode, frame: CGRect(origin: CGPoint(x: 0.0, y: panelHeight + panelInset), size: CGSize(width: width, height: inviteHeight)))
panelHeight += inviteHeight
hitTestSlop = -inviteHeight
}
} else if let inviteInfoNode = self.inviteInfoNode {
self.inviteInfoNode = nil
transition.updateAlpha(node: inviteInfoNode, alpha: 0.0, completion: { [weak inviteInfoNode] _ in
inviteInfoNode?.removeFromSupernode()
})
}
if let chatPeer = chatPeer, let distance = interfaceState.contactStatus?.peerStatusSettings?.geoDistance {
var peerNearbyInfoTransition = transition
let peerNearbyInfoNode: ChatInfoTitlePanelPeerNearbyInfoNode
if let current = self.peerNearbyInfoNode {
peerNearbyInfoNode = current
} else {
peerNearbyInfoTransition = .immediate
peerNearbyInfoNode = ChatInfoTitlePanelPeerNearbyInfoNode(openPeersNearby: { [weak self] in
self?.interfaceInteraction?.openPeersNearby()
})
self.addSubnode(peerNearbyInfoNode)
self.peerNearbyInfoNode = peerNearbyInfoNode
peerNearbyInfoNode.alpha = 0.0
transition.updateAlpha(node: peerNearbyInfoNode, alpha: 1.0)
}
if let peerNearbyInfoNode = self.peerNearbyInfoNode {
let peerNearbyHeight = peerNearbyInfoNode.update(width: width, theme: interfaceState.theme, strings: interfaceState.strings, wallpaper: interfaceState.chatWallpaper, chatPeer: chatPeer, distance: distance, transition: peerNearbyInfoTransition)
peerNearbyInfoTransition.updateFrame(node: peerNearbyInfoNode, frame: CGRect(origin: CGPoint(x: 0.0, y: panelHeight + panelInset), size: CGSize(width: width, height: peerNearbyHeight)))
panelHeight += peerNearbyHeight
}
} else if let peerNearbyInfoNode = self.peerNearbyInfoNode {
self.peerNearbyInfoNode = nil
transition.updateAlpha(node: peerNearbyInfoNode, alpha: 0.0, completion: { [weak peerNearbyInfoNode] _ in
peerNearbyInfoNode?.removeFromSupernode()
})
}
return LayoutResult(backgroundHeight: initialPanelHeight, insetHeight: panelHeight + panelInset, hitTestSlop: hitTestSlop)
}
@objc func buttonPressed(_ view: UIButton) {
for (button, buttonView) in self.buttons {
if buttonView === view {
switch button {
case .shareMyPhoneNumber:
self.interfaceInteraction?.shareAccountContact()
case .block, .reportSpam, .reportUserSpam:
self.interfaceInteraction?.reportPeer()
case .unarchive:
self.interfaceInteraction?.unarchivePeer()
case .addMembers:
self.interfaceInteraction?.presentInviteMembers()
case .addContact:
self.interfaceInteraction?.presentPeerContact()
case .restartTopic:
self.interfaceInteraction?.restartTopic()
}
break
}
}
}
@objc func closePressed() {
self.interfaceInteraction?.dismissReportPeer()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !self.closeButton.isHidden, let result = self.closeButton.hitTest(CGPoint(x: point.x - self.closeButton.frame.minX, y: point.y - self.closeButton.frame.minY), with: event) {
return result
}
if let inviteInfoNode = self.inviteInfoNode {
if let result = inviteInfoNode.view.hitTest(self.view.convert(point, to: inviteInfoNode.view), with: event) {
return result
}
}
if let _ = self.emojiStatusTextNode {
} else if point.y > 40.0 {
return nil
}
return super.hitTest(point, with: event)
}
}
@@ -0,0 +1,61 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramPresentationData
import ChatPresentationInterfaceState
final class ChatRequestInProgressTitlePanelNode: ChatTitleAccessoryPanelNode {
private let separatorNode: ASDisplayNode
private let titleNode: ImmediateTextNode
private let activateAreaNode: AccessibilityAreaNode
private var theme: PresentationTheme?
private var strings: PresentationStrings?
override init() {
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.titleNode = ImmediateTextNode()
self.titleNode.maximumNumberOfLines = 1
self.activateAreaNode = AccessibilityAreaNode()
self.activateAreaNode.accessibilityTraits = .staticText
super.init()
self.addSubnode(self.titleNode)
self.addSubnode(self.separatorNode)
self.addSubnode(self.activateAreaNode)
}
override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> LayoutResult {
if interfaceState.strings !== self.strings {
self.strings = interfaceState.strings
self.titleNode.attributedText = NSAttributedString(string: interfaceState.strings.Channel_NotificationLoading, font: Font.regular(14.0), textColor: interfaceState.theme.rootController.navigationBar.primaryTextColor)
}
if interfaceState.theme !== self.theme {
self.theme = interfaceState.theme
self.separatorNode.backgroundColor = interfaceState.theme.rootController.navigationBar.separatorColor
}
let panelHeight: CGFloat = 40.0
let titleSize = self.titleNode.updateLayout(CGSize(width: width - leftInset - rightInset, height: 100.0))
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floor((width - titleSize.width) / 2.0), y: floor((panelHeight - titleSize.height) / 2.0)), size: titleSize))
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)))
self.activateAreaNode.frame = CGRect(origin: .zero, size: CGSize(width: width, height: panelHeight))
self.activateAreaNode.accessibilityLabel = interfaceState.strings.Channel_NotificationLoading
return LayoutResult(backgroundHeight: panelHeight, insetHeight: panelHeight, hitTestSlop: 0.0)
}
}
@@ -0,0 +1,218 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import Postbox
import SwiftSignalKit
import TelegramStringFormatting
import ChatPresentationInterfaceState
import TelegramPresentationData
import ChatInputPanelNode
import AccountContext
import GlassBackgroundComponent
import ComponentFlow
import ComponentDisplayAdapters
final class ChatRestrictedInputPanelNode: ChatInputPanelNode {
private let backgroundView: GlassBackgroundView
private let buttonNode: HighlightTrackingButtonNode
private let textNode: ImmediateTextNode
private let tintTextNode: ImmediateTextNode
private let subtitleNode: ImmediateTextNode
private let tintSubtitleNode: ImmediateTextNode
private var iconView: UIImageView?
private var presentationInterfaceState: ChatPresentationInterfaceState?
override init() {
self.backgroundView = GlassBackgroundView()
self.textNode = ImmediateTextNode()
self.textNode.maximumNumberOfLines = 2
self.textNode.textAlignment = .center
self.tintTextNode = ImmediateTextNode()
self.tintTextNode.maximumNumberOfLines = 2
self.tintTextNode.textAlignment = .center
self.subtitleNode = ImmediateTextNode()
self.subtitleNode.maximumNumberOfLines = 1
self.subtitleNode.textAlignment = .center
self.tintSubtitleNode = ImmediateTextNode()
self.tintSubtitleNode.maximumNumberOfLines = 1
self.tintSubtitleNode.textAlignment = .center
self.buttonNode = HighlightTrackingButtonNode()
self.buttonNode.isUserInteractionEnabled = false
super.init()
self.backgroundView.contentView.addSubview(self.textNode.view)
self.backgroundView.maskContentView.addSubview(self.tintTextNode.view)
self.backgroundView.contentView.addSubview(self.subtitleNode.view)
self.backgroundView.maskContentView.addSubview(self.tintSubtitleNode.view)
self.view.addSubview(self.backgroundView)
self.addSubnode(self.buttonNode)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
if let self {
if highlighted {
self.iconView?.layer.removeAnimation(forKey: "opacity")
self.iconView?.alpha = 0.4
self.textNode.layer.removeAnimation(forKey: "opacity")
self.textNode.alpha = 0.4
self.subtitleNode.layer.removeAnimation(forKey: "opacity")
self.subtitleNode.alpha = 0.4
} else {
self.iconView?.alpha = 1.0
self.iconView?.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
self.textNode.alpha = 1.0
self.textNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
self.subtitleNode.alpha = 1.0
self.subtitleNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
}
@objc private func buttonPressed() {
self.interfaceInteraction?.openBoostToUnrestrict()
}
override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, additionalSideInsets: UIEdgeInsets, maxHeight: CGFloat, maxOverlayHeight: CGFloat, isSecondary: Bool, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics, isMediaInputExpanded: Bool) -> CGFloat {
if self.presentationInterfaceState != interfaceState {
self.presentationInterfaceState = interfaceState
}
var bannedPermission: (Int32, Bool)?
if let channel = interfaceState.renderedPeer?.peer as? TelegramChannel {
if let value = channel.hasBannedPermission(.banSendText) {
bannedPermission = value
} else if !channel.hasPermission(.sendSomething) {
bannedPermission = (Int32.max, false)
}
} else if let group = interfaceState.renderedPeer?.peer as? TelegramGroup {
if !group.hasPermission(.sendSomething) {
bannedPermission = (Int32.max, false)
}
}
var iconImage: UIImage?
var iconSpacing: CGFloat = 4.0
var isUserInteractionEnabled = false
var accountFreezeConfiguration: AccountFreezeConfiguration?
if let context = self.context {
accountFreezeConfiguration = AccountFreezeConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
}
if let channel = interfaceState.renderedPeer?.chatMainPeer as? TelegramChannel, channel.isMonoForum {
self.textNode.attributedText = NSAttributedString(string: interfaceState.strings.Chat_PanelForumModeReplyText, font: Font.regular(15.0), textColor: interfaceState.theme.chat.inputPanel.secondaryTextColor)
} else if let _ = accountFreezeConfiguration?.freezeUntilDate {
self.textNode.attributedText = NSAttributedString(string: interfaceState.strings.Chat_PanelFrozenAccount_Title, font: Font.semibold(15.0), textColor: interfaceState.theme.list.itemDestructiveColor)
self.subtitleNode.attributedText = NSAttributedString(string: interfaceState.strings.Chat_PanelFrozenAccount_Text, font: Font.regular(13.0), textColor: interfaceState.theme.chat.inputPanel.secondaryTextColor)
isUserInteractionEnabled = true
} else if case let .replyThread(message) = interfaceState.chatLocation, message.peerId == self.context?.account.peerId {
self.textNode.attributedText = NSAttributedString(string: interfaceState.strings.Chat_PanelStatusAuthorHidden, font: Font.regular(13.0), textColor: interfaceState.theme.chat.inputPanel.secondaryTextColor)
} else if let threadData = interfaceState.threadData, threadData.isClosed {
iconImage = PresentationResourcesChat.chatPanelLockIcon(interfaceState.theme)
self.textNode.attributedText = NSAttributedString(string: interfaceState.strings.Chat_PanelTopicClosedText, font: Font.regular(15.0), textColor: interfaceState.theme.chat.inputPanel.secondaryTextColor)
} else if let channel = interfaceState.renderedPeer?.peer as? TelegramChannel, channel.isForumOrMonoForum, case .peer = interfaceState.chatLocation {
if let replyMessage = interfaceState.replyMessage, let threadInfo = replyMessage.associatedThreadInfo {
self.textNode.attributedText = NSAttributedString(string: interfaceState.strings.Chat_TopicIsClosedLabel(threadInfo.title).string, font: Font.regular(15.0), textColor: interfaceState.theme.chat.inputPanel.secondaryTextColor)
} else {
self.textNode.attributedText = NSAttributedString(string: interfaceState.strings.Chat_PanelForumModeReplyText, font: Font.regular(15.0), textColor: interfaceState.theme.chat.inputPanel.secondaryTextColor)
}
} else if let (untilDate, personal) = bannedPermission {
if personal && untilDate != 0 && untilDate != Int32.max {
self.textNode.attributedText = NSAttributedString(string: interfaceState.strings.Conversation_RestrictedTextTimed(stringForFullDate(timestamp: untilDate, strings: interfaceState.strings, dateTimeFormat: interfaceState.dateTimeFormat)).string, font: Font.regular(13.0), textColor: interfaceState.theme.chat.inputPanel.secondaryTextColor)
} else if personal {
self.textNode.attributedText = NSAttributedString(string: interfaceState.strings.Conversation_RestrictedText, font: Font.regular(13.0), textColor: interfaceState.theme.chat.inputPanel.secondaryTextColor)
} else {
if (self.presentationInterfaceState?.boostsToUnrestrict ?? 0) > 0 {
iconSpacing = 0.0
iconImage = PresentationResourcesChat.chatPanelBoostIcon(interfaceState.theme)
self.textNode.attributedText = NSAttributedString(string: interfaceState.strings.Conversation_BoostToUnrestrictText, font: Font.regular(15.0), textColor: interfaceState.theme.chat.inputPanel.panelControlAccentColor)
isUserInteractionEnabled = true
} else {
self.textNode.attributedText = NSAttributedString(string: interfaceState.strings.Conversation_DefaultRestrictedText, font: Font.regular(13.0), textColor: interfaceState.theme.chat.inputPanel.secondaryTextColor)
}
}
} else if case let .customChatContents(customChatContents) = interfaceState.subject {
let displayCount: Int
switch customChatContents.kind {
case .hashTagSearch:
displayCount = 0
case .quickReplyMessageInput:
displayCount = customChatContents.messageLimit ?? 20
case .businessLinkSetup:
displayCount = 0
}
self.textNode.attributedText = NSAttributedString(string: interfaceState.strings.Chat_QuickReplyMessageLimitReachedText(Int32(displayCount)), font: Font.regular(13.0), textColor: interfaceState.theme.chat.inputPanel.secondaryTextColor)
}
self.buttonNode.isUserInteractionEnabled = isUserInteractionEnabled
self.tintTextNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: Font.regular(15.0), textColor: .black)
self.tintSubtitleNode.attributedText = NSAttributedString(string: self.subtitleNode.attributedText?.string ?? "", font: Font.regular(13.0), textColor: .black)
let panelHeight = defaultHeight(metrics: metrics)
let textSize = self.textNode.updateLayout(CGSize(width: width - leftInset - rightInset - 8.0 * 2.0, height: panelHeight))
let subtitleSize = self.subtitleNode.updateLayout(CGSize(width: width - leftInset - rightInset - 8.0 * 2.0, height: panelHeight))
var originX: CGFloat = leftInset + floor((width - leftInset - rightInset - textSize.width) / 2.0)
var totalWidth = textSize.width
if let iconImage {
let iconView: UIImageView
if let current = self.iconView {
iconView = current
} else {
iconView = UIImageView()
self.iconView = iconView
self.view.addSubview(iconView)
}
iconView.image = iconImage
totalWidth += iconImage.size.width + iconSpacing
iconView.frame = CGRect(origin: CGPoint(x: leftInset + floor((width - leftInset - rightInset - totalWidth) / 2.0), y: floor((panelHeight - textSize.height) / 2.0) + UIScreenPixel + floorToScreenPixels((textSize.height - iconImage.size.height) / 2.0)), size: iconImage.size)
originX += iconImage.size.width + iconSpacing
} else if let iconView = self.iconView {
self.iconView = nil
iconView.removeFromSuperview()
}
var combinedHeight: CGFloat = textSize.height
if subtitleSize.height > 0.0 {
combinedHeight += subtitleSize.height + 2.0
}
let textFrame = CGRect(origin: CGPoint(x: originX, y: floor((panelHeight - combinedHeight) / 2.0)), size: textSize)
let subtitleFrame = CGRect(origin: CGPoint(x: leftInset + floor((width - leftInset - rightInset - subtitleSize.width) / 2.0), y: floor((panelHeight + combinedHeight) / 2.0) - subtitleSize.height), size: subtitleSize)
var combinedFrame = textFrame.union(subtitleFrame)
if let iconView {
combinedFrame = combinedFrame.union(iconView.frame)
}
combinedFrame = combinedFrame.insetBy(dx: -12.0, dy: -6.0)
combinedFrame.origin.y += 1.0
self.textNode.frame = textFrame.offsetBy(dx: -combinedFrame.minX, dy: -combinedFrame.minY)
self.tintTextNode.frame = self.textNode.frame
self.subtitleNode.frame = subtitleFrame.offsetBy(dx: -combinedFrame.minX, dy: -combinedFrame.minY)
self.tintSubtitleNode.frame = self.subtitleNode.frame
self.backgroundView.frame = combinedFrame
self.backgroundView.update(size: combinedFrame.size, cornerRadius: combinedFrame.height * 0.5, isDark: interfaceState.theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: interfaceState.theme.chat.inputPanel.inputBackgroundColor.withMultipliedAlpha(0.7)), transition: ComponentTransition(transition))
self.buttonNode.frame = combinedFrame
return panelHeight
}
override func minimalHeight(interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics) -> CGFloat {
return defaultHeight(metrics: metrics)
}
}
@@ -0,0 +1,147 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import TelegramPresentationData
import SearchBarNode
import LocalizedPeerData
import SwiftSignalKit
import AccountContext
import ChatPresentationInterfaceState
private let searchBarFont = Font.regular(17.0)
final class ChatSearchNavigationContentNode: NavigationBarContentNode {
private let context: AccountContext
private let theme: PresentationTheme
private let strings: PresentationStrings
private let chatLocation: ChatLocation
private let searchBar: SearchBarNode
private let interaction: ChatPanelInterfaceInteraction
private var searchingActivityDisposable: Disposable?
init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, chatLocation: ChatLocation, interaction: ChatPanelInterfaceInteraction, presentationInterfaceState: ChatPresentationInterfaceState) {
self.context = context
self.theme = theme
self.strings = strings
self.chatLocation = chatLocation
self.interaction = interaction
self.searchBar = SearchBarNode(theme: SearchBarNodeTheme(theme: theme, hasBackground: false, hasSeparator: false), strings: strings, fieldStyle: .modern)
let placeholderText: String
switch chatLocation {
case .peer, .replyThread, .customChatContents:
if chatLocation.peerId == context.account.peerId, presentationInterfaceState.hasSearchTags {
if case .standard(.embedded(false)) = presentationInterfaceState.mode {
placeholderText = strings.Common_Search
} else {
placeholderText = strings.Chat_SearchTagsPlaceholder
}
} else {
placeholderText = strings.Conversation_SearchPlaceholder
}
}
self.searchBar.placeholderString = NSAttributedString(string: placeholderText, font: searchBarFont, textColor: theme.rootController.navigationSearchBar.inputPlaceholderTextColor)
super.init()
self.addSubnode(self.searchBar)
self.searchBar.cancel = { [weak self] in
self?.searchBar.deactivate(clear: false)
self?.interaction.dismissMessageSearch()
}
self.searchBar.textUpdated = { [weak self] query, _ in
self?.interaction.updateMessageSearch(query)
}
self.searchBar.clearPrefix = { [weak self] in
self?.interaction.toggleMembersSearch(false)
}
self.searchBar.clearTokens = { [weak self] in
self?.interaction.toggleMembersSearch(false)
}
self.searchBar.tokensUpdated = { [weak self] tokens in
if tokens.isEmpty {
self?.interaction.toggleMembersSearch(false)
}
}
if let statuses = interaction.statuses {
self.searchingActivityDisposable = (statuses.searching
|> deliverOnMainQueue).startStrict(next: { [weak self] value in
self?.searchBar.activity = value
})
}
}
deinit {
self.searchingActivityDisposable?.dispose()
}
override var nominalHeight: CGFloat {
return 54.0
}
override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) {
let searchBarFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - self.nominalHeight), size: CGSize(width: size.width, height: 54.0))
self.searchBar.frame = searchBarFrame
self.searchBar.updateLayout(boundingSize: searchBarFrame.size, leftInset: leftInset, rightInset: rightInset, transition: transition)
}
func activate() {
self.searchBar.activate()
}
func deactivate() {
self.searchBar.deactivate(clear: false)
}
func update(presentationInterfaceState: ChatPresentationInterfaceState) {
if let search = presentationInterfaceState.search {
self.searchBar.updateThemeAndStrings(theme: SearchBarNodeTheme(theme: presentationInterfaceState.theme, hasBackground: false, hasSeparator: false), strings: presentationInterfaceState.strings)
switch search.domain {
case .everything, .tag:
self.searchBar.tokens = []
self.searchBar.prefixString = nil
let placeholderText: String
switch self.chatLocation {
case .peer, .replyThread, .customChatContents:
if presentationInterfaceState.historyFilter != nil {
placeholderText = self.strings.Common_Search
} else if self.chatLocation.peerId == self.context.account.peerId, presentationInterfaceState.hasSearchTags {
if case .standard(.embedded(false)) = presentationInterfaceState.mode {
placeholderText = strings.Common_Search
} else {
placeholderText = self.strings.Chat_SearchTagsPlaceholder
}
} else {
placeholderText = self.strings.Conversation_SearchPlaceholder
}
}
self.searchBar.placeholderString = NSAttributedString(string: placeholderText, font: searchBarFont, textColor: theme.rootController.navigationSearchBar.inputPlaceholderTextColor)
case .members:
self.searchBar.tokens = []
self.searchBar.prefixString = NSAttributedString(string: strings.Conversation_SearchByName_Prefix, font: searchBarFont, textColor: theme.rootController.navigationSearchBar.inputTextColor)
self.searchBar.placeholderString = nil
case let .member(peer):
self.searchBar.tokens = [SearchBarToken(id: peer.id, icon: UIImage(bundleImageName: "Chat List/Search/User"), title: EnginePeer(peer).compactDisplayTitle, permanent: false)]
self.searchBar.prefixString = nil
self.searchBar.placeholderString = nil
}
if self.searchBar.text != search.query {
self.searchBar.text = search.query
self.interaction.updateMessageSearch(search.query)
}
}
}
}
@@ -0,0 +1,465 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramStringFormatting
import MergeLists
import ChatListUI
import AccountContext
import ContextUI
import ChatListSearchItemHeader
import AnimationCache
import MultiAnimationRenderer
private enum ChatListSearchEntryStableId: Hashable {
case messageId(MessageId)
public static func ==(lhs: ChatListSearchEntryStableId, rhs: ChatListSearchEntryStableId) -> Bool {
switch lhs {
case let .messageId(messageId):
if case .messageId(messageId) = rhs {
return true
} else {
return false
}
}
}
}
private enum ChatListSearchEntry: Comparable, Identifiable {
case message(Message, RenderedPeer, CombinedPeerReadState?, ChatListPresentationData)
public var stableId: ChatListSearchEntryStableId {
switch self {
case let .message(message, _, _, _):
return .messageId(message.id)
}
}
public static func ==(lhs: ChatListSearchEntry, rhs: ChatListSearchEntry) -> Bool {
switch lhs {
case let .message(lhsMessage, lhsPeer, lhsCombinedPeerReadState, lhsPresentationData):
if case let .message(rhsMessage, rhsPeer, rhsCombinedPeerReadState, rhsPresentationData) = rhs {
if lhsMessage.id != rhsMessage.id {
return false
}
if lhsMessage.stableVersion != rhsMessage.stableVersion {
return false
}
if lhsPeer != rhsPeer {
return false
}
if lhsPresentationData !== rhsPresentationData {
return false
}
if lhsCombinedPeerReadState != rhsCombinedPeerReadState {
return false
}
return true
} else {
return false
}
}
}
public static func <(lhs: ChatListSearchEntry, rhs: ChatListSearchEntry) -> Bool {
switch lhs {
case let .message(lhsMessage, _, _, _):
if case let .message(rhsMessage, _, _, _) = rhs {
return lhsMessage.index < rhsMessage.index
}
}
return false
}
public func item(context: AccountContext, interaction: ChatListNodeInteraction, location: ChatListControllerLocation) -> ListViewItem {
switch self {
case let .message(message, peer, readState, presentationData):
var displayAsMessage = true
if case .savedMessagesChats = location {
displayAsMessage = false
}
return ChatListItem(
presentationData: presentationData,
context: context,
chatListLocation: location,
filterData: nil,
index: .chatList(EngineChatList.Item.Index.ChatList(pinningIndex: nil, messageIndex: message.index)),
content: .peer(ChatListItemContent.PeerData(
messages: [EngineMessage(message)],
peer: EngineRenderedPeer(peer),
threadInfo: nil,
combinedReadState: readState.flatMap { EnginePeerReadCounters(state: $0, isMuted: false) },
isRemovedFromTotalUnreadCount: false,
presence: nil,
hasUnseenMentions: false,
hasUnseenReactions: false,
draftState: nil,
mediaDraftContentType: nil,
inputActivities: nil,
promoInfo: nil,
ignoreUnreadBadge: true,
displayAsMessage: displayAsMessage,
hasFailedMessages: false,
forumTopicData: nil,
topForumTopicItems: [],
autoremoveTimeout: nil,
storyState: nil,
requiresPremiumForMessaging: false,
displayAsTopicList: false,
tags: []
)),
editing: false,
hasActiveRevealControls: false,
selected: false,
header: nil,
enabledContextActions: nil,
hiddenOffset: false,
interaction: interaction
)
}
}
}
public struct ChatListSearchContainerTransition {
public let deletions: [ListViewDeleteItem]
public let insertions: [ListViewInsertItem]
public let updates: [ListViewUpdateItem]
public init(deletions: [ListViewDeleteItem], insertions: [ListViewInsertItem], updates: [ListViewUpdateItem]) {
self.deletions = deletions
self.insertions = insertions
self.updates = updates
}
}
private func chatListSearchContainerPreparedTransition(from fromEntries: [ChatListSearchEntry], to toEntries: [ChatListSearchEntry], context: AccountContext, interaction: ChatListNodeInteraction, location: ChatListControllerLocation) -> ChatListSearchContainerTransition {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, interaction: interaction, location: location), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, interaction: interaction, location: location), directionHint: nil) }
return ChatListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates)
}
class ChatSearchResultsControllerNode: ViewControllerTracingNode, ASScrollViewDelegate {
private let context: AccountContext
private var presentationData: PresentationData
private let animationCache: AnimationCache
private let animationRenderer: MultiAnimationRenderer
private let location: SearchMessagesLocation
private let searchQuery: String
private var searchResult: SearchMessagesResult
private var searchState: SearchMessagesState
private let mappedLocation: ChatListControllerLocation
private var interaction: ChatListNodeInteraction?
private let listNode: ListView
private var enqueuedTransitions: [(ChatListSearchContainerTransition, Bool)] = []
private var validLayout: (ContainerViewLayout, CGFloat)?
var resultsUpdated: ((SearchMessagesResult, SearchMessagesState) -> Void)?
var resultSelected: ((Int) -> Void)?
private let presentationDataPromise: Promise<ChatListPresentationData>
private let disposable = MetaDisposable()
private var isLoadingMore = false
private let loadMoreDisposable = MetaDisposable()
private let previousEntries = Atomic<[ChatListSearchEntry]?>(value: nil)
init(context: AccountContext, location: SearchMessagesLocation, searchQuery: String, searchResult: SearchMessagesResult, searchState: SearchMessagesState, presentInGlobalOverlay: @escaping (ViewController) -> Void) {
self.context = context
self.location = location
self.searchQuery = searchQuery
self.searchResult = searchResult
self.searchState = searchState
if case let .peer(peerId, _, _, _, _, _, _) = location, peerId == context.account.peerId {
self.mappedLocation = .savedMessagesChats(peerId: peerId)
} else {
self.mappedLocation = .chatList(groupId: .root)
}
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.presentationData = presentationData
self.presentationDataPromise = Promise(ChatListPresentationData(theme: self.presentationData.theme, fontSize: self.presentationData.listsFontSize, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameSortOrder: self.presentationData.nameSortOrder, nameDisplayOrder: self.presentationData.nameDisplayOrder, disableAnimations: true))
self.animationCache = context.animationCache
self.animationRenderer = context.animationRenderer
self.listNode = ListView()
self.listNode.verticalScrollIndicatorColor = self.presentationData.theme.list.scrollIndicatorColor
self.listNode.accessibilityPageScrolledString = { row, count in
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
}
super.init()
self.backgroundColor = self.presentationData.theme.chatList.backgroundColor
self.isOpaque = false
self.addSubnode(self.listNode)
let signal = self.presentationDataPromise.get()
|> map { presentationData -> [ChatListSearchEntry] in
var entries: [ChatListSearchEntry] = []
for message in searchResult.messages {
var peer = RenderedPeer(message: message)
if let group = message.peers[message.id.peerId] as? TelegramGroup, let migrationReference = group.migrationReference {
if let channelPeer = message.peers[migrationReference.peerId] {
peer = RenderedPeer(peer: channelPeer)
}
}
entries.append(.message(message, peer, searchResult.readStates[peer.peerId], presentationData))
}
return entries
}
let interaction = ChatListNodeInteraction(context: context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, activateSearch: {
}, peerSelected: { _, _, _, _, _ in
}, disabledPeerSelected: { _, _, _ in
}, togglePeerSelected: { _, _ in
}, togglePeersSelection: { _, _ in
}, additionalCategorySelected: { _ in
}, messageSelected: { [weak self] peer, _, message, _ in
if let strongSelf = self {
if let index = strongSelf.searchResult.messages.firstIndex(where: { $0.index == message.index }) {
if message.id.peerId.namespace == Namespaces.Peer.SecretChat {
strongSelf.resultSelected?(index)
} else {
strongSelf.resultSelected?(strongSelf.searchResult.messages.count - index - 1)
}
}
strongSelf.listNode.clearHighlightAnimated(true)
}
}, groupSelected: { _ in
}, addContact: { _ in
}, setPeerIdWithRevealedOptions: { _, _ in
}, setItemPinned: { _, _ in
}, setPeerMuted: { _, _ in
}, setPeerThreadMuted: { _, _, _ in
}, deletePeer: { _, _ in
}, deletePeerThread: { _, _ in
}, setPeerThreadStopped: { _, _, _ in
}, setPeerThreadPinned: { _, _, _ in
}, setPeerThreadHidden: { _, _, _ in
}, updatePeerGrouping: { _, _ in
}, togglePeerMarkedUnread: { _, _ in
}, toggleArchivedFolderHiddenByDefault: {
}, toggleThreadsSelection: { _, _ in
}, hidePsa: { _ in
}, activateChatPreview: { [weak self] item, _, node, gesture, _ in
guard let strongSelf = self else {
gesture?.cancel()
return
}
switch item.content {
case let .peer(peerData):
if let message = peerData.messages.first {
let chatController = strongSelf.context.sharedContext.makeChatController(context: strongSelf.context, chatLocation: .peer(id: peerData.peer.peerId), subject: .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false), botStart: nil, mode: .standard(.previewing), params: nil)
chatController.canReadHistory.set(false)
let contextController = ContextController(presentationData: strongSelf.presentationData, source: .controller(ContextControllerContentSourceImpl(controller: chatController, sourceNode: node)), items: .single(ContextController.Items(content: .list([]))), gesture: gesture)
presentInGlobalOverlay(contextController)
} else {
gesture?.cancel()
}
default:
gesture?.cancel()
}
}, present: { _ in
}, openForumThread: { _, _ in
}, openStorageManagement: {
}, openPasswordSetup: {
}, openPremiumIntro: {
}, openPremiumGift: { _, _ in
}, openPremiumManagement: {
}, openActiveSessions: {
}, openBirthdaySetup: {
}, performActiveSessionAction: { _, _ in
}, openChatFolderUpdates: {
}, hideChatFolderUpdates: {
}, openStories: { _, _ in
}, openStarsTopup: { _ in
}, dismissNotice: { _ in
}, editPeer: { _ in
}, openWebApp: { _ in
}, openPhotoSetup: {
}, openAdInfo: { _, _ in
}, openAccountFreezeInfo: {
}, openUrl: { _ in
})
interaction.searchTextHighightState = searchQuery
self.interaction = interaction
self.disposable.set((signal
|> deliverOnMainQueue).startStrict(next: { [weak self] entries in
if let strongSelf = self {
let previousEntries = strongSelf.previousEntries.swap(entries)
let firstTime = previousEntries == nil
let transition = chatListSearchContainerPreparedTransition(from: previousEntries ?? [], to: entries, context: context, interaction: interaction, location: strongSelf.mappedLocation)
strongSelf.enqueueTransition(transition, firstTime: firstTime)
}
}))
self.listNode.visibleBottomContentOffsetChanged = { [weak self] offset in
guard let strongSelf = self else {
return
}
guard case let .known(value) = offset, value < 100.0 else {
return
}
if strongSelf.searchResult.completed {
return
}
if strongSelf.isLoadingMore {
return
}
strongSelf.loadMore()
}
}
deinit {
self.disposable.dispose()
self.loadMoreDisposable.dispose()
}
private func loadMore() {
self.isLoadingMore = true
self.loadMoreDisposable.set((self.context.engine.messages.searchMessages(location: self.location, query: self.searchQuery, state: self.searchState)
|> deliverOnMainQueue).startStrict(next: { [weak self] (updatedResult, updatedState) in
guard let strongSelf = self else {
return
}
guard let interaction = strongSelf.interaction else {
return
}
strongSelf.isLoadingMore = false
strongSelf.searchResult = updatedResult
strongSelf.searchState = updatedState
strongSelf.resultsUpdated?(updatedResult, updatedState)
let context = strongSelf.context
let signal = strongSelf.presentationDataPromise.get()
|> map { presentationData -> [ChatListSearchEntry] in
var entries: [ChatListSearchEntry] = []
for message in updatedResult.messages {
var peer = RenderedPeer(message: message)
if let group = message.peers[message.id.peerId] as? TelegramGroup, let migrationReference = group.migrationReference {
if let channelPeer = message.peers[migrationReference.peerId] {
peer = RenderedPeer(peer: channelPeer)
}
}
entries.append(.message(message, peer, nil, presentationData))
}
return entries
}
strongSelf.disposable.set((signal
|> deliverOnMainQueue).startStrict(next: { entries in
if let strongSelf = self {
let previousEntries = strongSelf.previousEntries.swap(entries)
let firstTime = previousEntries == nil
let transition = chatListSearchContainerPreparedTransition(from: previousEntries ?? [], to: entries, context: context, interaction: interaction, location: strongSelf.mappedLocation)
strongSelf.enqueueTransition(transition, firstTime: firstTime)
}
}))
}))
}
func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
self.presentationDataPromise.set(.single(ChatListPresentationData(theme: self.presentationData.theme, fontSize: self.presentationData.listsFontSize, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameSortOrder: self.presentationData.nameSortOrder, nameDisplayOrder: self.presentationData.nameDisplayOrder, disableAnimations: true)))
self.listNode.forEachItemHeaderNode({ itemHeaderNode in
if let itemHeaderNode = itemHeaderNode as? ChatListSearchItemHeaderNode {
itemHeaderNode.updateTheme(theme: presentationData.theme)
}
})
}
private func enqueueTransition(_ transition: ChatListSearchContainerTransition, firstTime: Bool) {
self.enqueuedTransitions.append((transition, firstTime))
if self.validLayout != nil {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func dequeueTransition() {
if let (transition, _) = self.enqueuedTransitions.first {
self.enqueuedTransitions.remove(at: 0)
var options = ListViewDeleteAndInsertOptions()
options.insert(.PreferSynchronousDrawing)
options.insert(.PreferSynchronousResourceLoading)
self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { _ in
})
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
let hadValidLayout = self.validLayout != nil
self.validLayout = (layout, navigationBarHeight)
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
self.listNode.frame = CGRect(origin: CGPoint(), size: layout.size)
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: UIEdgeInsets(top: navigationBarHeight, left: layout.safeInsets.left, bottom: layout.insets(options: [.input]).bottom, right: layout.safeInsets.right), duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
if !hadValidLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
}
private final class ContextControllerContentSourceImpl: ContextControllerContentSource {
let controller: ViewController
weak var sourceNode: ASDisplayNode?
let navigationController: NavigationController? = nil
let passthroughTouches: Bool = true
init(controller: ViewController, sourceNode: ASDisplayNode?) {
self.controller = controller
self.sourceNode = sourceNode
}
func transitionInfo() -> ContextControllerTakeControllerInfo? {
let sourceNode = self.sourceNode
return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceNode] in
if let sourceNode = sourceNode {
return (sourceNode.view, sourceNode.bounds)
} else {
return nil
}
})
}
func animatedIn() {
}
}
@@ -0,0 +1,87 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import AccountContext
final class ChatSearchResultsController: ViewController {
private var controllerNode: ChatSearchResultsControllerNode {
return self.displayNode as! ChatSearchResultsControllerNode
}
private let context: AccountContext
private var presentationData: PresentationData
private let location: SearchMessagesLocation
private let searchQuery: String
private let searchResult: SearchMessagesResult
private let searchState: SearchMessagesState
private let navigateToMessageIndex: (Int) -> Void
private let resultsUpdated: (SearchMessagesResult, SearchMessagesState) -> Void
private var presentationDataDisposable: Disposable?
init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil, location: SearchMessagesLocation, searchQuery: String, searchResult: SearchMessagesResult, searchState: SearchMessagesState, navigateToMessageIndex: @escaping (Int) -> Void, resultsUpdated: @escaping (SearchMessagesResult, SearchMessagesState) -> Void) {
self.context = context
self.presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 }
self.location = location
self.searchQuery = searchQuery
self.navigateToMessageIndex = navigateToMessageIndex
self.resultsUpdated = resultsUpdated
self.searchResult = searchResult
self.searchState = searchState
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationTheme: self.presentationData.theme, presentationStrings: self.presentationData.strings))
self.navigationPresentation = .modal
self.presentationDataDisposable = ((updatedPresentationData?.signal ?? context.sharedContext.presentationData)
|> deliverOnMainQueue).startStrict(next: { [weak self] presentationData in
if let strongSelf = self {
strongSelf.presentationData = presentationData
strongSelf.navigationBar?.updatePresentationData(NavigationBarPresentationData(presentationTheme: presentationData.theme, presentationStrings: presentationData.strings))
strongSelf.controllerNode.updatePresentationData(presentationData)
}
})
self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style
self.title = searchQuery
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Done, style: .done, target: self, action: #selector(donePressed))
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.presentationDataDisposable?.dispose()
}
override public func loadDisplayNode() {
self.displayNode = ChatSearchResultsControllerNode(context: self.context, location: self.location, searchQuery: self.searchQuery, searchResult: self.searchResult, searchState: self.searchState, presentInGlobalOverlay: { [weak self] c in
self?.presentInGlobalOverlay(c)
})
self.controllerNode.resultSelected = { [weak self] messageIndex in
self?.navigateToMessageIndex(messageIndex)
self?.dismiss()
}
self.controllerNode.resultsUpdated = { [weak self] result, state in
self?.resultsUpdated(result, state)
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
@objc private func donePressed() {
self.dismiss()
}
}
@@ -0,0 +1,9 @@
import Foundation
import Postbox
import TelegramCore
struct ChatSearchState: Equatable {
let query: String
let location: SearchMessagesLocation
let loadMoreState: SearchMessagesState?
}
@@ -0,0 +1,827 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramPresentationData
import ChatPresentationInterfaceState
import AccountContext
import ComponentFlow
import MultilineTextComponent
import PlainButtonComponent
import UIKitRuntimeUtils
import TelegramCore
import Postbox
import EmojiStatusComponent
import SwiftSignalKit
import ContextUI
import PromptUI
import BundleIconComponent
import SavedTagNameAlertController
private let backgroundTagImage: UIImage? = {
if let image = UIImage(bundleImageName: "Chat/Title Panels/SearchTagTab") {
return image.stretchableImage(withLeftCapWidth: 8, topCapHeight: 0).withRenderingMode(.alwaysTemplate)
} else {
return nil
}
}()
final class ChatSearchTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, ChatControllerCustomNavigationPanelNode, ASScrollViewDelegate {
private struct Params: Equatable {
var width: CGFloat
var leftInset: CGFloat
var rightInset: CGFloat
var interfaceState: ChatPresentationInterfaceState
init(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, interfaceState: ChatPresentationInterfaceState) {
self.width = width
self.leftInset = leftInset
self.rightInset = rightInset
self.interfaceState = interfaceState
}
static func ==(lhs: Params, rhs: Params) -> Bool {
if lhs.width != rhs.width {
return false
}
if lhs.leftInset != rhs.leftInset {
return false
}
if lhs.rightInset != rhs.rightInset {
return false
}
if lhs.interfaceState != rhs.interfaceState {
return false
}
return true
}
}
private final class Item {
let reaction: MessageReaction.Reaction
let count: Int
let title: String?
let file: TelegramMediaFile
init(reaction: MessageReaction.Reaction, count: Int, title: String?, file: TelegramMediaFile) {
self.reaction = reaction
self.count = count
self.title = title
self.file = file
}
}
private final class PromoView: UIView {
private let containerButton: HighlightTrackingButton
private let background: UIImageView
private let titleIcon = ComponentView<Empty>()
private let title = ComponentView<Empty>()
private let text = ComponentView<Empty>()
private let arrowIcon = ComponentView<Empty>()
let action: () -> Void
init(action: @escaping () -> Void) {
self.action = action
self.containerButton = HighlightTrackingButton()
self.background = UIImageView()
self.background.image = backgroundTagImage
super.init(frame: CGRect())
self.containerButton.layer.allowsGroupOpacity = true
self.containerButton.addSubview(self.background)
self.addSubview(self.containerButton)
self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.containerButton.highligthedChanged = { [weak self] highlighted in
guard let self else {
return
}
if highlighted {
self.containerButton.alpha = 0.7
} else {
ComponentTransition.easeInOut(duration: 0.25).setAlpha(view: self.containerButton, alpha: 1.0)
}
}
}
required init?(coder: NSCoder) {
preconditionFailure()
}
@objc private func pressed() {
self.action()
}
func update(theme: PresentationTheme, strings: PresentationStrings, height: CGFloat, isUnlock: Bool, transition: ComponentTransition) -> CGSize {
let titleIconSpacing: CGFloat = 0.0
let titleIconSize = self.titleIcon.update(
transition: .immediate,
component: AnyComponent(BundleIconComponent(
name: "Chat/Stickers/Lock",
tintColor: theme.rootController.navigationBar.accentTextColor,
maxSize: CGSize(width: 14.0, height: 14.0)
)),
environment: {},
containerSize: CGSize(width: 14.0, height: 14.0)
)
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: isUnlock ? strings.Chat_TagsHeaderPanel_Unlock : strings.Chat_TagsHeaderPanel_AddTags, font: Font.medium(14.0), textColor: theme.rootController.navigationBar.accentTextColor))
)),
environment: {},
containerSize: CGSize(width: 200.0, height: 100.0)
)
let size = CGSize(width: titleIconSize.width + titleIconSpacing + titleSize.width - 1.0, height: height)
let titleIconFrame = CGRect(origin: CGPoint(x: -1.0, y: UIScreenPixel + floor((size.height - titleIconSize.height) * 0.5)), size: titleIconSize)
if let titleIconView = self.titleIcon.view {
if titleIconView.superview == nil {
titleIconView.isUserInteractionEnabled = false
self.containerButton.addSubview(titleIconView)
}
titleIconView.frame = titleIconFrame
}
let titleFrame = CGRect(origin: CGPoint(x: titleIconSize.width + titleIconSpacing, y: floor((size.height - titleSize.height) * 0.5)), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.containerButton.addSubview(titleView)
}
titleView.frame = titleFrame
}
self.background.tintColor = theme.rootController.navigationBar.accentTextColor.withMultipliedAlpha(0.1)
if let image = self.background.image {
let backgroundFrame = CGRect(origin: CGPoint(x: -6.0, y: floorToScreenPixels((size.height - image.size.height) * 0.5)), size: CGSize(width: size.width + 6.0 + 9.0, height: image.size.height))
transition.setFrame(view: self.background, frame: backgroundFrame)
}
var totalSize = size
let textSize = self.text.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: strings.Chat_TagsHeaderPanel_AddTagsSuffix, font: Font.regular(14.0), textColor: theme.rootController.navigationBar.secondaryTextColor))
)),
environment: {},
containerSize: CGSize(width: 200.0, height: 100.0)
)
let arrowSize = self.arrowIcon.update(
transition: .immediate,
component: AnyComponent(BundleIconComponent(
name: "Item List/DisclosureArrow",
tintColor: theme.rootController.navigationBar.secondaryTextColor.withMultipliedAlpha(0.6)
)),
environment: {},
containerSize: CGSize(width: 200.0, height: 100.0)
)
let textSpacing: CGFloat = 13.0
let arrowSpacing: CGFloat = -5.0
totalSize.width += textSpacing
let textFrame = CGRect(origin: CGPoint(x: totalSize.width, y: floor((size.height - textSize.height) * 0.5)), size: textSize)
if let textView = self.text.view {
if textView.superview == nil {
textView.isUserInteractionEnabled = false
self.containerButton.addSubview(textView)
}
textView.frame = textFrame
transition.setAlpha(view: textView, alpha: isUnlock ? 0.0 : 1.0)
}
totalSize.width += textSize.width
totalSize.width += arrowSpacing
let arrowFrame = CGRect(origin: CGPoint(x: totalSize.width, y: 1.0 + floor((size.height - arrowSize.height) * 0.5)), size: arrowSize)
if let arrowIconView = self.arrowIcon.view {
if arrowIconView.superview == nil {
arrowIconView.isUserInteractionEnabled = false
self.containerButton.addSubview(arrowIconView)
}
arrowIconView.frame = arrowFrame
transition.setAlpha(view: arrowIconView, alpha: isUnlock ? 0.0 : 1.0)
}
totalSize.width += arrowSize.width
transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: totalSize))
return isUnlock ? size : totalSize
}
}
private final class ItemView: UIView {
private let context: AccountContext
private let action: () -> Void
private let extractedContainerNode: ContextExtractedContentContainingNode
private let containerNode: ContextControllerSourceNode
private let containerButton: HighlightTrackingButton
private let background: UIImageView
private let icon = ComponentView<Empty>()
private let title = ComponentView<Empty>()
private let counter = ComponentView<Empty>()
init(context: AccountContext, action: @escaping (() -> Void), contextGesture: @escaping (ContextGesture, ContextExtractedContentContainingNode) -> Void) {
self.context = context
self.action = action
self.extractedContainerNode = ContextExtractedContentContainingNode()
self.containerNode = ContextControllerSourceNode()
self.containerButton = HighlightTrackingButton()
self.background = UIImageView()
self.background.image = backgroundTagImage
super.init(frame: CGRect())
self.extractedContainerNode.contentNode.view.addSubview(self.containerButton)
self.containerNode.addSubnode(self.extractedContainerNode)
self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode
self.addSubview(self.containerNode.view)
self.containerButton.addSubview(self.background)
self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.containerButton.highligthedChanged = { [weak self] highlighted in
if let self, self.bounds.width > 0.0 {
let topScale: CGFloat = (self.bounds.width - 1.0) / self.bounds.width
let maxScale: CGFloat = (self.bounds.width + 1.0) / self.bounds.width
if highlighted {
self.layer.removeAnimation(forKey: "opacity")
self.layer.removeAnimation(forKey: "sublayerTransform")
let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut)
transition.updateTransformScale(layer: self.layer, scale: topScale)
} else {
let transition: ContainedViewLayoutTransition = .immediate
transition.updateTransformScale(layer: self.layer, scale: 1.0)
self.layer.animateScale(from: topScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in
guard let self else {
return
}
self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue)
})
}
}
}
self.containerNode.activated = { [weak self] gesture, _ in
guard let self else {
return
}
contextGesture(gesture, self.extractedContainerNode)
}
}
required init?(coder: NSCoder) {
preconditionFailure()
}
@objc private func pressed() {
self.action()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
var mappedPoint = point
if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) {
mappedPoint = self.bounds.center
}
return super.hitTest(mappedPoint, with: event)
}
func update(item: Item, isSelected: Bool, isLocked: Bool, theme: PresentationTheme, height: CGFloat, transition: ComponentTransition) -> CGSize {
let spacing: CGFloat = 3.0
let contentsAlpha: CGFloat = isLocked ? 0.6 : 1.0
let reactionSize = CGSize(width: 20.0, height: 20.0)
var reactionDisplaySize = reactionSize
if case .builtin = item.reaction {
reactionDisplaySize = CGSize(width: reactionDisplaySize.width * 2.0, height: reactionDisplaySize.height * 2.0)
}
let _ = self.icon.update(
transition: .immediate,
component: AnyComponent(EmojiStatusComponent(
context: self.context,
animationCache: self.context.animationCache,
animationRenderer: self.context.animationRenderer,
content: .animation(
content: .file(file: item.file),
size: reactionDisplaySize,
placeholderColor: theme.list.mediaPlaceholderColor,
themeColor: theme.list.itemPrimaryTextColor,
loopMode: .forever
),
isVisibleForAnimations: false,
useSharedAnimation: true,
action: nil,
emojiFileUpdated: nil
)),
environment: {},
containerSize: reactionDisplaySize
)
let titleText: String = item.title ?? ""
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: titleText, font: Font.regular(11.0), textColor: isSelected ? theme.list.itemCheckColors.foregroundColor : theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.6)))
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let counterText: String = "\(item.count)"
let counterSize = self.counter.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: counterText, font: Font.regular(11.0), textColor: isSelected ? theme.list.itemCheckColors.foregroundColor : theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.6)))
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let titleCounterSpacing: CGFloat = 3.0
var titleAndCounterSize: CGFloat = titleSize.width
if titleSize.width != 0.0 {
titleAndCounterSize += titleCounterSpacing
}
titleAndCounterSize += counterSize.width
let size = CGSize(width: reactionSize.width + spacing + titleAndCounterSize - 2.0, height: height)
let iconFrame = CGRect(origin: CGPoint(x: -1.0, y: floor((size.height - reactionSize.height) * 0.5)), size: reactionSize)
let titleFrame = CGRect(origin: CGPoint(x: iconFrame.maxX + spacing, y: floor((size.height - titleSize.height) * 0.5)), size: titleSize)
let counterFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + (titleSize.width.isZero ? 0.0 : titleCounterSpacing), y: floor((size.height - counterSize.height) * 0.5)), size: counterSize)
if let iconView = self.icon.view {
if iconView.superview == nil {
iconView.isUserInteractionEnabled = false
self.containerButton.addSubview(iconView)
}
iconView.frame = reactionDisplaySize.centered(around: iconFrame.center)
transition.setAlpha(view: iconView, alpha: contentsAlpha)
}
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.containerButton.addSubview(titleView)
}
titleView.frame = titleFrame
transition.setAlpha(view: titleView, alpha: contentsAlpha)
}
if let counterView = self.counter.view {
if counterView.superview == nil {
counterView.isUserInteractionEnabled = false
self.containerButton.addSubview(counterView)
}
counterView.frame = counterFrame
transition.setAlpha(view: counterView, alpha: contentsAlpha)
}
if theme.overallDarkAppearance {
self.background.tintColor = isSelected ? theme.list.itemCheckColors.fillColor : UIColor(white: 1.0, alpha: 0.1)
} else {
self.background.tintColor = isSelected ? theme.list.itemCheckColors.fillColor : theme.rootController.navigationSearchBar.inputFillColor
}
if let image = self.background.image {
let backgroundFrame = CGRect(origin: CGPoint(x: -6.0, y: floorToScreenPixels((size.height - image.size.height) * 0.5)), size: CGSize(width: size.width + 6.0 + 9.0, height: image.size.height))
transition.setFrame(view: self.background, frame: backgroundFrame)
}
transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size))
self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size)
self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size)
self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))
self.containerNode.frame = CGRect(origin: CGPoint(), size: size)
return size
}
}
private final class ScrollView: UIScrollView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return super.hitTest(point, with: event)
}
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
private let context: AccountContext
private let scrollView: ScrollView
private var params: Params?
private var items: [Item] = []
private var itemViews: [MessageReaction.Reaction: ItemView] = [:]
private var promoView: PromoView?
private var itemsDisposable: Disposable?
private var appliedScrollToTag: MemoryBuffer?
init(context: AccountContext, chatLocation: ChatLocation) {
self.context = context
self.scrollView = ScrollView(frame: CGRect())
super.init()
self.scrollView.delaysContentTouches = false
self.scrollView.canCancelContentTouches = true
self.scrollView.clipsToBounds = false
self.scrollView.contentInsetAdjustmentBehavior = .never
if #available(iOS 13.0, *) {
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
}
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.alwaysBounceVertical = false
self.scrollView.scrollsToTop = false
self.scrollView.delegate = self.wrappedScrollViewDelegate
self.view.addSubview(self.scrollView)
self.scrollView.disablesInteractiveTransitionGestureRecognizer = true
let tagsAndFiles: Signal<([MessageReaction.Reaction: Int], [Int64: TelegramMediaFile]), NoError> = context.engine.data.subscribe(
TelegramEngine.EngineData.Item.Messages.SavedMessageTagStats(peerId: context.account.peerId, threadId: chatLocation.threadId)
)
|> distinctUntilChanged
|> mapToSignal { tags -> Signal<([MessageReaction.Reaction: Int], [Int64: TelegramMediaFile]), NoError> in
var customFileIds: [Int64] = []
for (reaction, _) in tags {
switch reaction {
case .builtin:
break
case let .custom(fileId):
customFileIds.append(fileId)
case .stars:
break
}
}
return context.engine.stickers.resolveInlineStickers(fileIds: customFileIds)
|> map { files in
return (tags, files)
}
}
var isFirstUpdate = true
self.itemsDisposable = (combineLatest(
context.availableReactions,
context.engine.stickers.savedMessageTagData(),
tagsAndFiles
)
|> deliverOnMainQueue).start(next: { [weak self] availableReactions, savedMessageTags, tagsAndFiles in
guard let self else {
return
}
self.items.removeAll()
let (tags, files) = tagsAndFiles
for (reaction, count) in tags {
let title = savedMessageTags?.tags.first(where: { $0.reaction == reaction })?.title
switch reaction {
case .builtin, .stars:
if let availableReactions {
inner: for availableReaction in availableReactions.reactions {
if availableReaction.value == reaction {
if let file = availableReaction.centerAnimation {
self.items.append(Item(reaction: reaction, count: count, title: title, file: file._parse()))
}
break inner
}
}
}
case let .custom(fileId):
if let file = files[fileId] {
self.items.append(Item(reaction: reaction, count: count, title: title, file: file))
}
}
}
self.items.sort(by: { lhs, rhs in
if lhs.count != rhs.count {
return lhs.count > rhs.count
}
return lhs.reaction < rhs.reaction
})
self.update(transition: isFirstUpdate ? .immediate : .animated(duration: 0.3, curve: .easeInOut))
isFirstUpdate = false
})
}
deinit {
self.itemsDisposable?.dispose()
}
private func update(transition: ContainedViewLayoutTransition) {
if let params = self.params {
self.update(params: params, transition: transition)
}
}
override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> LayoutResult {
let params = Params(width: width, leftInset: leftInset, rightInset: rightInset, interfaceState: interfaceState)
if self.params != params {
self.params = params
self.update(params: params, transition: transition)
}
let panelHeight: CGFloat = 39.0
return LayoutResult(backgroundHeight: panelHeight, insetHeight: panelHeight, hitTestSlop: 0.0)
}
func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, chatController: ChatController) -> LayoutResult {
return self.updateLayout(width: width, leftInset: leftInset, rightInset: rightInset, transition: transition, interfaceState: (chatController as! ChatControllerImpl).presentationInterfaceState)
}
private func update(params: Params, transition: ContainedViewLayoutTransition) {
let panelHeight: CGFloat = 39.0
let containerInsets = UIEdgeInsets(top: 0.0, left: params.leftInset + 16.0, bottom: 0.0, right: params.rightInset + 16.0)
let itemSpacing: CGFloat = 24.0
var contentSize = CGSize(width: 0.0, height: panelHeight)
contentSize.width += containerInsets.left
var validIds: [MessageReaction.Reaction] = []
let hadItemViews = !self.itemViews.isEmpty
var isFirst = true
if !params.interfaceState.isPremium {
let promoView: PromoView
var itemTransition = transition
if let current = self.promoView {
promoView = current
} else {
itemTransition = .immediate
promoView = PromoView(action: { [weak self] in
guard let self, let interfaceInteraction = self.interfaceInteraction else {
return
}
(interfaceInteraction.chatController() as? ChatControllerImpl)?.presentTagPremiumPaywall()
})
self.promoView = promoView
self.scrollView.addSubview(promoView)
}
let itemSize = promoView.update(theme: params.interfaceState.theme, strings: params.interfaceState.strings, height: panelHeight, isUnlock: !self.items.isEmpty, transition: .immediate)
let itemFrame = CGRect(origin: CGPoint(x: contentSize.width, y: -5.0), size: itemSize)
itemTransition.updatePosition(layer: promoView.layer, position: itemFrame.center)
promoView.bounds = CGRect(origin: CGPoint(), size: itemFrame.size)
contentSize.width += itemSize.width
isFirst = false
} else {
if let promoView = self.promoView {
self.promoView = nil
promoView.removeFromSuperview()
}
}
for item in self.items {
if isFirst {
isFirst = false
} else {
contentSize.width += itemSpacing
}
let itemId = item.reaction
validIds.append(itemId)
var itemTransition = transition
var animateIn = false
let itemView: ItemView
if let current = self.itemViews[itemId] {
itemView = current
} else {
itemTransition = .immediate
animateIn = true
let reaction = item.reaction
itemView = ItemView(context: self.context, action: { [weak self] in
guard let self, let params = self.params else {
return
}
if !params.interfaceState.isPremium {
if let chatController = self.interfaceInteraction?.chatController() {
(chatController as? ChatControllerImpl)?.presentTagPremiumPaywall()
}
return
}
let tag = ReactionsMessageAttribute.messageTag(reaction: reaction)
var updatedFilter: ChatPresentationInterfaceState.HistoryFilter?
let currentTag = params.interfaceState.historyFilter?.customTag
if currentTag == tag {
updatedFilter = nil
} else {
updatedFilter = ChatPresentationInterfaceState.HistoryFilter(customTag: tag, isActive: true)
}
self.interfaceInteraction?.updateHistoryFilter({ filter in
return updatedFilter
})
}, contextGesture: { [weak self] gesture, sourceNode in
guard let self, let params = self.params, let interfaceInteraction = self.interfaceInteraction, let chatController = interfaceInteraction.chatController() else {
gesture.cancel()
return
}
guard let item = self.items.first(where: { $0.reaction == reaction }) else {
gesture.cancel()
return
}
if !params.interfaceState.isPremium {
(chatController as? ChatControllerImpl)?.presentTagPremiumPaywall()
return
}
var items: [ContextMenuItem] = []
let presentationData = self.context.sharedContext.currentPresentationData.with({ $0 })
items.append(.action(ContextMenuActionItem(text: item.title != nil ? presentationData.strings.Chat_ReactionContextMenu_EditTagLabel : presentationData.strings.Chat_ReactionContextMenu_SetTagLabel, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/TagEditName"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] c, a in
guard let self else {
a(.default)
return
}
c?.dismiss(completion: { [weak self] in
guard let self, let item = self.items.first(where: { $0.reaction == reaction }) else {
return
}
self.openEditTagTitle(reaction: reaction, hasTitle: item.title != nil)
})
})))
let controller = ContextController(presentationData: presentationData, source: .extracted(TagContextExtractedContentSource(controller: chatController, sourceNode: sourceNode, keepInPlace: false)), items: .single(ContextController.Items(content: .list(items))), recognizer: nil, gesture: gesture)
interfaceInteraction.presentGlobalOverlayController(controller, nil)
})
self.itemViews[itemId] = itemView
self.scrollView.addSubview(itemView)
}
var isSelected = false
if let historyFilter = params.interfaceState.historyFilter {
if historyFilter.customTag == ReactionsMessageAttribute.messageTag(reaction: item.reaction) {
isSelected = true
}
}
let itemSize = itemView.update(item: item, isSelected: isSelected, isLocked: !params.interfaceState.isPremium, theme: params.interfaceState.theme, height: panelHeight, transition: .immediate)
let itemFrame = CGRect(origin: CGPoint(x: contentSize.width, y: -5.0), size: itemSize)
itemTransition.updatePosition(layer: itemView.layer, position: itemFrame.center)
itemTransition.updateBounds(layer: itemView.layer, bounds: CGRect(origin: CGPoint(), size: itemFrame.size))
if animateIn && transition.isAnimated {
itemView.layer.animateAlpha(from: 0.0, to: itemView.alpha, duration: 0.15)
transition.animateTransformScale(view: itemView, from: 0.001)
}
contentSize.width += itemSize.width
}
var removedIds: [MessageReaction.Reaction] = []
for (id, itemView) in self.itemViews {
if !validIds.contains(id) {
removedIds.append(id)
if transition.isAnimated {
itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak itemView] _ in
itemView?.removeFromSuperview()
})
transition.updateTransformScale(layer: itemView.layer, scale: 0.001)
} else {
itemView.removeFromSuperview()
}
}
}
for id in removedIds {
self.itemViews.removeValue(forKey: id)
}
contentSize.width += containerInsets.right
let scrollSize = CGSize(width: params.width, height: contentSize.height)
if self.scrollView.bounds.size != scrollSize {
self.scrollView.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: scrollSize)
}
if self.scrollView.contentSize != contentSize {
self.scrollView.contentSize = contentSize
}
let currentFilterTag = params.interfaceState.historyFilter?.customTag
if self.appliedScrollToTag != currentFilterTag {
if let tag = currentFilterTag {
if let reaction = ReactionsMessageAttribute.reactionFromMessageTag(tag: tag), let itemView = self.itemViews[reaction] {
self.appliedScrollToTag = currentFilterTag
self.scrollView.scrollRectToVisible(itemView.frame.insetBy(dx: -46.0, dy: 0.0), animated: hadItemViews)
}
} else {
self.appliedScrollToTag = currentFilterTag
}
}
}
private func openEditTagTitle(reaction: MessageReaction.Reaction, hasTitle: Bool) {
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let optionTitle = hasTitle ? presentationData.strings.Chat_EditTagTitle_TitleEdit : presentationData.strings.Chat_EditTagTitle_TitleSet
let reactionFile: Signal<TelegramMediaFile?, NoError>
switch reaction {
case .builtin, .stars:
reactionFile = self.context.engine.stickers.availableReactions()
|> take(1)
|> map { availableReactions -> TelegramMediaFile? in
return availableReactions?.reactions.first(where: { $0.value == reaction })?.selectAnimation._parse()
}
case let .custom(fileId):
reactionFile = self.context.engine.stickers.resolveInlineStickers(fileIds: [fileId])
|> map { files -> TelegramMediaFile? in
return files.values.first
}
}
let _ = (combineLatest(
self.context.engine.stickers.savedMessageTagData(),
reactionFile
)
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] savedMessageTags, reactionFile in
guard let self, let reactionFile else {
return
}
let promptController = savedTagNameAlertController(context: self.context, updatedPresentationData: nil, text: optionTitle, subtext: presentationData.strings.Chat_EditTagTitle_Text, value: savedMessageTags?.tags.first(where: { $0.reaction == reaction })?.title ?? "", reaction: reaction, file: reactionFile, characterLimit: 12, apply: { [weak self] value in
guard let self else {
return
}
if let value {
let _ = self.context.engine.stickers.setSavedMessageTagTitle(reaction: reaction, title: value.isEmpty ? nil : value).start()
}
})
self.interfaceInteraction?.presentController(promptController, nil)
})
}
}
private final class TagContextExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool
let ignoreContentTouches: Bool = true
let blurBackground: Bool = true
let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center
private let controller: ViewController
private let sourceNode: ContextExtractedContentContainingNode
init(controller: ViewController, sourceNode: ContextExtractedContentContainingNode, keepInPlace: Bool) {
self.controller = controller
self.sourceNode = sourceNode
self.keepInPlace = keepInPlace
}
func takeView() -> ContextControllerTakeViewInfo? {
return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds)
}
func putBack() -> ContextControllerPutBackViewInfo? {
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
@@ -0,0 +1,183 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import UIKit
import TelegramCore
import SwiftSignalKit
import Photos
import TelegramPresentationData
import AccountContext
final class ChatSecretAutoremoveTimerActionSheetController: ActionSheetController {
private var presentationDisposable: Disposable?
private let _ready = Promise<Bool>()
override var ready: Promise<Bool> {
return self._ready
}
init(context: AccountContext, currentValue: Int32, availableValues: [Int32]? = nil, applyValue: @escaping (Int32) -> Void) {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let strings = presentationData.strings
super.init(theme: ActionSheetControllerTheme(presentationData: presentationData))
self.presentationDisposable = context.sharedContext.presentationData.startStrict(next: { [weak self] presentationData in
if let strongSelf = self {
strongSelf.theme = ActionSheetControllerTheme(presentationData: presentationData)
}
})
self._ready.set(.single(true))
var updatedValue: Int32
if currentValue > 0 {
updatedValue = currentValue
} else {
if let availableValues = availableValues {
updatedValue = availableValues[0]
} else {
updatedValue = 7
}
}
self.setItemGroups([
ActionSheetItemGroup(items: [
AutoremoveTimeoutSelectorItem(strings: strings, currentValue: updatedValue, availableValues: availableValues, valueChanged: { value in
updatedValue = value
}),
ActionSheetButtonItem(title: strings.Common_Done, font: .bold, action: { [weak self] in
self?.dismissAnimated()
applyValue(updatedValue)
})
]),
])
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.presentationDisposable?.dispose()
}
}
private final class AutoremoveTimeoutSelectorItem: ActionSheetItem {
let strings: PresentationStrings
let currentValue: Int32
let availableValues: [Int32]?
let valueChanged: (Int32) -> Void
init(strings: PresentationStrings, currentValue: Int32, availableValues: [Int32]?, valueChanged: @escaping (Int32) -> Void) {
self.strings = strings
self.currentValue = currentValue
self.availableValues = availableValues
self.valueChanged = valueChanged
}
func node(theme: ActionSheetControllerTheme) -> ActionSheetItemNode {
return AutoremoveTimeoutSelectorItemNode(theme: theme, strings: self.strings, currentValue: self.currentValue, availableValues: self.availableValues, valueChanged: self.valueChanged)
}
func updateNode(_ node: ActionSheetItemNode) {
}
}
private let defaultTimeoutValues: [Int32] = [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
30,
1 * 60,
1 * 60 * 60,
24 * 60 * 60,
7 * 24 * 60 * 60
]
private final class AutoremoveTimeoutSelectorItemNode: ActionSheetItemNode, UIPickerViewDelegate, UIPickerViewDataSource {
private let theme: ActionSheetControllerTheme
private let strings: PresentationStrings
private let timeoutValues: [Int32]
private let valueChanged: (Int32) -> Void
private let pickerView: UIPickerView
init(theme: ActionSheetControllerTheme, strings: PresentationStrings, currentValue: Int32, availableValues: [Int32]?, valueChanged: @escaping (Int32) -> Void) {
self.theme = theme
self.strings = strings
self.valueChanged = valueChanged
self.pickerView = UIPickerView()
if let availableValues = availableValues {
self.timeoutValues = [0] + availableValues.filter({ $0 > 0 })
} else {
self.timeoutValues = defaultTimeoutValues
}
super.init(theme: theme)
self.pickerView.delegate = self
self.pickerView.dataSource = self
self.view.addSubview(self.pickerView)
self.pickerView.reloadAllComponents()
var index: Int = 0
for i in 0 ..< self.timeoutValues.count {
if currentValue <= self.timeoutValues[i] {
index = i
break
}
}
self.pickerView.selectRow(index, inComponent: 0, animated: false)
}
func numberOfComponents(in pickerView: UIPickerView) -> Int {
return 1
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
return self.timeoutValues.count
}
func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat {
return 40.0
}
func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? {
if self.timeoutValues[row] == 0 {
return NSAttributedString(string: self.strings.Profile_MessageLifetimeForever, font: Font.medium(15.0), textColor: self.theme.primaryTextColor)
} else {
return NSAttributedString(string: timeIntervalString(strings: self.strings, value: self.timeoutValues[row]), font: Font.medium(15.0), textColor: self.theme.primaryTextColor)
}
}
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
self.valueChanged(self.timeoutValues[row])
}
public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
let size = CGSize(width: constrainedSize.width, height: 180.0)
self.pickerView.frame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: 180.0))
self.updateInternalLayout(size, constrainedSize: constrainedSize)
return size
}
}
@@ -0,0 +1,51 @@
import Foundation
import Display
import TelegramPresentationData
import SwiftSignalKit
import TelegramStringFormatting
import ChatPresentationInterfaceState
private func timeoutValue(strings: PresentationStrings, slowmodeState: ChatSlowmodeState) -> String {
switch slowmodeState.variant {
case .pendingMessages:
return strings.Chat_SlowmodeTooltipPending
case let .timestamp(untilTimestamp):
let timestamp = Int32(Date().timeIntervalSince1970)
let seconds = max(0, untilTimestamp - timestamp)
return strings.Chat_SlowmodeTooltip(stringForDuration(seconds)).string
}
}
final class ChatSlowmodeHintController: TooltipController {
private let strings: PresentationStrings
private let slowmodeState: ChatSlowmodeState
private var timer: SwiftSignalKit.Timer?
init(presentationData: PresentationData, slowmodeState: ChatSlowmodeState) {
self.strings = presentationData.strings
self.slowmodeState = slowmodeState
super.init(content: .text(timeoutValue(strings: presentationData.strings, slowmodeState: slowmodeState)), baseFontSize: presentationData.listsFontSize.baseDisplaySize, timeout: 2.0, dismissByTapOutside: false, dismissByTapOutsideSource: true)
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.timer?.invalidate()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let timer = SwiftSignalKit.Timer(timeout: 0.5, repeat: true, completion: { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.updateContent(.text(timeoutValue(strings: strongSelf.strings, slowmodeState: strongSelf.slowmodeState)), animated: false, extendTimer: false)
}, queue: .mainQueue())
self.timer = timer
timer.start()
}
}
@@ -0,0 +1,247 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import LegacyComponents
import TelegramPresentationData
import TooltipUI
private final class ChecksNodeParameters: NSObject {
let color: UIColor
let progress: CGFloat
init(color: UIColor, progress: CGFloat) {
self.color = color
self.progress = progress
super.init()
}
}
private class ChecksNode: ASDisplayNode {
var state: Bool? = nil
var color: UIColor {
didSet {
self.setNeedsDisplay()
}
}
private var effectiveProgress: CGFloat = 1.0 {
didSet {
self.setNeedsDisplay()
}
}
init(color: UIColor) {
self.color = color
super.init()
self.backgroundColor = .clear
self.isOpaque = false
}
func animateProgress(from: CGFloat, to: CGFloat) {
self.pop_removeAllAnimations()
let animation = POPBasicAnimation()
animation.property = (POPAnimatableProperty.property(withName: "progress", initializer: { property in
property?.readBlock = { node, values in
values?.pointee = (node as! ChecksNode).effectiveProgress
}
property?.writeBlock = { node, values in
(node as! ChecksNode).effectiveProgress = values!.pointee
}
property?.threshold = 0.01
}) as! POPAnimatableProperty)
animation.fromValue = from as NSNumber
animation.toValue = to as NSNumber
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
animation.duration = 0.2
self.pop_add(animation, forKey: "progress")
}
override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
return ChecksNodeParameters(color: self.color, progress: self.effectiveProgress)
}
override func didEnterHierarchy() {
super.didEnterHierarchy()
}
@objc override class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
let context = UIGraphicsGetCurrentContext()!
if !isRasterizing {
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fill(bounds)
}
guard let parameters = parameters as? ChecksNodeParameters else {
return
}
let scaleFactor: CGFloat = 1.0
context.translateBy(x: bounds.width / 2.0, y: bounds.height / 2.0)
context.scaleBy(x: scaleFactor, y: scaleFactor)
context.translateBy(x: -bounds.width / 2.0, y: -bounds.height / 2.0)
let progress = parameters.progress
context.setStrokeColor(parameters.color.cgColor)
context.setLineWidth(1.0 + UIScreenPixel)
context.setLineCap(.round)
context.setLineJoin(.round)
context.setMiterLimit(10.0)
context.saveGState()
var s1 = CGPoint(x: 9.0, y: 13.0)
var s2 = CGPoint(x: 5.0, y: 13.0)
let p1 = CGPoint(x: 3.5, y: 3.5)
let p2 = CGPoint(x: 7.5 - UIScreenPixel, y: -8.0)
let check1FirstSegment: CGFloat = max(0.0, min(1.0, progress * 3.0))
let check2FirstSegment: CGFloat = max(0.0, min(1.0, (progress - 1.0) * 3.0))
let firstProgress = max(0.0, min(1.0, progress))
let secondProgress = max(0.0, min(1.0, progress - 1.0))
let scale: CGFloat = 1.2
context.translateBy(x: 16.0, y: 13.0)
context.scaleBy(x: scale - abs((scale - 1.0) * (firstProgress - 0.5) / 0.5), y: scale - abs((scale - 1.0) * (firstProgress - 0.5) / 0.5))
s1 = s1.offsetBy(dx: -16.0, dy: -13.0)
if !check1FirstSegment.isZero {
if check1FirstSegment < 1.0 {
context.move(to: CGPoint(x: s1.x + p1.x * check1FirstSegment, y: s1.y + p1.y * check1FirstSegment))
context.addLine(to: s1)
} else {
let secondSegment = (min(1.0, progress) - 0.33) * 1.5
context.move(to: CGPoint(x: s1.x + p1.x + p2.x * secondSegment, y: s1.y + p1.y + p2.y * secondSegment))
context.addLine(to: CGPoint(x: s1.x + p1.x, y: s1.y + p1.y))
context.addLine(to: CGPoint(x: s1.x + p1.x * min(1.0, check2FirstSegment), y: s1.y + p1.y * min(1.0, check2FirstSegment)))
}
}
context.strokePath()
context.restoreGState()
context.translateBy(x: 12.0, y: 13.0)
context.scaleBy(x: scale - abs((scale - 1.0) * (secondProgress - 0.5) / 0.5), y: scale - abs((scale - 1.0) * (secondProgress - 0.5) / 0.5))
s2 = s2.offsetBy(dx: -12.0, dy: -13.0)
if !check2FirstSegment.isZero {
if check2FirstSegment < 1.0 {
context.move(to: CGPoint(x: s2.x + p1.x * check2FirstSegment, y: s2.y + p1.y * check2FirstSegment))
context.addLine(to: s2)
} else {
let secondSegment = (max(0.0, (progress - 1.0)) - 0.33) * 1.5
context.move(to: CGPoint(x: s2.x + p1.x + p2.x * secondSegment, y: s2.y + p1.y + p2.y * secondSegment))
context.addLine(to: CGPoint(x: s2.x + p1.x, y: s2.y + p1.y))
context.addLine(to: s2)
}
}
context.strokePath()
}
func updateState(_ state: Bool, animated: Bool) {
guard state != self.state else {
return
}
let previousState = self.state
self.state = state
if animated {
if previousState == nil && self.state == false {
self.animateProgress(from: 0.0, to: 1.0)
} else if previousState == false && self.state == true {
self.animateProgress(from: 1.0, to: 2.0)
}
} else {
if let state = self.state {
self.effectiveProgress = state ? 2.0 : 1.0
} else {
self.effectiveProgress = 0.0
}
}
}
}
class ChatStatusChecksTooltipContentNode: ASDisplayNode, TooltipControllerCustomContentNode {
private let deliveredChecksNode: ChecksNode
private let deliveredTextNode: ImmediateTextNode
private let readChecksNode: ChecksNode
private let readTextNode: ImmediateTextNode
init(presentationData: PresentationData) {
self.deliveredChecksNode = ChecksNode(color: .white)
self.deliveredTextNode = ImmediateTextNode()
self.readChecksNode = ChecksNode(color: .white)
self.readTextNode = ImmediateTextNode()
self.deliveredTextNode.attributedText = NSAttributedString(string: presentationData.strings.Conversation_ChecksTooltip_Delivered, font: Font.regular(14.0), textColor: UIColor.white)
self.readTextNode.attributedText = NSAttributedString(string: presentationData.strings.Conversation_ChecksTooltip_Read, font: Font.regular(14.0), textColor: UIColor.white)
super.init()
self.addSubnode(self.deliveredChecksNode)
self.addSubnode(self.deliveredTextNode)
self.addSubnode(self.readChecksNode)
self.addSubnode(self.readTextNode)
}
func animateIn() {
self.deliveredChecksNode.updateState(false, animated: true)
self.readChecksNode.updateState(false, animated: true)
Queue.mainQueue().after(0.25) {
self.deliveredChecksNode.layer.animateScale(from: 1.0, to: 1.12, duration: 0.25, delay: 0.0, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.deliveredChecksNode.layer.animateScale(from: 1.12, to: 1.0, duration: 0.25)
}
})
self.deliveredTextNode.layer.animateScale(from: 1.0, to: 1.12, duration: 0.25, delay: 0.0, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.deliveredTextNode.layer.animateScale(from: 1.12, to: 1.0, duration: 0.25)
}
})
Queue.mainQueue().after(0.5) {
self.readChecksNode.updateState(true, animated: true)
self.readChecksNode.layer.animateScale(from: 1.0, to: 1.12, duration: 0.25, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.readChecksNode.layer.animateScale(from: 1.12, to: 1.0, duration: 0.25)
}
})
self.readTextNode.layer.animateScale(from: 1.0, to: 1.12, duration: 0.25, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.readTextNode.layer.animateScale(from: 1.12, to: 1.0, duration: 0.25)
}
})
}
}
}
func updateLayout(size: CGSize) -> CGSize {
let deliveredSize = self.deliveredTextNode.updateLayout(size)
let readSize = self.readTextNode.updateLayout(size)
let checksInset: CGFloat = 8.0
let checksSize = CGSize(width: 24.0, height: 24.0)
self.deliveredChecksNode.frame = CGRect(origin: CGPoint(x: checksInset, y: 15.0), size: checksSize)
self.deliveredTextNode.frame = CGRect(origin: CGPoint(x: checksInset + checksSize.width + 5.0, y: 19.0), size: deliveredSize)
self.readChecksNode.frame = CGRect(origin: CGPoint(x: checksInset, y: 38.0), size: checksSize)
self.readTextNode.frame = CGRect(origin: CGPoint(x: checksInset + checksSize.width + 5.0, y: 43.0), size: readSize)
let contentWidth = max(deliveredSize.width, readSize.width) + checksInset + checksSize.width + 18.0
let contentHeight: CGFloat = 77.0
return CGSize(width: contentWidth, height: contentHeight)
}
}
@@ -0,0 +1,584 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import Postbox
import SwiftSignalKit
import TelegramNotices
import TelegramPresentationData
import ActivityIndicator
import ChatPresentationInterfaceState
import ChatInputPanelNode
import ComponentFlow
import MultilineTextComponent
import PlainButtonComponent
import ComponentDisplayAdapters
import BundleIconComponent
import AnimatedTextComponent
import GlassBackgroundComponent
private let labelFont = Font.regular(15.0)
final class ChatTagSearchInputPanelNode: ChatInputPanelNode {
private struct Params: Equatable {
var width: CGFloat
var leftInset: CGFloat
var rightInset: CGFloat
var bottomInset: CGFloat
var additionalSideInsets: UIEdgeInsets
var maxHeight: CGFloat
var maxOverlayHeight: CGFloat
var isSecondary: Bool
var interfaceState: ChatPresentationInterfaceState
var metrics: LayoutMetrics
var isMediaInputExpanded: Bool
init(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, additionalSideInsets: UIEdgeInsets, maxHeight: CGFloat, maxOverlayHeight: CGFloat, isSecondary: Bool, interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics, isMediaInputExpanded: Bool) {
self.width = width
self.leftInset = leftInset
self.rightInset = rightInset
self.bottomInset = bottomInset
self.additionalSideInsets = additionalSideInsets
self.maxHeight = maxHeight
self.maxOverlayHeight = maxOverlayHeight
self.isSecondary = isSecondary
self.interfaceState = interfaceState
self.metrics = metrics
self.isMediaInputExpanded = isMediaInputExpanded
}
}
private struct Layout {
var params: Params
var height: CGFloat
init(params: Params, height: CGFloat) {
self.params = params
self.height = height
}
}
private let leftControlsBackgroundView: GlassBackgroundView
private let rightControlsBackgroundView: GlassBackgroundView
private let calendarButton = ComponentView<Empty>()
private var membersButton: ComponentView<Empty>?
private var resultsText: ComponentView<Empty>?
private var listModeButton: ComponentView<Empty>?
private var isUpdating: Bool = false
private var alwaysShowTotalMessagesCount = false
private var currentLayout: Layout?
private var tagMessageCount: (tag: MemoryBuffer, count: Int?, disposable: Disposable?)?
private var totalMessageCount: Int?
private var totalMessageCountDisposable: Disposable?
public var externalSearchResultsCount: Int32? {
didSet {
if let params = self.currentLayout?.params {
let _ = self.update(params: params, transition: .spring(duration: 0.4))
}
}
}
override var interfaceInteraction: ChatPanelInterfaceInteraction? {
didSet {
}
}
init(theme: PresentationTheme, alwaysShowTotalMessagesCount: Bool) {
self.alwaysShowTotalMessagesCount = alwaysShowTotalMessagesCount
self.leftControlsBackgroundView = GlassBackgroundView()
self.rightControlsBackgroundView = GlassBackgroundView()
super.init()
self.view.addSubview(self.leftControlsBackgroundView)
self.view.addSubview(self.rightControlsBackgroundView)
}
deinit {
self.tagMessageCount?.disposable?.dispose()
self.totalMessageCountDisposable?.dispose()
}
override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, additionalSideInsets: UIEdgeInsets, maxHeight: CGFloat, maxOverlayHeight: CGFloat, isSecondary: Bool, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics, isMediaInputExpanded: Bool) -> CGFloat {
let params = Params(width: width, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, additionalSideInsets: additionalSideInsets, maxHeight: maxHeight, maxOverlayHeight: maxOverlayHeight, isSecondary: isSecondary, interfaceState: interfaceState, metrics: metrics, isMediaInputExpanded: isMediaInputExpanded)
if let currentLayout = self.currentLayout, currentLayout.params == params {
return currentLayout.height
}
let height = self.update(params: params, transition: ComponentTransition(transition))
self.currentLayout = Layout(params: params, height: height)
return height
}
func prepareSwitchToFilter(tag: MemoryBuffer, count: Int) {
self.tagMessageCount?.disposable?.dispose()
self.tagMessageCount = (tag, count, nil)
}
private func update(transition: ComponentTransition) {
if self.isUpdating {
return
}
if let params = self.currentLayout?.params {
let _ = self.update(params: params, transition: transition)
}
}
private func update(params: Params, transition: ComponentTransition) -> CGFloat {
self.isUpdating = true
defer {
self.isUpdating = false
}
if self.totalMessageCountDisposable == nil, let context = self.context, case let .peer(peerId) = params.interfaceState.chatLocation, peerId == context.account.peerId {
self.totalMessageCountDisposable = (context.engine.data.subscribe(
TelegramEngine.EngineData.Item.Messages.MessageCount(
peerId: peerId,
threadId: nil,
tag: []
)
)
|> distinctUntilChanged
|> deliverOnMainQueue).start(next: { [weak self] value in
guard let self else {
return
}
if self.totalMessageCount != value {
self.totalMessageCount = value
if !self.isUpdating {
self.update(transition: .easeInOut(duration: 0.25))
}
}
})
}
if let historyFilter = params.interfaceState.historyFilter, let reaction = ReactionsMessageAttribute.reactionFromMessageTag(tag: historyFilter.customTag) {
let tag = historyFilter.customTag
if let current = self.tagMessageCount, current.tag == tag {
} else {
self.tagMessageCount = (tag, nil, nil)
}
if self.tagMessageCount?.disposable == nil {
if let context = self.context {
self.tagMessageCount?.disposable = (context.engine.data.subscribe(
TelegramEngine.EngineData.Item.Messages.ReactionTagMessageCount(peerId: context.account.peerId, threadId: params.interfaceState.chatLocation.threadId, reaction: reaction)
)
|> deliverOnMainQueue).startStrict(next: { [weak self] count in
guard let self else {
return
}
if self.tagMessageCount?.tag == tag {
if self.tagMessageCount?.count != count {
self.tagMessageCount?.count = count
if !self.isUpdating {
self.update(transition: .easeInOut(duration: 0.25))
}
}
}
})
}
}
} else {
if let tagMessageCount = self.tagMessageCount {
self.tagMessageCount = nil
tagMessageCount.disposable?.dispose()
}
}
var canSearchMembers = false
if let search = params.interfaceState.search {
if case .everything = search.domain {
if let _ = params.interfaceState.renderedPeer?.peer as? TelegramGroup {
canSearchMembers = true
} else if let peer = params.interfaceState.renderedPeer?.peer as? TelegramChannel, case .group = peer.info, !peer.isMonoForum {
canSearchMembers = true
}
} else {
canSearchMembers = false
}
}
let displaySearchMembers = (params.interfaceState.search?.query.isEmpty ?? true) && canSearchMembers
var canChangeListMode = false
var resultsTextString: [AnimatedTextComponent.Item] = []
if let externalSearchResultsCount = self.externalSearchResultsCount {
let value = presentationStringsFormattedNumber(externalSearchResultsCount, params.interfaceState.dateTimeFormat.groupingSeparator)
let suffix = params.interfaceState.strings.Chat_BottomSearchPanel_StoryCount(externalSearchResultsCount)
resultsTextString = [AnimatedTextComponent.Item(
id: "stories",
isUnbreakable: true,
content: .text(params.interfaceState.strings.Chat_BottomSearchPanel_MessageCountFormat(value, suffix).string)
)]
} else if let results = params.interfaceState.search?.resultsState {
let displayTotalCount = results.completed ? results.messageIndices.count : Int(results.totalCount)
if let currentId = results.currentId, let index = results.messageIndices.firstIndex(where: { $0.id == currentId }) {
canChangeListMode = true
if self.alwaysShowTotalMessagesCount {
let value = presentationStringsFormattedNumber(Int32(displayTotalCount), params.interfaceState.dateTimeFormat.groupingSeparator)
let suffix = params.interfaceState.strings.Chat_BottomSearchPanel_MessageCount(Int32(displayTotalCount))
resultsTextString = [AnimatedTextComponent.Item(
id: "text",
isUnbreakable: true,
content: .text(params.interfaceState.strings.Chat_BottomSearchPanel_MessageCountFormat(value, suffix).string)
)]
} else if params.interfaceState.displayHistoryFilterAsList {
resultsTextString = AnimatedTextComponent.extractAnimatedTextString(string: params.interfaceState.strings.Chat_BottomSearchPanel_MessageCountFormat(
".",
"."
), id: "total_count", mapping: [
0: .number(displayTotalCount, minDigits: 1),
1: .text(params.interfaceState.strings.Chat_BottomSearchPanel_MessageCount(Int32(displayTotalCount)))
])
} else {
let adjustedIndex = results.messageIndices.count - 1 - index
resultsTextString = AnimatedTextComponent.extractAnimatedTextString(string: params.interfaceState.strings.Items_NOfM(
".",
"."
), id: "position", mapping: [
0: .number(adjustedIndex + 1, minDigits: 1),
1: .number(displayTotalCount, minDigits: 1)
])
}
} else {
canChangeListMode = false
resultsTextString.append(AnimatedTextComponent.Item(id: AnyHashable("search_no_results"), isUnbreakable: true, content: .text(params.interfaceState.strings.Conversation_SearchNoResults)))
}
} else if let count = self.tagMessageCount?.count ?? self.totalMessageCount {
canChangeListMode = count != 0
resultsTextString = AnimatedTextComponent.extractAnimatedTextString(string: params.interfaceState.strings.Chat_BottomSearchPanel_MessageCountFormat(
".",
"."
), id: "total_count", mapping: [
0: .number(count, minDigits: 1),
1: .text(params.interfaceState.strings.Chat_BottomSearchPanel_MessageCount(Int32(count)))
])
} else if let context = self.context, case .peer(context.account.peerId) = params.interfaceState.chatLocation {
canChangeListMode = true
}
if let channel = params.interfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum, params.interfaceState.chatLocation.threadId == nil, let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = params.interfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.sendSomething) {
canChangeListMode = false
}
let height: CGFloat
if case .regular = params.metrics.widthClass {
height = 40.0
} else {
height = 40.0
}
var modeButtonTitle: [AnimatedTextComponent.Item] = []
modeButtonTitle = AnimatedTextComponent.extractAnimatedTextString(string: params.interfaceState.strings.Chat_BottomSearchPanel_DisplayModeFormat("."), id: "mode", mapping: [
0: params.interfaceState.displayHistoryFilterAsList ? .text(params.interfaceState.strings.Chat_BottomSearchPanel_DisplayModeChat) : .text(params.interfaceState.strings.Chat_BottomSearchPanel_DisplayModeList)
])
let size = CGSize(width: params.width - params.additionalSideInsets.left * 2.0 - params.leftInset * 2.0, height: height)
var listModeButtonFrameValue: CGRect?
if canChangeListMode {
var listModeButtonTransition = transition
let listModeButton: ComponentView<Empty>
if let current = self.listModeButton {
listModeButton = current
} else {
listModeButtonTransition = listModeButtonTransition.withAnimation(.none)
listModeButton = ComponentView()
self.listModeButton = listModeButton
}
let buttonSize = listModeButton.update(
transition: listModeButtonTransition,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(AnimatedTextComponent(
font: Font.regular(15.0),
color: params.interfaceState.theme.chat.inputPanel.panelControlColor,
items: modeButtonTitle
)),
effectAlignment: .right,
minSize: CGSize(width: 1.0, height: 40.0),
contentInsets: UIEdgeInsets(top: 0.0, left: 4.0, bottom: 0.0, right: 4.0),
action: { [weak self] in
guard let self, let params = self.currentLayout?.params else {
return
}
self.interfaceInteraction?.updateDisplayHistoryFilterAsList(!params.interfaceState.displayHistoryFilterAsList)
},
animateScale: false,
animateContents: true
)),
environment: {},
containerSize: size
)
if let buttonView = listModeButton.view {
if buttonView.superview == nil {
buttonView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.5)
buttonView.alpha = 0.0
self.view.addSubview(buttonView)
}
let listModeFrame = CGRect(origin: CGPoint(x: params.width - params.rightInset - 20.0 - 8.0 - buttonSize.width, y: floor((size.height - buttonSize.height) * 0.5)), size: buttonSize)
listModeButtonFrameValue = listModeFrame
listModeButtonTransition.setPosition(view: buttonView, position: CGPoint(x: listModeFrame.minX + listModeFrame.width * buttonView.layer.anchorPoint.x, y: listModeFrame.minY + listModeFrame.height * buttonView.layer.anchorPoint.y))
listModeButtonTransition.setBounds(view: buttonView, bounds: CGRect(origin: CGPoint(), size: listModeFrame.size))
transition.setAlpha(view: buttonView, alpha: 1.0)
}
} else {
if let listModeButton = self.listModeButton {
self.listModeButton = nil
if let listModeButtonView = listModeButton.view {
transition.setAlpha(view: listModeButtonView, alpha: 0.0, completion: { [weak listModeButtonView] _ in
listModeButtonView?.removeFromSuperview()
})
}
}
}
var nextLeftX: CGFloat = 16.0 + 8.0
var calendarButtonFrameValue: CGRect?
var membersButtonFrameValue: CGRect?
var resultsTextFrameValue: CGRect?
if !self.alwaysShowTotalMessagesCount && self.externalSearchResultsCount == nil {
nextLeftX -= 4.0
let calendarButtonSize = self.calendarButton.update(
transition: .immediate,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(BundleIconComponent(
name: "Chat/Input/Search/Calendar",
tintColor: params.interfaceState.theme.chat.inputPanel.panelControlColor
)),
effectAlignment: .center,
minSize: CGSize(width: 40.0, height: 40.0),
action: { [weak self] in
guard let self else {
return
}
self.interfaceInteraction?.openCalendarSearch()
}
)),
environment: {},
containerSize: size
)
let calendarButtonFrame = CGRect(origin: CGPoint(x: nextLeftX, y: floor((size.height - calendarButtonSize.height) * 0.5)), size: calendarButtonSize)
calendarButtonFrameValue = calendarButtonFrame
if let calendarButtonView = self.calendarButton.view {
if calendarButtonView.superview == nil {
self.view.addSubview(calendarButtonView)
if !transition.animation.isImmediate {
calendarButtonView.alpha = 1.0
transition.animateAlpha(view: calendarButtonView, from: 0.0, to: 1.0)
transition.animateScale(view: calendarButtonView, from: 0.01, to: 1.0)
}
}
transition.setFrame(view: calendarButtonView, frame: calendarButtonFrame)
}
nextLeftX += calendarButtonSize.width + 0.0
} else if let calendarButtonView = self.calendarButton.view {
if transition.animation.isImmediate {
calendarButtonView.removeFromSuperview()
} else {
transition.setAlpha(view: calendarButtonView, alpha: 0.0, completion: { finished in
if finished {
calendarButtonView.removeFromSuperview()
}
calendarButtonView.alpha = 1.0
})
transition.animateScale(view: calendarButtonView, from: 1.0, to: 0.01)
}
}
if displaySearchMembers {
let membersButton: ComponentView<Empty>
if let current = self.membersButton {
membersButton = current
} else {
membersButton = ComponentView()
self.membersButton = membersButton
}
let buttonSize = membersButton.update(
transition: .immediate,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(BundleIconComponent(
name: "Chat/Input/Search/Members",
tintColor: params.interfaceState.theme.chat.inputPanel.panelControlColor
)),
effectAlignment: .center,
minSize: CGSize(width: 40.0, height: 40.0),
action: { [weak self] in
guard let self else {
return
}
self.interfaceInteraction?.toggleMembersSearch(true)
}
)),
environment: {},
containerSize: size
)
if let buttonView = membersButton.view {
var membersButtonTransition = transition
var animateIn = false
if buttonView.superview == nil {
membersButtonTransition = membersButtonTransition.withAnimation(.none)
buttonView.alpha = 0.0
animateIn = true
self.view.addSubview(buttonView)
}
let membersButtonFrame = CGRect(origin: CGPoint(x: nextLeftX, y: floor((size.height - buttonSize.height) * 0.5)), size: buttonSize)
membersButtonFrameValue = membersButtonFrame
membersButtonTransition.setFrame(view: buttonView, frame: membersButtonFrame)
transition.setAlpha(view: buttonView, alpha: 1.0)
if animateIn {
transition.animateScale(view: buttonView, from: 0.001, to: 1.0)
}
}
nextLeftX += buttonSize.width + 0.0
} else {
if let membersButton = self.membersButton {
self.membersButton = nil
if let membersButtonView = membersButton.view {
transition.setAlpha(view: membersButtonView, alpha: 0.0, completion: { [weak membersButtonView] _ in
membersButtonView?.removeFromSuperview()
})
transition.setScale(view: membersButtonView, scale: 0.001)
}
}
}
if !resultsTextString.isEmpty {
var resultsTextTransition = transition
let resultsText: ComponentView<Empty>
if let current = self.resultsText {
resultsText = current
} else {
resultsTextTransition = resultsTextTransition.withAnimation(.none)
resultsText = ComponentView()
self.resultsText = resultsText
}
if self.alwaysShowTotalMessagesCount {
resultsTextTransition = .immediate
}
let resultsTextSize = resultsText.update(
transition: resultsTextTransition,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(AnimatedTextComponent(
font: Font.regular(15.0),
color: params.interfaceState.theme.rootController.navigationBar.secondaryTextColor,
items: resultsTextString
)),
effectAlignment: .center,
action: { [weak self] in
guard let self, let params = self.currentLayout?.params else {
return
}
self.interfaceInteraction?.updateDisplayHistoryFilterAsList(!params.interfaceState.displayHistoryFilterAsList)
},
isEnabled: params.interfaceState.displayHistoryFilterAsList && canChangeListMode
)),
environment: {},
containerSize: CGSize(width: 200.0, height: 100.0)
)
var resultsTextFrame = CGRect(origin: CGPoint(x: nextLeftX - 3.0, y: floor((size.height - resultsTextSize.height) * 0.5)), size: resultsTextSize)
if !displaySearchMembers && !(!self.alwaysShowTotalMessagesCount && self.externalSearchResultsCount == nil) {
resultsTextFrame.origin.x += 8.0
}
resultsTextFrameValue = resultsTextFrame
if let resultsTextView = resultsText.view {
if resultsTextView.superview == nil {
resultsTextView.alpha = 0.0
self.view.addSubview(resultsTextView)
}
resultsTextTransition.setFrame(view: resultsTextView, frame: resultsTextFrame)
transition.setAlpha(view: resultsTextView, alpha: 1.0)
}
nextLeftX += -3.0 + resultsTextSize.width
} else {
if let resultsText = self.resultsText {
self.resultsText = nil
if let resultsTextView = resultsText.view {
transition.setAlpha(view: resultsTextView, alpha: 0.0, completion: { [weak resultsTextView] _ in
resultsTextView?.removeFromSuperview()
})
}
}
}
let adjustedResultsTextFrameValue = resultsTextFrameValue.flatMap { rect in
var rect = rect
rect.size.width += 8.0
return rect
}
let leftControlsFrames: [CGRect?] = [
calendarButtonFrameValue,
membersButtonFrameValue,
adjustedResultsTextFrameValue
]
var leftControlsRect = CGRect()
for rect in leftControlsFrames {
guard let rect else {
continue
}
if leftControlsRect.isEmpty {
leftControlsRect = rect
} else {
leftControlsRect = leftControlsRect.union(rect)
}
}
var leftControlsBackgroundFrame = CGRect(origin: CGPoint(x: 20.0, y: floor((height - 40.0) * 0.5)), size: CGSize(width: 0.0, height: 40.0))
leftControlsBackgroundFrame.size.width = max(40.0, leftControlsRect.maxX - leftControlsBackgroundFrame.minX)
transition.setFrame(view: self.leftControlsBackgroundView, frame: leftControlsBackgroundFrame)
self.leftControlsBackgroundView.update(size: leftControlsBackgroundFrame.size, cornerRadius: leftControlsBackgroundFrame.height * 0.5, isDark: params.interfaceState.theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: params.interfaceState.theme.chat.inputPanel.inputBackgroundColor.withMultipliedAlpha(0.7)), transition: transition)
transition.setAlpha(view: self.leftControlsBackgroundView, alpha: leftControlsRect.isEmpty ? 0.0 : 1.0)
let rightControlsFrames: [CGRect?] = [
listModeButtonFrameValue
]
var rightControlsRect = CGRect()
for rect in rightControlsFrames {
guard let rect else {
continue
}
if rightControlsRect.isEmpty {
rightControlsRect = rect
} else {
rightControlsRect = rightControlsRect.union(rect)
}
}
var rightControlsBackgroundFrame = CGRect(origin: CGPoint(x: params.width - params.rightInset - 20.0, y: floor((height - 40.0) * 0.5)), size: CGSize(width: 0.0, height: 40.0))
rightControlsBackgroundFrame.size.width = max(40.0, rightControlsRect.maxX - rightControlsRect.minX + 8.0 * 2.0)
rightControlsBackgroundFrame.origin.x -= rightControlsBackgroundFrame.width
transition.setFrame(view: self.rightControlsBackgroundView, frame: rightControlsBackgroundFrame)
self.rightControlsBackgroundView.update(size: rightControlsBackgroundFrame.size, cornerRadius: rightControlsBackgroundFrame.height * 0.5, isDark: params.interfaceState.theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: params.interfaceState.theme.chat.inputPanel.inputBackgroundColor.withMultipliedAlpha(0.7)), transition: transition)
transition.setAlpha(view: self.rightControlsBackgroundView, alpha: rightControlsRect.isEmpty ? 0.0 : 1.0)
return height
}
override func minimalHeight(interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics) -> CGFloat {
return defaultHeight(metrics: metrics)
}
}
@@ -0,0 +1,16 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import ChatPresentationInterfaceState
import AccountContext
class ChatTitleAccessoryPanelNode: ASDisplayNode {
typealias LayoutResult = ChatControllerCustomNavigationPanelNode.LayoutResult
var interfaceInteraction: ChatPanelInterfaceInteraction?
func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> LayoutResult {
preconditionFailure()
}
}
@@ -0,0 +1,64 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import ChatPresentationInterfaceState
final class ChatToastAlertPanelNode: ChatTitleAccessoryPanelNode {
private let separatorNode: ASDisplayNode
private let titleNode: ImmediateTextNode
private let activateAreaNode: AccessibilityAreaNode
private var textColor: UIColor = .black {
didSet {
if !self.textColor.isEqual(oldValue) {
self.titleNode.attributedText = NSAttributedString(string: self.text, font: Font.regular(14.0), textColor: self.textColor)
}
}
}
var text: String = "" {
didSet {
if self.text != oldValue {
self.titleNode.attributedText = NSAttributedString(string: text, font: Font.regular(14.0), textColor: self.textColor)
self.setNeedsLayout()
}
}
}
override init() {
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.titleNode = ImmediateTextNode()
self.titleNode.attributedText = NSAttributedString(string: "", font: Font.regular(14.0), textColor: UIColor.black)
self.titleNode.maximumNumberOfLines = 1
self.titleNode.insets = UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0)
self.activateAreaNode = AccessibilityAreaNode()
self.activateAreaNode.accessibilityTraits = [.staticText]
super.init()
self.addSubnode(self.titleNode)
self.addSubnode(self.separatorNode)
}
override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> LayoutResult {
let panelHeight: CGFloat = 40.0
self.textColor = interfaceState.theme.rootController.navigationBar.primaryTextColor
self.separatorNode.backgroundColor = interfaceState.theme.chat.historyNavigation.strokeColor
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)))
let titleSize = self.titleNode.updateLayout(CGSize(width: width - leftInset - rightInset - 20.0, height: 100.0))
self.titleNode.frame = CGRect(origin: CGPoint(x: floor((width - titleSize.width) / 2.0), y: floor((panelHeight - titleSize.height) / 2.0)), size: titleSize)
self.activateAreaNode.frame = CGRect(origin: .zero, size: CGSize(width: width, height: panelHeight))
self.activateAreaNode.accessibilityLabel = self.titleNode.attributedText?.string ?? ""
return LayoutResult(backgroundHeight: panelHeight, insetHeight: panelHeight, hitTestSlop: 0.0)
}
}

Some files were not shown because too many files have changed in this diff Show More