GLEGram 12.5 — Initial public release

Based on Swiftgram 12.5 (Telegram iOS 12.5).
All GLEGram features ported and organized in GLEGram/ folder.

Features: Ghost Mode, Saved Deleted Messages, Content Protection Bypass,
Font Replacement, Fake Profile, Chat Export, Plugin System, and more.

See CHANGELOG_12.5.md for full details.
This commit is contained in:
Leeksov
2026-04-06 09:48:12 +03:00
commit 4647310322
39685 changed files with 11052678 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
sgdeps = [
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//submodules/TextFormat:TextFormat"
]
swift_library(
name = "TranslateUI",
module_name = "TranslateUI",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = sgdeps + [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
"//submodules/Display:Display",
"//submodules/Postbox:Postbox",
"//submodules/TelegramCore:TelegramCore",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/AccountContext:AccountContext",
"//submodules/AlertUI:AlertUI",
"//submodules/PresentationDataUtils:PresentationDataUtils",
"//submodules/Speak:Speak",
"//submodules/ManagedAnimationNode:ManagedAnimationNode",
"//submodules/TelegramStringFormatting:TelegramStringFormatting",
"//submodules/ItemListUI:ItemListUI",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/ComponentFlow:ComponentFlow",
"//submodules/Components/ViewControllerComponent:ViewControllerComponent",
"//submodules/Components/MultilineTextComponent:MultilineTextComponent",
"//submodules/Components/MultilineTextWithEntitiesComponent:MultilineTextWithEntitiesComponent",
"//submodules/Components/BundleIconComponent:BundleIconComponent",
"//submodules/UndoUI:UndoUI",
"//submodules/ActivityIndicator:ActivityIndicator",
"//submodules/ShimmerEffect:ShimmerEffect",
"//submodules/Components/ResizableSheetComponent",
"//submodules/TelegramUI/Components/GlassBarButtonComponent",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,399 @@
import TextFormat
import Foundation
import NaturalLanguage
import SwiftSignalKit
import TelegramCore
import AccountContext
import TelegramUIPreferences
public struct ChatTranslationState: Codable {
enum CodingKeys: String, CodingKey {
case baseLang
case fromLang
case timestamp
case toLang
case isEnabled
}
public let baseLang: String
public let fromLang: String
public let timestamp: Int32?
public let toLang: String?
public let isEnabled: Bool
public init(
baseLang: String,
fromLang: String,
timestamp: Int32?,
toLang: String?,
isEnabled: Bool
) {
self.baseLang = baseLang
self.fromLang = fromLang
self.timestamp = timestamp
self.toLang = toLang
self.isEnabled = isEnabled
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.baseLang = try container.decode(String.self, forKey: .baseLang)
self.fromLang = try container.decode(String.self, forKey: .fromLang)
self.timestamp = try container.decodeIfPresent(Int32.self, forKey: .timestamp)
self.toLang = try container.decodeIfPresent(String.self, forKey: .toLang)
self.isEnabled = try container.decode(Bool.self, forKey: .isEnabled)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.baseLang, forKey: .baseLang)
try container.encode(self.fromLang, forKey: .fromLang)
try container.encodeIfPresent(self.timestamp, forKey: .timestamp)
try container.encodeIfPresent(self.toLang, forKey: .toLang)
try container.encode(self.isEnabled, forKey: .isEnabled)
}
public func withFromLang(_ fromLang: String) -> ChatTranslationState {
return ChatTranslationState(
baseLang: self.baseLang,
fromLang: fromLang,
timestamp: self.timestamp,
toLang: self.toLang,
isEnabled: self.isEnabled
)
}
public func withToLang(_ toLang: String?) -> ChatTranslationState {
return ChatTranslationState(
baseLang: self.baseLang,
fromLang: self.fromLang,
timestamp: self.timestamp,
toLang: toLang,
isEnabled: self.isEnabled
)
}
public func withIsEnabled(_ isEnabled: Bool) -> ChatTranslationState {
return ChatTranslationState(
baseLang: self.baseLang,
fromLang: self.fromLang,
timestamp: self.timestamp,
toLang: self.toLang,
isEnabled: isEnabled
)
}
}
private func cachedChatTranslationState(engine: TelegramEngine, peerId: EnginePeer.Id, threadId: Int64?) -> Signal<ChatTranslationState?, NoError> {
let key: EngineDataBuffer
if let threadId {
key = EngineDataBuffer(length: 16)
key.setInt64(0, value: peerId.id._internalGetInt64Value())
key.setInt64(8, value: threadId)
} else {
key = EngineDataBuffer(length: 8)
key.setInt64(0, value: peerId.id._internalGetInt64Value())
}
return engine.data.subscribe(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.translationState, id: key))
|> map { entry -> ChatTranslationState? in
return entry?.get(ChatTranslationState.self)
}
}
private func updateChatTranslationState(engine: TelegramEngine, peerId: EnginePeer.Id, threadId: Int64?, state: ChatTranslationState?) -> Signal<Never, NoError> {
let key: EngineDataBuffer
if let threadId {
key = EngineDataBuffer(length: 16)
key.setInt64(0, value: peerId.id._internalGetInt64Value())
key.setInt64(8, value: threadId)
} else {
key = EngineDataBuffer(length: 8)
key.setInt64(0, value: peerId.id._internalGetInt64Value())
}
if let state {
return engine.itemCache.put(collectionId: ApplicationSpecificItemCacheCollectionId.translationState, id: key, item: state)
} else {
return engine.itemCache.remove(collectionId: ApplicationSpecificItemCacheCollectionId.translationState, id: key)
}
}
public func updateChatTranslationStateInteractively(engine: TelegramEngine, peerId: EnginePeer.Id, threadId: Int64?, _ f: @escaping (ChatTranslationState?) -> ChatTranslationState?) -> Signal<Never, NoError> {
let key: EngineDataBuffer
if let threadId {
key = EngineDataBuffer(length: 16)
key.setInt64(0, value: peerId.id._internalGetInt64Value())
key.setInt64(8, value: threadId)
} else {
key = EngineDataBuffer(length: 8)
key.setInt64(0, value: peerId.id._internalGetInt64Value())
}
return engine.data.get(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.translationState, id: key))
|> map { entry -> ChatTranslationState? in
return entry?.get(ChatTranslationState.self)
}
|> mapToSignal { current -> Signal<Never, NoError> in
if let current {
return updateChatTranslationState(engine: engine, peerId: peerId, threadId: threadId, state: f(current))
} else {
return .never()
}
}
}
@available(iOS 12.0, *)
private let languageRecognizer = NLLanguageRecognizer()
public func translateMessageIds(context: AccountContext, messageIds: [EngineMessage.Id], fromLang: String?, toLang: String, viaText: Bool = false, forQuickTranslate: Bool = false) -> Signal<Never, NoError> {
return context.account.postbox.transaction { transaction -> Signal<Never, NoError> in
var messageDictToTranslate: [EngineMessage.Id: String] = [:]
var messageIdsToTranslate: [EngineMessage.Id] = []
var messageIdsSet = Set<EngineMessage.Id>()
for messageId in messageIds {
if let message = transaction.getMessage(messageId) {
if let replyAttribute = message.attributes.first(where: { $0 is ReplyMessageAttribute }) as? ReplyMessageAttribute, let replyMessage = message.associatedMessages[replyAttribute.messageId] {
if !replyMessage.text.isEmpty {
if let translation = replyMessage.attributes.first(where: { $0 is TranslationMessageAttribute }) as? TranslationMessageAttribute, translation.toLang == toLang {
} else {
if !messageIdsSet.contains(replyMessage.id) {
messageIdsToTranslate.append(replyMessage.id)
messageIdsSet.insert(replyMessage.id)
messageDictToTranslate[replyMessage.id] = replyMessage.text
}
}
}
}
// MARK: Swiftgram
guard forQuickTranslate || message.author?.id != context.account.peerId else {
continue
}
if let translation = message.attributes.first(where: { $0 is TranslationMessageAttribute }) as? TranslationMessageAttribute, translation.toLang == toLang {
continue
}
if !message.text.isEmpty {
if !messageIdsSet.contains(messageId) {
messageIdsToTranslate.append(messageId)
messageIdsSet.insert(messageId)
messageDictToTranslate[messageId] = message.text
}
// TODO(swiftgram): Translate polls
} else if let _ = message.media.first(where: { $0 is TelegramMediaPoll }), !viaText {
if !messageIdsSet.contains(messageId) {
messageIdsToTranslate.append(messageId)
messageIdsSet.insert(messageId)
}
} else if let audioTranscription = message.attributes.first(where: { $0 is AudioTranscriptionMessageAttribute }) as? AudioTranscriptionMessageAttribute, !audioTranscription.text.isEmpty && !audioTranscription.isPending {
if !messageIdsSet.contains(messageId) {
messageIdsToTranslate.append(messageId)
messageIdsSet.insert(messageId)
}
}
} else {
if !messageIdsSet.contains(messageId) {
messageIdsToTranslate.append(messageId)
messageIdsSet.insert(messageId)
}
}
}
let translationConfiguration = TranslationConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
var enableLocalIfPossible = false
switch translationConfiguration.auto {
case .system:
if #available(iOS 18.0, *) {
enableLocalIfPossible = true
}
default:
break
}
if viaText {
return context.engine.messages.translateMessagesViaText(messagesDict: messageDictToTranslate, fromLang: fromLang, toLang: toLang, generateEntitiesFunction: { text in
generateTextEntities(text, enabledTypes: .all)
}, enableLocalIfPossible: enableLocalIfPossible) //context.sharedContext.immediateExperimentalUISettings.enableLocalTranslation)
|> `catch` { _ -> Signal<Never, NoError> in
return .complete()
}
} else {
if forQuickTranslate && messageIdsToTranslate.isEmpty { return .complete() } // Otherwise Telegram's API will return .never()
return context.engine.messages.translateMessages(messageIds: messageIdsToTranslate, fromLang: fromLang, toLang: toLang, enableLocalIfPossible: enableLocalIfPossible)
|> `catch` { _ -> Signal<Never, NoError> in
return translateMessageIds(context: context, messageIds: messageIdsToTranslate, fromLang: fromLang, toLang: toLang, viaText: true, forQuickTranslate: forQuickTranslate)
}
}
} |> switchToLatest
}
public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64?, forcePredict: Bool = false) -> Signal<ChatTranslationState?, NoError> {
if peerId.id == EnginePeer.Id.Id._internalFromInt64Value(777000) {
return .single(nil)
}
guard canTranslateChats(context: context) else {
return .single(nil)
}
let loggingEnabled = context.sharedContext.immediateExperimentalUISettings.logLanguageRecognition
if #available(iOS 12.0, *) {
var baseLang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
let rawSuffix = "-raw"
if baseLang.hasSuffix(rawSuffix) {
baseLang = String(baseLang.dropLast(rawSuffix.count))
}
return combineLatest(
context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.translationSettings])
|> map { sharedData -> TranslationSettings in
return sharedData.entries[ApplicationSpecificSharedDataKeys.translationSettings]?.get(TranslationSettings.self) ?? TranslationSettings.defaultSettings
},
context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.AutoTranslateEnabled(id: peerId))
)
|> mapToSignal { settings, autoTranslateEnabled in
if !settings.translateChats && !autoTranslateEnabled && !forcePredict {
return .single(nil)
}
var dontTranslateLanguages = Set<String>()
if let ignoredLanguages = settings.ignoredLanguages {
dontTranslateLanguages = Set(ignoredLanguages)
} else {
dontTranslateLanguages.insert(baseLang)
for language in systemLanguageCodes() {
dontTranslateLanguages.insert(language)
}
}
return cachedChatTranslationState(engine: context.engine, peerId: peerId, threadId: threadId)
|> mapToSignal { cached in
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
if let cached, let timestamp = cached.timestamp, cached.baseLang == baseLang && currentTime - timestamp < 60 * 60 {
if !dontTranslateLanguages.contains(cached.fromLang) || forcePredict {
return .single(cached)
} else {
return .single(nil)
}
} else {
return .single(nil)
|> then(
context.account.viewTracker.aroundMessageHistoryViewForLocation(.peer(peerId: peerId, threadId: threadId), index: .upperBound, anchorIndex: .upperBound, count: 32, fixedCombinedReadStates: nil)
|> filter { messageHistoryView -> Bool in
return messageHistoryView.0.entries.count > 1
}
|> take(1)
|> map { messageHistoryView, _, _ -> ChatTranslationState? in
let messages = messageHistoryView.entries.map(\.message)
if loggingEnabled {
Logger.shared.log("ChatTranslation", "Start language recognizing for \(peerId)")
}
var fromLangs: [String: Int] = [:]
var count = 0
for message in messages {
if message.effectivelyIncoming(context.account.peerId), message.text.count >= 10 {
if let summaryAttribute = message.attributes.first(where: { $0 is SummarizationMessageAttribute }) as? SummarizationMessageAttribute, !summaryAttribute.fromLang.isEmpty {
let fromLang = normalizeTranslationLanguage(summaryAttribute.fromLang)
if supportedTranslationLanguages.contains(fromLang) {
fromLangs[fromLang] = (fromLangs[fromLang] ?? 0) + message.text.count
count += 1
}
} else {
var text = String(message.text.prefix(256))
if var entities = message.textEntitiesAttribute?.entities.filter({ entity in
switch entity.type {
case .Pre, .Code, .Url, .Email, .Mention, .Hashtag, .BotCommand:
return true
default:
return false
}
}) {
entities = entities.sorted(by: { $0.range.lowerBound > $1.range.lowerBound })
var ranges: [Range<String.Index>] = []
for entity in entities {
if entity.range.lowerBound > text.count || entity.range.upperBound > text.count {
continue
}
ranges.append(text.index(text.startIndex, offsetBy: entity.range.lowerBound) ..< text.index(text.startIndex, offsetBy: entity.range.upperBound))
}
for range in ranges {
if range.upperBound < text.endIndex {
text.removeSubrange(range)
}
}
}
if message.text.count < 10 {
continue
}
languageRecognizer.processString(text)
let hypotheses = languageRecognizer.languageHypotheses(withMaximum: 4)
languageRecognizer.reset()
let filteredLanguages = hypotheses.filter { supportedTranslationLanguages.contains(normalizeTranslationLanguage($0.key.rawValue)) }.sorted(by: { $0.value > $1.value })
if let language = filteredLanguages.first {
let fromLang = normalizeTranslationLanguage(language.key.rawValue)
if loggingEnabled && !["en", "ru"].contains(fromLang) && !dontTranslateLanguages.contains(fromLang) {
Logger.shared.log("ChatTranslation", "\(text)")
Logger.shared.log("ChatTranslation", "Recognized as: \(fromLang), other hypotheses: \(hypotheses.map { $0.key.rawValue }.joined(separator: ",")) ")
}
fromLangs[fromLang] = (fromLangs[fromLang] ?? 0) + message.text.count
count += 1
}
}
}
if count >= 16 {
break
}
}
var mostFrequent: (String, Int)?
for (lang, count) in fromLangs {
if let current = mostFrequent {
if count > current.1 {
mostFrequent = (lang, count)
}
} else {
mostFrequent = (lang, count)
}
}
let fromLang = mostFrequent?.0 ?? ""
if loggingEnabled {
Logger.shared.log("ChatTranslation", "Ended with: \(fromLang)")
}
let isEnabled: Bool
if let currentIsEnabled = cached?.isEnabled {
isEnabled = currentIsEnabled
} else if autoTranslateEnabled {
isEnabled = true
} else {
isEnabled = false
}
let state = ChatTranslationState(
baseLang: baseLang,
fromLang: fromLang,
timestamp: currentTime,
toLang: cached?.toLang,
isEnabled: isEnabled
)
let _ = updateChatTranslationState(engine: context.engine, peerId: peerId, threadId: threadId, state: state).start()
if !dontTranslateLanguages.contains(fromLang) || forcePredict {
return state
} else {
return nil
}
}
)
}
}
}
} else {
return .single(nil)
}
}
@@ -0,0 +1,198 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import ItemListUI
import PresentationDataUtils
import TelegramStringFormatting
import AccountContext
private final class LanguageSelectionControllerArguments {
let context: AccountContext
let updateLanguageSelected: (String) -> Void
init(context: AccountContext, updateLanguageSelected: @escaping (String) -> Void) {
self.context = context
self.updateLanguageSelected = updateLanguageSelected
}
}
private enum LanguageSelectionControllerSection: Int32 {
case languages
}
private enum LanguageSelectionControllerEntry: ItemListNodeEntry {
case language(Int32, PresentationTheme, String, String, Bool, String)
var section: ItemListSectionId {
switch self {
case .language:
return LanguageSelectionControllerSection.languages.rawValue
}
}
var stableId: Int32 {
switch self {
case let .language(index, _, _, _, _, _):
return index
}
}
static func ==(lhs: LanguageSelectionControllerEntry, rhs: LanguageSelectionControllerEntry) -> Bool {
switch lhs {
case let .language(lhsIndex, lhsTheme, lhsTitle, lhsSubtitle, lhsValue, lhsCode):
if case let .language(rhsIndex, rhsTheme, rhsTitle, rhsSubtitle, rhsValue, rhsCode) = rhs, lhsIndex == rhsIndex, lhsTheme === rhsTheme, lhsTitle == rhsTitle, lhsSubtitle == rhsSubtitle, lhsValue == rhsValue, lhsCode == rhsCode {
return true
} else {
return false
}
}
}
static func <(lhs: LanguageSelectionControllerEntry, rhs: LanguageSelectionControllerEntry) -> Bool {
return lhs.stableId < rhs.stableId
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let arguments = arguments as! LanguageSelectionControllerArguments
switch self {
case let .language(_, _, title, subtitle, value, code):
return LocalizationListItem(presentationData: presentationData, id: code, title: title, subtitle: subtitle, checked: value, activity: false, loading: false, editing: LocalizationListItemEditing(editable: false, editing: false, revealed: false, reorderable: false), sectionId: self.section, alwaysPlain: false, action: {
arguments.updateLanguageSelected(code)
}, setItemWithRevealedOptions: { _, _ in }, removeItem: { _ in })
}
}
}
private func languageSelectionControllerEntries(theme: PresentationTheme, strings: PresentationStrings, selectedLanguage: String, languages: [(String, String, String)]) -> [LanguageSelectionControllerEntry] {
var entries: [LanguageSelectionControllerEntry] = []
var index: Int32 = 0
for (code, title, subtitle) in languages {
entries.append(.language(index, theme, title, subtitle, code == selectedLanguage, code))
index += 1
}
return entries
}
private struct LanguageSelectionControllerState: Equatable {
enum Section {
case original
case translation
}
var section: Section
var fromLanguage: String
var toLanguage: String
}
public func languageSelectionController(translateOutgoingMessage: Bool = false, context: AccountContext, forceTheme: PresentationTheme? = nil, fromLanguage: String, toLanguage: String, completion: @escaping (String, String) -> Void) -> ViewController {
let statePromise = ValuePromise(LanguageSelectionControllerState(section: .translation, fromLanguage: fromLanguage, toLanguage: toLanguage), ignoreRepeated: true)
let stateValue = Atomic(value: LanguageSelectionControllerState(section: .translation, fromLanguage: fromLanguage, toLanguage: toLanguage))
let updateState: ((LanguageSelectionControllerState) -> LanguageSelectionControllerState) -> Void = { f in
statePromise.set(stateValue.modify { f($0) })
}
let actionsDisposable = DisposableSet()
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let interfaceLanguageCode = presentationData.strings.baseLanguageCode
var dismissImpl: (() -> Void)?
let arguments = LanguageSelectionControllerArguments(context: context, updateLanguageSelected: { code in
updateState { current in
var updated = current
switch updated.section {
case .original:
updated.fromLanguage = code
case .translation:
updated.toLanguage = code
}
if translateOutgoingMessage { completion(updated.fromLanguage, updated.toLanguage); dismissImpl?() }
return updated
}
})
let enLocale = Locale(identifier: "en")
var languages: [(String, String, String)] = []
var addedLanguages = Set<String>()
for code in popularTranslationLanguages {
if let title = enLocale.localizedString(forLanguageCode: code) {
let languageLocale = Locale(identifier: code)
let subtitle = languageLocale.localizedString(forLanguageCode: code) ?? title
let value = (code, title.capitalized, subtitle.capitalized)
if code == interfaceLanguageCode {
languages.insert(value, at: 0)
} else {
languages.append(value)
}
addedLanguages.insert(code)
}
}
for code in supportedTranslationLanguages {
if !addedLanguages.contains(code), let title = enLocale.localizedString(forLanguageCode: code) {
let languageLocale = Locale(identifier: code)
let subtitle = languageLocale.localizedString(forLanguageCode: code) ?? title
let value = (code, title.capitalized, subtitle.capitalized)
if code == interfaceLanguageCode {
languages.insert(value, at: 0)
} else {
languages.append(value)
}
}
}
let signal = combineLatest(queue: Queue.mainQueue(), context.sharedContext.presentationData, statePromise.get())
|> map { presentationData, state -> (ItemListControllerState, (ItemListNodeState, Any)) in
var presentationData = presentationData
if let forceTheme {
presentationData = presentationData.withUpdated(theme: forceTheme)
}
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: translateOutgoingMessage ? .sectionControl([presentationData.strings.Translate_Languages_Translation], 0) : .sectionControl([presentationData.strings.Translate_Languages_Original, presentationData.strings.Translate_Languages_Translation], 1), leftNavigationButton: ItemListNavigationButton(content: .none, style: .regular, enabled: false, action: {}), rightNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Done), style: .bold, enabled: true, action: {
completion(state.fromLanguage, state.toLanguage)
dismissImpl?()
}), backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back))
let selectedLanguage: String
switch state.section {
case.original:
selectedLanguage = state.fromLanguage
case .translation:
selectedLanguage = state.toLanguage
}
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: languageSelectionControllerEntries(theme: presentationData.theme, strings: presentationData.strings, selectedLanguage: selectedLanguage, languages: languages), style: .blocks, animateChanges: false)
return (controllerState, (listState, arguments))
}
|> afterDisposed {
actionsDisposable.dispose()
}
let controller = ItemListController(context: context, state: signal)
controller.titleControlValueChanged = { value in
updateState { current in
var updated = current
if value == 0 {
updated.section = .original
} else {
updated.section = .translation
}
return updated
}
}
controller.alwaysSynchronous = true
controller.navigationPresentation = .modal
dismissImpl = { [weak controller] in
controller?.dismiss(animated: true, completion: nil)
}
return controller
}
@@ -0,0 +1,526 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import ActivityIndicator
import ShimmerEffect
public struct LocalizationListItemEditing: Equatable {
let editable: Bool
let editing: Bool
let revealed: Bool
let reorderable: Bool
public init(editable: Bool, editing: Bool, revealed: Bool, reorderable: Bool) {
self.editable = editable
self.editing = editing
self.revealed = revealed
self.reorderable = reorderable
}
}
public class LocalizationListItem: ListViewItem, ItemListItem {
let presentationData: ItemListPresentationData
let systemStyle: ItemListSystemStyle
let id: String
let title: String
let subtitle: String
let checked: Bool
let activity: Bool
let loading: Bool
let editing: LocalizationListItemEditing
let enabled: Bool
public let sectionId: ItemListSectionId
let alwaysPlain: Bool
let action: () -> Void
let setItemWithRevealedOptions: ((String?, String?) -> Void)?
let removeItem: ((String) -> Void)?
public init(presentationData: ItemListPresentationData, systemStyle: ItemListSystemStyle = .legacy, id: String, title: String, subtitle: String, checked: Bool, activity: Bool, loading: Bool, editing: LocalizationListItemEditing, enabled: Bool = true, sectionId: ItemListSectionId, alwaysPlain: Bool, action: @escaping () -> Void, setItemWithRevealedOptions: ((String?, String?) -> Void)?, removeItem: ((String) -> Void)?) {
self.presentationData = presentationData
self.systemStyle = systemStyle
self.id = id
self.title = title
self.subtitle = subtitle
self.checked = checked
self.activity = activity
self.loading = loading
self.editing = editing
self.enabled = enabled
self.sectionId = sectionId
self.alwaysPlain = alwaysPlain
self.action = action
self.setItemWithRevealedOptions = setItemWithRevealedOptions
self.removeItem = removeItem
}
public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = LocalizationListItemNode()
var neighbors = itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)
if previousItem == nil && self.alwaysPlain {
neighbors.top = .sameSection(alwaysPlain: false)
}
let (layout, apply) = node.asyncLayout()(self, params, neighbors)
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply(false) })
})
}
}
}
public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? LocalizationListItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
var neighbors = itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)
if previousItem == nil && self.alwaysPlain {
neighbors.top = .sameSection(alwaysPlain: false)
}
let (layout, apply) = makeLayout(self, params, neighbors)
Queue.mainQueue().async {
completion(layout, { _ in
apply(animation.isAnimated)
})
}
}
}
}
}
public var selectable: Bool = true
public func selected(listView: ListView){
listView.clearHighlightAnimated(true)
self.action()
}
}
class LocalizationListItemNode: ItemListRevealOptionsItemNode {
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private let maskNode: ASImageNode
private let iconNode: ASImageNode
private let activityNode: ActivityIndicator
private let titleNode: TextNode
private let subtitleNode: TextNode
private var item: LocalizationListItem?
private var layoutParams: (ListViewItemLayoutParams, ItemListNeighbors)?
private var placeholderNode: ShimmerEffectNode?
private var absoluteLocation: (CGRect, CGSize)?
private var editableControlNode: ItemListEditableControlNode?
private var reorderControlNode: ItemListEditableReorderControlNode?
private let activateArea: AccessibilityAreaNode
private let containerNode: ASDisplayNode
override var controlsContainer: ASDisplayNode {
return self.containerNode
}
override var canBeSelected: Bool {
if self.editableControlNode != nil {
return false
}
if let _ = self.layoutParams?.0, let item = self.item, !item.loading, item.enabled {
return super.canBeSelected
} else {
return false
}
}
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.containerNode = ASDisplayNode()
self.iconNode = ASImageNode()
self.iconNode.isLayerBacked = true
self.iconNode.displayWithoutProcessing = true
self.iconNode.displaysAsynchronously = false
self.activityNode = ActivityIndicator(type: ActivityIndicatorType.custom(.black, 22.0, 0.0, false))
self.activityNode.isHidden = true
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.contentMode = .left
self.titleNode.contentsScale = UIScreenScale
self.subtitleNode = TextNode()
self.subtitleNode.isUserInteractionEnabled = false
self.subtitleNode.contentMode = .left
self.subtitleNode.contentsScale = UIScreenScale
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
self.activateArea = AccessibilityAreaNode()
super.init(layerBacked: false, rotated: false, seeThrough: false)
self.addSubnode(self.containerNode)
self.containerNode.addSubnode(self.iconNode)
self.containerNode.addSubnode(self.activityNode)
self.containerNode.addSubnode(self.titleNode)
self.containerNode.addSubnode(self.subtitleNode)
self.addSubnode(self.activateArea)
}
override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
var rect = rect
rect.origin.y += self.insets.top
self.absoluteLocation = (rect, containerSize)
if let shimmerNode = self.placeholderNode {
shimmerNode.updateAbsoluteRect(rect, within: containerSize)
}
}
func asyncLayout() -> (_ item: LocalizationListItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) {
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let makeSubtitleLayout = TextNode.asyncLayout(self.subtitleNode)
let editableControlLayout = ItemListEditableControlNode.asyncLayout(self.editableControlNode)
let currentItem = self.item
return { item, params, neighbors in
var leftInset: CGFloat = params.leftInset
let titleFont = Font.regular(item.presentationData.fontSize.itemListBaseFontSize)
let subtitleFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 13.0 / 17.0))
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - 50.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let (subtitleLayout, subtitleApply) = makeSubtitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.subtitle, font: subtitleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - 50.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let insets = itemListNeighborsGroupedInsets(neighbors, params)
let verticalInset: CGFloat
switch item.systemStyle {
case .glass:
verticalInset = 12.0
case .legacy:
verticalInset = 8.0
}
let contentSize = CGSize(width: params.width, height: titleLayout.size.height + 1.0 + subtitleLayout.size.height + verticalInset * 2.0)
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
var editableControlSizeAndApply: (CGFloat, (CGFloat) -> ItemListEditableControlNode)?
var editingOffset: CGFloat = 0.0
if item.editing.editing {
let sizeAndApply = editableControlLayout(item.presentationData.theme, false)
editableControlSizeAndApply = sizeAndApply
editingOffset = sizeAndApply.0
}
leftInset += 16.0
let separatorHeight = UIScreenPixel
let separatorRightInset: CGFloat = item.systemStyle == .glass ? 16.0 : 0.0
var updateCheckImage: UIImage?
var updatedTheme: PresentationTheme?
if currentItem?.presentationData.theme !== item.presentationData.theme {
updatedTheme = item.presentationData.theme
updateCheckImage = PresentationResourcesItemList.checkIconImage(item.presentationData.theme)
}
return (layout, { [weak self] animated in
if let strongSelf = self {
strongSelf.item = item
strongSelf.layoutParams = (params, neighbors)
strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: params.width - params.leftInset - params.rightInset, height: layout.contentSize.height))
strongSelf.activateArea.accessibilityLabel = item.title
strongSelf.activateArea.accessibilityValue = item.subtitle
let revealOffset = strongSelf.revealOffset
let transition: ContainedViewLayoutTransition
if animated {
transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
} else {
transition = .immediate
}
if let updateCheckImage = updateCheckImage {
strongSelf.iconNode.image = updateCheckImage
strongSelf.activityNode.type = ActivityIndicatorType.custom(item.presentationData.theme.list.itemAccentColor, 22.0, 0.0, false)
}
strongSelf.activityNode.isHidden = !item.activity
if let _ = updatedTheme {
strongSelf.topStripeNode.backgroundColor = item.presentationData.theme.list.itemBlocksSeparatorColor
strongSelf.bottomStripeNode.backgroundColor = item.presentationData.theme.list.itemBlocksSeparatorColor
strongSelf.backgroundNode.backgroundColor = item.presentationData.theme.list.itemBlocksBackgroundColor
strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor
}
let _ = titleApply()
let _ = subtitleApply()
if let image = strongSelf.iconNode.image {
transition.updateFrame(node: strongSelf.iconNode, frame: CGRect(origin: CGPoint(x: editingOffset + revealOffset + params.width - params.rightInset - image.size.width - floor((44.0 - image.size.width) / 2.0), y: floor((contentSize.height - image.size.height) / 2.0)), size: image.size))
}
let activitySize = CGSize(width: 22.0, height: 22.0)
transition.updateFrame(node: strongSelf.activityNode, frame: CGRect(origin: CGPoint(x: editingOffset + revealOffset + params.width - params.rightInset - activitySize.width - floor((44.0 - activitySize.width) / 2.0), y: floor((contentSize.height - activitySize.height) / 2.0)), size: activitySize))
strongSelf.iconNode.isHidden = !item.checked || item.activity
if strongSelf.backgroundNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0)
}
if strongSelf.topStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1)
}
if strongSelf.bottomStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2)
}
if strongSelf.maskNode.supernode == nil {
strongSelf.addSubnode(strongSelf.maskNode)
}
let hasCorners = itemListHasRoundedBlockLayout(params) && !item.alwaysPlain
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false):
strongSelf.topStripeNode.isHidden = true
default:
hasTopCorners = true
strongSelf.topStripeNode.isHidden = hasCorners
}
let bottomStripeInset: CGFloat
switch neighbors.bottom {
case .sameSection(false):
bottomStripeInset = leftInset
strongSelf.bottomStripeNode.isHidden = false
default:
bottomStripeInset = 0.0
hasBottomCorners = true
strongSelf.bottomStripeNode.isHidden = hasCorners
}
strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: item.systemStyle == .glass) : nil
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
strongSelf.containerNode.frame = CGRect(origin: CGPoint(), size: strongSelf.backgroundNode.frame.size)
strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: separatorHeight))
strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - separatorHeight), size: CGSize(width: params.width - bottomStripeInset - params.rightInset - separatorRightInset, height: separatorHeight))
transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: editingOffset + revealOffset + leftInset, y: verticalInset), size: titleLayout.size))
transition.updateFrame(node: strongSelf.subtitleNode, frame: CGRect(origin: CGPoint(x: editingOffset + revealOffset + leftInset, y: strongSelf.titleNode.frame.maxY + 1.0), size: subtitleLayout.size))
strongSelf.titleNode.alpha = item.enabled ? 1.0 : 0.5
strongSelf.subtitleNode.alpha = item.enabled ? 1.0 : 0.5
if let editableControlSizeAndApply = editableControlSizeAndApply {
let editableControlFrame = CGRect(origin: CGPoint(x: params.leftInset + revealOffset, y: 0.0), size: CGSize(width: editableControlSizeAndApply.0, height: layout.contentSize.height))
if strongSelf.editableControlNode == nil {
let editableControlNode = editableControlSizeAndApply.1(layout.contentSize.height)
editableControlNode.tapped = {
if let strongSelf = self {
strongSelf.setRevealOptionsOpened(true, animated: true)
strongSelf.revealOptionsInteractivelyOpened()
}
}
strongSelf.editableControlNode = editableControlNode
strongSelf.addSubnode(editableControlNode)
editableControlNode.frame = editableControlFrame
transition.animatePosition(node: editableControlNode, from: CGPoint(x: -editableControlFrame.size.width / 2.0, y: editableControlFrame.midY))
editableControlNode.alpha = 0.0
transition.updateAlpha(node: editableControlNode, alpha: 1.0)
} else {
strongSelf.editableControlNode?.frame = editableControlFrame
}
strongSelf.editableControlNode?.isHidden = !item.editing.editable
} else if let editableControlNode = strongSelf.editableControlNode {
var editableControlFrame = editableControlNode.frame
editableControlFrame.origin.x = -editableControlFrame.size.width
strongSelf.editableControlNode = nil
transition.updateAlpha(node: editableControlNode, alpha: 0.0)
transition.updateFrame(node: editableControlNode, frame: editableControlFrame, completion: { [weak editableControlNode] _ in
editableControlNode?.removeFromSupernode()
})
}
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: contentSize.height + UIScreenPixel + UIScreenPixel))
strongSelf.updateLayout(size: layout.contentSize, leftInset: params.leftInset, rightInset: params.rightInset)
if item.editing.editable, item.removeItem != nil {
strongSelf.setRevealOptions((left: [], right: [ItemListRevealOption(key: 0, title: item.presentationData.strings.Common_Delete, icon: .none, color: item.presentationData.theme.list.itemDisclosureActions.destructive.fillColor, textColor: item.presentationData.theme.list.itemDisclosureActions.destructive.foregroundColor)]))
} else {
strongSelf.setRevealOptions((left: [], right: []))
}
strongSelf.setRevealOptionsOpened(item.editing.revealed, animated: animated)
if item.loading {
let shimmerNode: ShimmerEffectNode
if let current = strongSelf.placeholderNode {
shimmerNode = current
} else {
shimmerNode = ShimmerEffectNode()
strongSelf.placeholderNode = shimmerNode
if strongSelf.bottomStripeNode.supernode != nil {
strongSelf.insertSubnode(shimmerNode, belowSubnode: strongSelf.bottomStripeNode)
} else {
strongSelf.addSubnode(shimmerNode)
}
}
shimmerNode.frame = CGRect(origin: CGPoint(), size: layout.contentSize)
if let (rect, size) = strongSelf.absoluteLocation {
shimmerNode.updateAbsoluteRect(rect, within: size)
}
var shapes: [ShimmerEffectNode.Shape] = []
let titleLineWidth: CGFloat = 80.0
let subtitleLineWidth: CGFloat = 50.0
let lineDiameter: CGFloat = 10.0
let titleFrame = strongSelf.titleNode.frame
shapes.append(.roundedRectLine(startPoint: CGPoint(x: titleFrame.minX, y: titleFrame.minY + floor((titleFrame.height - lineDiameter) / 2.0)), width: titleLineWidth, diameter: lineDiameter))
let subtitleFrame = strongSelf.subtitleNode.frame
shapes.append(.roundedRectLine(startPoint: CGPoint(x: subtitleFrame.minX, y: subtitleFrame.minY + floor((subtitleFrame.height - lineDiameter) / 2.0)), width: subtitleLineWidth, diameter: lineDiameter))
shimmerNode.update(backgroundColor: item.presentationData.theme.list.itemBlocksBackgroundColor, foregroundColor: item.presentationData.theme.list.mediaPlaceholderColor, shimmeringColor: item.presentationData.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4), shapes: shapes, size: layout.contentSize)
} else if let shimmerNode = strongSelf.placeholderNode {
strongSelf.placeholderNode = nil
shimmerNode.removeFromSupernode()
}
}
})
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
if highlighted {
self.highlightedBackgroundNode.alpha = 1.0
if self.highlightedBackgroundNode.supernode == nil {
var anchorNode: ASDisplayNode?
if self.bottomStripeNode.supernode != nil {
anchorNode = self.bottomStripeNode
} else if self.topStripeNode.supernode != nil {
anchorNode = self.topStripeNode
} else if self.backgroundNode.supernode != nil {
anchorNode = self.backgroundNode
}
if let anchorNode = anchorNode {
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: anchorNode)
} else {
self.addSubnode(self.highlightedBackgroundNode)
}
}
} else {
if self.highlightedBackgroundNode.supernode != nil {
if animated {
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in
if let strongSelf = self {
if completed {
strongSelf.highlightedBackgroundNode.removeFromSupernode()
}
}
})
self.highlightedBackgroundNode.alpha = 0.0
} else {
self.highlightedBackgroundNode.removeFromSupernode()
}
}
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
override func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) {
super.updateRevealOffset(offset: offset, transition: transition)
guard let params = self.layoutParams?.0 else {
return
}
var leftInset: CGFloat = params.leftInset
leftInset += 16.0
var editingOffset: CGFloat = 0.0
if let editableControlNode = self.editableControlNode {
editingOffset += editableControlNode.bounds.size.width
var editableControlFrame = editableControlNode.frame
editableControlFrame.origin.x = params.leftInset + offset
transition.updateFrame(node: editableControlNode, frame: editableControlFrame)
}
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: editingOffset + leftInset + offset, y: self.titleNode.frame.minY), size: self.titleNode.bounds.size))
transition.updateFrame(node: self.subtitleNode, frame: CGRect(origin: CGPoint(x: editingOffset + leftInset + offset, y: self.subtitleNode.frame.minY), size: self.subtitleNode.bounds.size))
if let image = self.iconNode.image {
transition.updateFrame(node: self.iconNode, frame: CGRect(origin: CGPoint(x: editingOffset + offset + params.width - params.rightInset - image.size.width - floor((44.0 - image.size.width) / 2.0), y: self.iconNode.frame.minY), size: self.iconNode.bounds.size))
}
let activitySize = CGSize(width: 22.0, height: 22.0)
transition.updateFrame(node: self.activityNode, frame: CGRect(origin: CGPoint(x: editingOffset + offset + params.width - params.rightInset - activitySize.width - floor((44.0 - activitySize.width) / 2.0), y: floor((contentSize.height - activitySize.height) / 2.0)), size: activitySize))
}
override func revealOptionsInteractivelyOpened() {
if let item = self.item {
item.setItemWithRevealedOptions?(item.id, nil)
}
}
override func revealOptionsInteractivelyClosed() {
if let item = self.item {
item.setItemWithRevealedOptions?(nil, item.id)
}
}
override func revealOptionSelected(_ option: ItemListRevealOption, animated: Bool) {
self.setRevealOptionsOpened(false, animated: true)
self.revealOptionsInteractivelyClosed()
if let item = self.item {
item.removeItem?(item.id)
}
}
}
@@ -0,0 +1,120 @@
import Foundation
import UIKit
import ComponentFlow
import ManagedAnimationNode
enum PlayPauseIconNodeState: Equatable {
case play
case pause
}
private final class PlayPauseIconNode: ManagedAnimationNode {
private let duration: Double = 0.35
private var iconState: PlayPauseIconNodeState = .play
init() {
super.init(size: CGSize(width: 40.0, height: 40.0))
self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.01))
}
func enqueueState(_ state: PlayPauseIconNodeState, animated: Bool) {
guard self.iconState != state else {
return
}
let previousState = self.iconState
self.iconState = state
switch previousState {
case .pause:
switch state {
case .play:
if animated {
self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 41, endFrame: 83), duration: self.duration))
} else {
self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.01))
}
case .pause:
break
}
case .play:
switch state {
case .pause:
if animated {
self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 0, endFrame: 41), duration: self.duration))
} else {
self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 41, endFrame: 41), duration: 0.01))
}
case .play:
break
}
}
}
}
final class PlayPauseIconComponent: Component {
let state: PlayPauseIconNodeState
let tintColor: UIColor?
let size: CGSize
init(state: PlayPauseIconNodeState, tintColor: UIColor?, size: CGSize) {
self.state = state
self.tintColor = tintColor
self.size = size
}
static func ==(lhs: PlayPauseIconComponent, rhs: PlayPauseIconComponent) -> Bool {
if lhs.state != rhs.state {
return false
}
if lhs.tintColor != rhs.tintColor {
return false
}
if lhs.size != rhs.size {
return false
}
return true
}
final class View: UIView {
private var component: PlayPauseIconComponent?
private var animationNode: PlayPauseIconNode
override init(frame: CGRect) {
self.animationNode = PlayPauseIconNode()
super.init(frame: frame)
self.addSubview(self.animationNode.view)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: PlayPauseIconComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize {
if self.component?.state != component.state {
self.component = component
self.animationNode.enqueueState(component.state, animated: true)
}
self.animationNode.customColor = component.tintColor
let animationSize = component.size
let size = CGSize(width: min(animationSize.width, availableSize.width), height: min(animationSize.height, availableSize.height))
self.animationNode.view.frame = CGRect(origin: CGPoint(x: floor((size.width - animationSize.width) / 2.0), y: floor((size.height - animationSize.height) / 2.0)), size: animationSize)
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, transition: transition)
}
}
@@ -0,0 +1,548 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import AccountContext
import NaturalLanguage
import TelegramCore
import SwiftUI
import Translation
import Combine
// Incuding at least one Objective-C class in a swift file ensures that it doesn't get stripped by the linker
private final class LinkHelperClass: NSObject {
}
public var supportedTranslationLanguages = [
"af",
"sq",
"am",
"ar",
"hy",
"az",
"eu",
"be",
"bn",
"bs",
"bg",
"ca",
"ceb",
"zh",
"co",
"hr",
"cs",
"da",
"nl",
"en",
"eo",
"et",
"fi",
"fr",
"fy",
"gl",
"ka",
"de",
"el",
"gu",
"ht",
"ha",
"haw",
"he",
"hi",
"hmn",
"hu",
"is",
"ig",
"id",
"ga",
"it",
"ja",
"jv",
"kn",
"kk",
"km",
"rw",
"ko",
"ku",
"ky",
"lo",
"lv",
"lt",
"lb",
"mk",
"mg",
"ms",
"ml",
"mt",
"mi",
"mr",
"mn",
"my",
"ne",
"no",
"ny",
"or",
"ps",
"fa",
"pl",
"pt",
"pa",
"ro",
"ru",
"sm",
"gd",
"sr",
"st",
"sn",
"sd",
"si",
"sk",
"sl",
"so",
"es",
"su",
"sw",
"sv",
"tl",
"tg",
"ta",
"tt",
"te",
"th",
"tr",
"tk",
"uk",
"ur",
"ug",
"uz",
"vi",
"cy",
"xh",
"yi",
"yo",
"zu"
]
public var popularTranslationLanguages = [
"en",
"ar",
"zh",
"fr",
"de",
"it",
"ja",
"ko",
"pt",
"ru",
"es",
"uk"
]
@available(iOS 12.0, *)
private let languageRecognizer = NLLanguageRecognizer()
public func effectiveIgnoredTranslationLanguages(context: AccountContext, ignoredLanguages: [String]?) -> Set<String> {
var baseLang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
let rawSuffix = "-raw"
if baseLang.hasSuffix(rawSuffix) {
baseLang = String(baseLang.dropLast(rawSuffix.count))
}
var dontTranslateLanguages = Set<String>()
if let ignoredLanguages = ignoredLanguages {
dontTranslateLanguages = Set(ignoredLanguages)
} else {
dontTranslateLanguages.insert(baseLang)
for language in systemLanguageCodes() {
dontTranslateLanguages.insert(language)
}
}
return dontTranslateLanguages
}
public func normalizeTranslationLanguage(_ code: String) -> String {
var code = code
if code.contains("-") {
code = code.components(separatedBy: "-").first ?? code
}
if code == "nb" {
code = "no"
}
return code
}
public func canTranslateChats(context: AccountContext) -> Bool {
let translationConfiguration = TranslationConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
var chatTranslationAvailable = true
switch translationConfiguration.auto {
case .system:
if #available(iOS 18.0, *) {
} else {
chatTranslationAvailable = false
}
case .alternative, .disabled:
chatTranslationAvailable = false
default:
break
}
return chatTranslationAvailable || true // MARK: Swiftgram
}
public func canTranslateText(context: AccountContext, text: String, showTranslate: Bool, showTranslateIfTopical: Bool = false, ignoredLanguages: [String]?) -> (canTranslate: Bool, language: String?) {
guard showTranslate || showTranslateIfTopical, text.count > 0 else {
return (false, nil)
}
let translationConfiguration = TranslationConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
var translateButtonAvailable = false
switch translationConfiguration.manual {
case .enabled, .alternative:
translateButtonAvailable = true
case .system:
if #available(iOS 18.0, *) {
translateButtonAvailable = true
}
default:
break
}
translateButtonAvailable = true // MARK: Swiftgram
let showTranslate = showTranslate && translateButtonAvailable
if #available(iOS 12.0, *) {
if context.sharedContext.immediateExperimentalUISettings.disableLanguageRecognition {
return (true, nil)
}
let dontTranslateLanguages = effectiveIgnoredTranslationLanguages(context: context, ignoredLanguages: ignoredLanguages)
let text = String(text.prefix(64))
languageRecognizer.processString(text)
let hypotheses = languageRecognizer.languageHypotheses(withMaximum: 3)
languageRecognizer.reset()
var supportedTranslationLanguages = supportedTranslationLanguages
if !showTranslate && showTranslateIfTopical {
supportedTranslationLanguages = ["uk", "ru"]
}
let filteredLanguages = hypotheses.filter { supportedTranslationLanguages.contains(normalizeTranslationLanguage($0.key.rawValue)) }.sorted(by: { $0.value > $1.value })
if let language = filteredLanguages.first {
let languageCode = normalizeTranslationLanguage(language.key.rawValue)
return (!dontTranslateLanguages.contains(languageCode), languageCode)
} else {
return (false, nil)
}
} else {
return (false, nil)
}
}
public func systemLanguageCodes() -> [String] {
var languages: [String] = []
for language in Locale.preferredLanguages.prefix(2) {
let language = language.components(separatedBy: "-").first ?? language
languages.append(language)
}
if languages.count == 2 && languages != ["en", "ru"] {
languages = Array(languages.prefix(1))
}
return languages
}
@available(iOS 13.0, *)
class ExternalTranslationTrigger: ObservableObject {
@Published var shouldInvalidate: Int = 0
}
@available(iOS 18.0, *)
private struct TranslationViewImpl: View {
@State private var configuration: TranslationSession.Configuration?
@ObservedObject var externalCondition: ExternalTranslationTrigger
private let taskContainer: Atomic<ExperimentalInternalTranslationServiceImpl.TranslationTaskContainer>
init(externalCondition: ExternalTranslationTrigger, taskContainer: Atomic<ExperimentalInternalTranslationServiceImpl.TranslationTaskContainer>) {
self.externalCondition = externalCondition
self.taskContainer = taskContainer
}
var body: some View {
Text("ABC")
.onChange(of: self.externalCondition.shouldInvalidate) { _ in
let firstTaskLanguagePair = self.taskContainer.with { taskContainer -> (String, String)? in
if let firstTask = taskContainer.tasks.first {
return (firstTask.fromLang, firstTask.toLang)
} else {
return nil
}
}
if let firstTaskLanguagePair {
if let configuration = self.configuration, configuration.source?.languageCode?.identifier == firstTaskLanguagePair.0, configuration.target?.languageCode?.identifier == firstTaskLanguagePair.1 {
self.configuration?.invalidate()
} else {
self.configuration = .init(
source: Locale.Language(identifier: firstTaskLanguagePair.0),
target: Locale.Language(identifier: firstTaskLanguagePair.1)
)
}
}
}
.translationTask(self.configuration, action: { session in
var task: ExperimentalInternalTranslationServiceImpl.TranslationTask?
task = self.taskContainer.with { taskContainer -> ExperimentalInternalTranslationServiceImpl.TranslationTask? in
if !taskContainer.tasks.isEmpty {
return taskContainer.tasks.removeFirst()
} else {
return nil
}
}
guard let task else {
return
}
do {
var nextClientIdentifier: Int = 0
var clientIdentifierMap: [String: AnyHashable] = [:]
let translationRequests = task.texts.map { key, value in
let id = nextClientIdentifier
nextClientIdentifier += 1
clientIdentifierMap["\(id)"] = key
return TranslationSession.Request(sourceText: value, clientIdentifier: "\(id)")
}
let responses = try await session.translations(from: translationRequests)
var resultMap: [AnyHashable: String] = [:]
for response in responses {
if let clientIdentifier = response.clientIdentifier, let originalKey = clientIdentifierMap[clientIdentifier] {
resultMap[originalKey] = "\(response.targetText)"
}
}
task.completion(resultMap)
} catch let e {
print("Translation error: \(e)")
task.completion(nil)
}
let firstTaskLanguagePair = self.taskContainer.with { taskContainer -> (String, String)? in
if let firstTask = taskContainer.tasks.first {
return (firstTask.fromLang, firstTask.toLang)
} else {
return nil
}
}
if let firstTaskLanguagePair {
if let configuration = self.configuration, configuration.source?.languageCode?.identifier == firstTaskLanguagePair.0, configuration.target?.languageCode?.identifier == firstTaskLanguagePair.1 {
self.configuration?.invalidate()
} else {
self.configuration = .init(
source: Locale.Language(identifier: firstTaskLanguagePair.0),
target: Locale.Language(identifier: firstTaskLanguagePair.1)
)
}
}
})
}
}
@available(iOS 18.0, *)
public final class ExperimentalInternalTranslationServiceImpl: ExperimentalInternalTranslationService {
fileprivate final class TranslationTask {
let id: Int
let texts: [AnyHashable: String]
let fromLang: String
let toLang: String
let completion: ([AnyHashable: String]?) -> Void
init(id: Int, texts: [AnyHashable: String], fromLang: String, toLang: String, completion: @escaping ([AnyHashable: String]?) -> Void) {
self.id = id
self.texts = texts
self.fromLang = fromLang
self.toLang = toLang
self.completion = completion
}
}
fileprivate final class TranslationTaskContainer {
var tasks: [TranslationTask] = []
init() {
}
}
private final class Impl {
private let hostingController: UIViewController
private let taskContainer = Atomic(value: TranslationTaskContainer())
private let taskTrigger = ExternalTranslationTrigger()
private var nextId: Int = 0
init(view: UIView) {
self.hostingController = UIHostingController(rootView: TranslationViewImpl(
externalCondition: self.taskTrigger,
taskContainer: self.taskContainer
))
view.addSubview(self.hostingController.view)
}
func translate(texts: [AnyHashable: String], fromLang: String, toLang: String, onResult: @escaping ([AnyHashable: String]?) -> Void) -> Disposable {
let id = self.nextId
self.nextId += 1
self.taskContainer.with { taskContainer in
taskContainer.tasks.append(TranslationTask(
id: id,
texts: texts,
fromLang: fromLang,
toLang: toLang,
completion: { result in
onResult(result)
}
))
}
self.taskTrigger.shouldInvalidate += 1
return ActionDisposable { [weak self] in
Queue.mainQueue().async {
guard let self else {
return
}
self.taskContainer.with { taskContainer in
taskContainer.tasks.removeAll(where: { $0.id == id })
}
}
}
}
}
private let impl: QueueLocalObject<Impl>
public init(view: UIView) {
self.impl = QueueLocalObject(queue: .mainQueue(), generate: {
return Impl(view: view)
})
}
public func translate(texts: [AnyHashable: String], fromLang: String, toLang: String) -> Signal<[AnyHashable: String]?, NoError> {
return self.impl.signalWith { impl, subscriber in
return impl.translate(texts: texts, fromLang: fromLang, toLang: toLang, onResult: { result in
subscriber.putNext(result)
subscriber.putCompletion()
})
}
}
}
func alternativeTranslateText(text: String, fromLang: String?, toLang: String) -> Signal<(String, [MessageTextEntity])?, TelegramCore.TranslationError> {
return Signal { subscriber in
var task: URLSessionTask?
Queue.concurrentDefaultQueue().async {
let effectiveFromLang: String
if let fromLang {
effectiveFromLang = fromLang
} else {
languageRecognizer.processString(text)
let hypotheses = languageRecognizer.languageHypotheses(withMaximum: 3)
languageRecognizer.reset()
let filteredLanguages = hypotheses.filter { supportedTranslationLanguages.contains(normalizeTranslationLanguage($0.key.rawValue)) }.sorted(by: { $0.value > $1.value })
if let language = filteredLanguages.first {
let languageCode = normalizeTranslationLanguage(language.key.rawValue)
effectiveFromLang = languageCode
} else {
effectiveFromLang = "en"
}
}
var uri = "https://translate.goo"
uri += "gleapis.com/transl"
uri += "ate_a"
uri += "/singl"
uri += "e?client=gtx&sl=\(effectiveFromLang.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")"
uri += "&tl=\(toLang.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")"
uri += "&dt=t&ie=UTF-8&oe=UTF-8&otf=1&ssel=0&tsel=0&kc=7&dt=at&dt=bd&dt=ex&dt=ld&dt=md&dt=qca&dt=rw&dt=rm&dt=ss&q="
uri += text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
guard let url = URL(string: uri) else {
subscriber.putError(.generic)
return
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(getRandomUserAgent(), forHTTPHeaderField: "User-Agent")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
print("Translation failed: \(error.localizedDescription)")
subscriber.putError(.generic)
return
}
guard let httpResponse = response as? HTTPURLResponse else {
subscriber.putError(.generic)
return
}
if httpResponse.statusCode != 200 {
print("Translation failed with status code: \(httpResponse.statusCode)")
let isRateLimit = httpResponse.statusCode == 429
subscriber.putError(isRateLimit ? .limitExceeded : .generic)
return
}
guard let data = data else {
subscriber.putError(.generic)
return
}
do {
guard let jsonArray = try JSONSerialization.jsonObject(with: data) as? [Any] else {
subscriber.putError(.generic)
return
}
guard let translationArray = jsonArray.first as? [Any] else {
subscriber.putError(.generic)
return
}
var result = ""
for element in translationArray {
if let translationBlock = element as? [Any],
translationBlock.count > 0,
let blockText = translationBlock[0] as? String,
blockText != "null" && !blockText.isEmpty {
result += blockText
}
}
if text.hasPrefix("\n") {
result = "\n" + result
}
subscriber.putNext((result, []))
subscriber.putCompletion()
} catch {
print("JSON parsing error: \(error)")
subscriber.putError(.generic)
}
}
task?.resume()
}
return ActionDisposable {
task?.cancel()
}
}
}
func getRandomUserAgent() -> String {
let userAgents = [
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1"
]
return userAgents.randomElement() ?? userAgents[0]
}
@@ -0,0 +1,184 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import BundleIconComponent
private final class TranslateButtonContentComponent: CombinedComponent {
let theme: PresentationTheme
let title: String
let icon: String
init(
theme: PresentationTheme,
title: String,
icon: String
) {
self.theme = theme
self.title = title
self.icon = icon
}
static func ==(lhs: TranslateButtonContentComponent, rhs: TranslateButtonContentComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.icon != rhs.icon {
return false
}
return true
}
static var body: Body {
let title = Child(Text.self)
let icon = Child(BundleIconComponent.self)
return { context in
let component = context.component
let icon = icon.update(
component: BundleIconComponent(
name: component.icon,
tintColor: component.theme.list.itemPrimaryTextColor
),
availableSize: CGSize(width: 30.0, height: 30.0),
transition: context.transition
)
let title = title.update(
component: Text(
text: component.title,
font: Font.regular(17.0),
color: component.theme.list.itemPrimaryTextColor
),
availableSize: context.availableSize,
transition: .immediate
)
let sideInset: CGFloat = 16.0
let textSideInset: CGFloat = 60.0
context.add(title
.position(CGPoint(x: textSideInset + title.size.width / 2.0, y: context.availableSize.height / 2.0))
)
context.add(icon
.position(CGPoint(x: sideInset + icon.size.width / 2.0, y: context.availableSize.height / 2.0))
)
return context.availableSize
}
}
}
final class TranslateButtonComponent: Component {
private let content: TranslateButtonContentComponent
private let theme: PresentationTheme
private let isEnabled: Bool
private let action: () -> Void
init(
theme: PresentationTheme,
title: String,
icon: String,
isEnabled: Bool,
action: @escaping () -> Void
) {
self.content = TranslateButtonContentComponent(theme: theme, title: title, icon: icon)
self.isEnabled = isEnabled
self.theme = theme
self.action = action
}
static func ==(lhs: TranslateButtonComponent, rhs: TranslateButtonComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.content !== rhs.content {
return false
}
if lhs.isEnabled != rhs.isEnabled {
return false
}
return true
}
final class View: HighlightTrackingButton {
private let backgroundView: UIView
private let centralContentView: ComponentHostView<Empty>
private var component: TranslateButtonComponent?
override init(frame: CGRect) {
self.backgroundView = UIView()
self.backgroundView.isUserInteractionEnabled = false
self.centralContentView = ComponentHostView()
self.centralContentView.isUserInteractionEnabled = false
super.init(frame: frame)
self.backgroundView.clipsToBounds = true
self.addSubview(self.backgroundView)
self.addSubview(self.centralContentView)
self.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self, let component = strongSelf.component {
if highlighted {
strongSelf.backgroundView.backgroundColor = component.theme.list.itemHighlightedBackgroundColor
} else {
UIView.animate(withDuration: 0.3, animations: {
strongSelf.backgroundView.backgroundColor = component.theme.list.itemBlocksBackgroundColor
})
}
}
}
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc func pressed() {
if let component = self.component {
component.action()
}
}
public func update(component: TranslateButtonComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize {
self.component = component
self.backgroundView.backgroundColor = component.theme.list.itemBlocksBackgroundColor
self.backgroundView.layer.cornerRadius = 26.0
let _ = self.centralContentView.update(
transition: transition,
component: AnyComponent(component.content),
environment: {},
containerSize: availableSize
)
transition.setFrame(view: self.centralContentView, frame: CGRect(origin: CGPoint(), size: availableSize), completion: nil)
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: availableSize), completion: nil)
self.centralContentView.alpha = component.isEnabled ? 1.0 : 0.4
self.isUserInteractionEnabled = component.isEnabled
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
@@ -0,0 +1,851 @@
import SGSimpleSettings
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import AccountContext
import TelegramPresentationData
import PresentationDataUtils
import Speak
import ComponentFlow
import ViewControllerComponent
import MultilineTextComponent
import MultilineTextWithEntitiesComponent
import BundleIconComponent
import UndoUI
import SwiftUI
import ResizableSheetComponent
import GlassBarButtonComponent
private func generateExpandBackground(size: CGSize, color: UIColor) -> UIImage {
return generateImage(size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
var locations: [CGFloat] = [0.0, 1.0]
let colors: [CGColor] = [color.withAlphaComponent(0.0).cgColor, color.cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 40.0, y: size.height), options: CGGradientDrawingOptions())
context.setFillColor(color.cgColor)
context.fill(CGRect(origin: CGPoint(x: 40.0, y: 0.0), size: CGSize(width: size.width - 40.0, height: size.height)))
})!
}
private final class SheetContent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let text: String
let entities: [MessageTextEntity]
let fromLanguage: String?
let toLanguage: String
let copyTranslation: ((String) -> Void)?
let changeLanguage: (String, String, @escaping (String, String) -> Void) -> Void
let expand: () -> Void
init(context: AccountContext, text: String, entities: [MessageTextEntity], fromLanguage: String?, toLanguage: String, copyTranslation: ((String) -> Void)?, changeLanguage: @escaping (String, String, @escaping (String, String) -> Void) -> Void, expand: @escaping () -> Void) {
self.context = context
self.text = text
self.entities = entities
self.fromLanguage = fromLanguage
self.toLanguage = toLanguage
self.copyTranslation = copyTranslation
self.changeLanguage = changeLanguage
self.expand = expand
}
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.text != rhs.text {
return false
}
if lhs.entities != rhs.entities {
return false
}
if lhs.fromLanguage != rhs.fromLanguage {
return false
}
if lhs.toLanguage != rhs.toLanguage {
return false
}
return true
}
final class State: ComponentState {
private let context: AccountContext
var fromLanguage: String?
let text: String
var textExpanded: Bool = false
var toLanguage: String
var translatedText: String?
private let expand: () -> Void
private var translationDisposable = MetaDisposable()
fileprivate var isSpeakingOriginalText: Bool = false
fileprivate var isSpeakingTranslatedText: Bool = false
private var speechHolder: SpeechSynthesizerHolder?
fileprivate var availableSpeakLanguages: Set<String>
fileprivate var moreBackgroundImage: (CGSize, UIImage, UIColor)?
private let useAlternativeTranslation: Bool
init(context: AccountContext, fromLanguage: String?, text: String, toLanguage: String, expand: @escaping () -> Void) {
self.context = context
self.text = text
self.fromLanguage = fromLanguage
self.toLanguage = toLanguage
self.expand = expand
self.availableSpeakLanguages = supportedSpeakLanguages()
let translationConfiguration = TranslationConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
var useAlternativeTranslation = false
switch translationConfiguration.manual {
case .alternative:
useAlternativeTranslation = true
default:
break
}
self.useAlternativeTranslation = useAlternativeTranslation
super.init()
self.translationDisposable.set((self.translate(text: text, fromLang: fromLanguage, toLang: toLanguage) |> deliverOnMainQueue).start(next: { [weak self] text in
guard let strongSelf = self else {
return
}
strongSelf.translatedText = text?.0
strongSelf.updated(transition: .immediate)
}, error: { error in
}))
}
deinit {
self.speechHolder?.stop()
self.translationDisposable.dispose()
}
func translate(text: String, fromLang: String?, toLang: String) -> Signal<(String, [MessageTextEntity])?, TranslationError> {
if self.useAlternativeTranslation && SGSimpleSettings.shared.translationBackendEnum == .default {
return alternativeTranslateText(text: text, fromLang: fromLang, toLang: toLang)
} else {
return self.context.engine.messages.translate(text: text, toLang: toLang)
}
}
func changeLanguage(fromLanguage: String, toLanguage: String) {
guard self.fromLanguage != fromLanguage || self.toLanguage != toLanguage else {
return
}
self.fromLanguage = fromLanguage
self.toLanguage = toLanguage
self.translatedText = nil
self.updated(transition: .immediate)
self.translationDisposable.set((self.translate(text: text, fromLang: fromLanguage, toLang: toLanguage) |> deliverOnMainQueue).start(next: { [weak self] text in
guard let strongSelf = self else {
return
}
strongSelf.translatedText = text?.0
strongSelf.updated(transition: .immediate)
}, error: { error in
}))
}
func expandText() {
self.textExpanded = true
self.updated(transition: .immediate)
self.expand()
}
func speakOriginalText() {
if let speechHolder = self.speechHolder {
self.speechHolder = nil
speechHolder.stop()
}
if self.isSpeakingOriginalText {
self.isSpeakingOriginalText = false
} else {
self.isSpeakingTranslatedText = false
self.isSpeakingOriginalText = true
self.speechHolder = speakText(context: self.context, text: self.text)
self.speechHolder?.completion = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.isSpeakingOriginalText = false
strongSelf.updated(transition: .immediate)
}
}
self.updated(transition: .immediate)
}
func speakTranslatedText() {
guard let translatedText = self.translatedText else {
return
}
if let speechHolder = self.speechHolder {
self.speechHolder = nil
speechHolder.stop()
}
if self.isSpeakingTranslatedText {
self.isSpeakingTranslatedText = false
} else {
self.isSpeakingOriginalText = false
self.isSpeakingTranslatedText = true
self.speechHolder = speakText(context: self.context, text: translatedText)
self.speechHolder?.completion = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.isSpeakingTranslatedText = false
strongSelf.updated(transition: .immediate)
}
}
self.updated(transition: .immediate)
}
}
func makeState() -> State {
return State(context: self.context, fromLanguage: self.fromLanguage, text: self.text, toLanguage: self.toLanguage, expand: self.expand)
}
static var body: Body {
let textBackground = Child(RoundedRectangle.self)
let originalTitle = Child(MultilineTextComponent.self)
let originalText = Child(MultilineTextComponent.self)
let originalMoreBackground = Child(Image.self)
let originalMoreButton = Child(Button.self)
let originalSpeakButton = Child(Button.self)
let translationTitle = Child(MultilineTextComponent.self)
let translationText = Child(MultilineTextComponent.self)
let translationPlaceholder = Child(RoundedRectangle.self)
let translationSpeakButton = Child(Button.self)
let copyButton = Child(TranslateButtonComponent.self)
let changeLanguageButton = Child(TranslateButtonComponent.self)
let textStripe = Child(Rectangle.self)
return { context in
let environment = context.environment[ViewControllerComponentContainer.Environment.self].value
let state = context.state
let theme = environment.theme.withModalBlocksBackground()
let strings = environment.strings
let topInset: CGFloat = environment.navigationHeight - 35.0
let sideInset: CGFloat = 20.0 + environment.safeInsets.left
let textTopInset: CGFloat = 16.0
let textSideInset: CGFloat = 20.0
let textSpacing: CGFloat = 5.0
let itemSpacing: CGFloat = 20.0
let itemHeight: CGFloat = 52.0
var languageCode = environment.strings.baseLanguageCode
let rawSuffix = "-raw"
if languageCode.hasSuffix(rawSuffix) {
languageCode = String(languageCode.dropLast(rawSuffix.count))
}
let locale = Locale(identifier: languageCode)
let fromLanguage: String
if let languageCode = state.fromLanguage {
fromLanguage = locale.localizedString(forLanguageCode: languageCode) ?? ""
} else {
fromLanguage = ""
}
let originalTitle = originalTitle.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: fromLanguage, font: Font.medium(13.0), textColor: theme.list.itemPrimaryTextColor, paragraphAlignment: .natural)),
horizontalAlignment: .natural,
maximumNumberOfLines: 1
),
availableSize: CGSize(width: context.availableSize.width - (sideInset + textSideInset) * 2.0, height: CGFloat.greatestFiniteMagnitude),
transition: .immediate
)
let originalText = originalText.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: state.text, font: Font.medium(20.0), textColor: theme.list.itemPrimaryTextColor, paragraphAlignment: .natural)),
horizontalAlignment: .natural,
maximumNumberOfLines: state.textExpanded ? 0 : 1,
lineSpacing: 0.1
),
availableSize: CGSize(width: context.availableSize.width - (sideInset + textSideInset) * 2.0 - (state.textExpanded ? 30.0 : 0.0), height: context.availableSize.height),
transition: .immediate
)
let toLanguage = locale.localizedString(forLanguageCode: state.toLanguage) ?? ""
let translationTitle = translationTitle.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: toLanguage, font: Font.medium(13.0), textColor: theme.list.itemAccentColor, paragraphAlignment: .natural)),
horizontalAlignment: .natural,
maximumNumberOfLines: 1
),
availableSize: CGSize(width: context.availableSize.width - (sideInset + textSideInset) * 2.0, height: CGFloat.greatestFiniteMagnitude),
transition: .immediate
)
let translationTextHeight: CGFloat
var maybeTranslationText: _UpdatedChildComponent? = nil
var maybeTranslationPlaceholder: _UpdatedChildComponent? = nil
if let translatedText = state.translatedText {
maybeTranslationText = translationText.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: translatedText, font: Font.medium(20.0), textColor: theme.list.itemAccentColor, paragraphAlignment: .natural)),
horizontalAlignment: .natural,
maximumNumberOfLines: 0,
lineSpacing: 0.1
),
availableSize: CGSize(width: context.availableSize.width - (sideInset + textSideInset) * 2.0 - 30.0, height: context.availableSize.height),
transition: .immediate
)
translationTextHeight = maybeTranslationText?.size.height ?? 0.0
} else {
maybeTranslationPlaceholder = translationPlaceholder.update(
component: RoundedRectangle(color: theme.list.itemAccentColor.withAlphaComponent(0.17), cornerRadius: 6.0),
availableSize: CGSize(width: context.availableSize.width - (sideInset + textSideInset) * 2.0 - 42.0, height: 12.0),
transition: .immediate
)
translationTextHeight = 22.0
}
let textBackgroundOrigin = CGPoint(x: sideInset, y: topInset)
let textStripe = textStripe.update(
component: Rectangle(color: theme.list.itemPlainSeparatorColor),
availableSize: CGSize(width: context.availableSize.width - (sideInset + textSideInset) * 2.0, height: UIScreenPixel),
transition: .immediate
)
let textBackgroundSize = CGSize(width: context.availableSize.width - sideInset * 2.0, height: textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationTextHeight + itemSpacing)
let textBackground = textBackground.update(
component: RoundedRectangle(color: theme.list.itemBlocksBackgroundColor, cornerRadius: 26.0),
availableSize: textBackgroundSize,
transition: context.transition
)
context.add(textBackground
.position(CGPoint(x: textBackgroundOrigin.x + textBackgroundSize.width / 2.0, y: topInset + textBackgroundSize.height / 2.0))
)
context.add(textStripe
.position(CGPoint(x: textBackgroundOrigin.x + textSideInset + textStripe.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing))
)
context.add(originalTitle
.position(CGPoint(x: textBackgroundOrigin.x + textSideInset + originalTitle.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height / 2.0))
)
context.add(originalText
.position(CGPoint(x: textBackgroundOrigin.x + textSideInset + originalText.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height / 2.0))
)
if state.textExpanded {
if let fromLanguage = state.fromLanguage, state.availableSpeakLanguages.contains(fromLanguage) {
var checkColor = theme.list.itemCheckColors.foregroundColor
if checkColor.rgb == theme.list.itemPrimaryTextColor.rgb {
checkColor = theme.list.plainBackgroundColor
}
let originalSpeakButton = originalSpeakButton.update(
component: Button(
content: AnyComponent(ZStack([
AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle(
fillColor: theme.list.itemPrimaryTextColor,
size: CGSize(width: 26.0, height: 26.0)
))),
AnyComponentWithIdentity(id: "a", component: AnyComponent(PlayPauseIconComponent(
state: state.isSpeakingOriginalText ? .pause : .play,
tintColor: checkColor,
size: CGSize(width: 20.0, height: 20.0)
))),
])),
action: { [weak state] in
guard let state = state else {
return
}
state.speakOriginalText()
}
).minSize(CGSize(width: 44.0, height: 44.0)),
availableSize: CGSize(width: 26.0, height: 26.0),
transition: .immediate
)
context.add(originalSpeakButton
.position(CGPoint(x: context.availableSize.width - sideInset - textSideInset - originalSpeakButton.size.width / 2.0 + 9.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height - originalSpeakButton.size.height / 2.0 - 2.0 + 12.0))
)
}
} else {
let originalMoreButton = originalMoreButton.update(
component: Button(
content: AnyComponent(Text(text: strings.PeerInfo_BioExpand, font: Font.regular(17.0), color: theme.list.itemAccentColor)),
action: { [weak state] in
guard let state = state else {
return
}
state.expandText()
}
),
availableSize: context.availableSize,
transition: .immediate
)
let originalMoreBackgroundSize = CGSize(width: originalMoreButton.size.width + 50.0, height: originalMoreButton.size.height)
let originalMoreBackgroundImage: UIImage
let backgroundColor = theme.list.itemBlocksBackgroundColor
if let (size, image, color) = state.moreBackgroundImage, size == originalMoreBackgroundSize && color == backgroundColor {
originalMoreBackgroundImage = image
} else {
originalMoreBackgroundImage = generateExpandBackground(size: originalMoreBackgroundSize, color: backgroundColor)
state.moreBackgroundImage = (originalMoreBackgroundSize, originalMoreBackgroundImage, backgroundColor)
}
let originalMoreBackground = originalMoreBackground.update(
component: Image(image: originalMoreBackgroundImage, tintColor: backgroundColor),
availableSize: originalMoreBackgroundSize,
transition: .immediate
)
context.add(originalMoreBackground
.position(CGPoint(x: context.availableSize.width - sideInset - textSideInset - originalMoreBackground.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalMoreBackground.size.height / 2.0 - 1.0))
)
context.add(originalMoreButton
.position(CGPoint(x: context.availableSize.width - sideInset - textSideInset - originalMoreButton.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height / 2.0 - 1.0))
)
}
context.add(translationTitle
.position(CGPoint(x: textBackgroundOrigin.x + textSideInset + translationTitle.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height / 2.0))
)
if let translationText = maybeTranslationText {
context.add(translationText
.position(CGPoint(x: textBackgroundOrigin.x + textSideInset + translationText.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationText.size.height / 2.0))
)
if state.availableSpeakLanguages.contains(state.toLanguage) {
let translationSpeakButton = translationSpeakButton.update(
component: Button(
content: AnyComponent(ZStack([
AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle(
fillColor: theme.list.itemAccentColor,
size: CGSize(width: 26.0, height: 26.0)
))),
AnyComponentWithIdentity(id: "a", component: AnyComponent(PlayPauseIconComponent(
state: state.isSpeakingTranslatedText ? .pause : .play,
tintColor: theme.list.itemCheckColors.foregroundColor,
size: CGSize(width: 20.0, height: 20.0)
))),
])),
action: { [weak state] in
guard let state = state else {
return
}
state.speakTranslatedText()
}
).minSize(CGSize(width: 44.0, height: 44.0)),
availableSize: CGSize(width: 26.0, height: 26.0),
transition: .immediate
)
context.add(translationSpeakButton
.position(CGPoint(x: context.availableSize.width - sideInset - textSideInset - translationSpeakButton.size.width / 2.0 + 9.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationTextHeight - translationSpeakButton.size.height / 2.0 - 2.0 + 12.0))
.appear(.default())
.disappear(.default())
)
}
} else if let translationPlaceholder = maybeTranslationPlaceholder {
context.add(translationPlaceholder
.position(CGPoint(x: textBackgroundOrigin.x + textSideInset + translationPlaceholder.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationPlaceholder.size.height / 2.0 + 4.0))
)
}
let buttonsSpacing: CGFloat = 20.0
let smallSectionSpacing: CGFloat = 20.0
var buttonsHeight: CGFloat = 0.0
let component = context.component
if component.copyTranslation != nil {
let copyButton = copyButton.update(
component: TranslateButtonComponent(
theme: theme,
title: strings.Translate_CopyTranslation,
icon: "Chat/Context Menu/Copy",
isEnabled: state.translatedText != nil,
action: { [weak component] in
component?.copyTranslation?(state.translatedText ?? "")
}
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: itemHeight),
transition: context.transition
)
context.add(copyButton
.position(CGPoint(x: context.availableSize.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationTextHeight + itemSpacing + buttonsSpacing + copyButton.size.height / 2.0))
)
buttonsHeight += copyButton.size.height + smallSectionSpacing
}
let changeLanguageButton = changeLanguageButton.update(
component: TranslateButtonComponent(
theme: theme,
title: strings.Translate_ChangeLanguage,
icon: "Chat/Context Menu/Translate",
isEnabled: true,
action: { [weak component] in
component?.changeLanguage(state.fromLanguage ?? "", state.toLanguage, { fromLang, toLang in
state.changeLanguage(fromLanguage: fromLang, toLanguage: toLang)
})
}
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: itemHeight),
transition: context.transition
)
context.add(changeLanguageButton
.position(CGPoint(x: context.availableSize.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationTextHeight + itemSpacing + buttonsSpacing + buttonsHeight + changeLanguageButton.size.height / 2.0))
)
buttonsHeight += changeLanguageButton.size.height
let contentSize = CGSize(width: context.availableSize.width, height: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationTextHeight + itemSpacing + buttonsSpacing + buttonsHeight + environment.safeInsets.bottom + 44.0)
return contentSize
}
}
}
private final class TranslateSheetComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
private let context: AccountContext
private let text: String
private let entities: [MessageTextEntity]
private let fromLanguage: String?
private let toLanguage: String
private let copyTranslation: ((String) -> Void)?
private let changeLanguage: (String, String, @escaping (String, String) -> Void) -> Void
init(
context: AccountContext,
text: String,
entities: [MessageTextEntity],
fromLanguage: String?,
toLanguage: String,
copyTranslation: ((String) -> Void)?,
changeLanguage: @escaping (String, String, @escaping (String, String) -> Void) -> Void
) {
self.context = context
self.text = text
self.entities = entities
self.fromLanguage = fromLanguage
self.toLanguage = toLanguage
self.copyTranslation = copyTranslation
self.changeLanguage = changeLanguage
}
static func ==(lhs: TranslateSheetComponent, rhs: TranslateSheetComponent) -> Bool {
return true
}
static var body: Body {
let sheet = Child(ResizableSheetComponent<(EnvironmentType)>.self)
let animateOut = StoredActionSlot(Action<Void>.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let controller = environment.controller
let dismiss: (Bool) -> Void = { animated in
if animated {
animateOut.invoke(Action { _ in
if let controller = controller() {
controller.dismiss(completion: nil)
}
})
} else {
if let controller = controller() {
controller.dismiss(completion: nil)
}
}
}
let theme = environment.theme.withModalBlocksBackground()
let sheet = sheet.update(
component: ResizableSheetComponent<EnvironmentType>(
content: AnyComponent<EnvironmentType>(SheetContent(
context: context.component.context,
text: context.component.text,
entities: context.component.entities,
fromLanguage: context.component.fromLanguage,
toLanguage: context.component.toLanguage,
copyTranslation: context.component.copyTranslation,
changeLanguage: context.component.changeLanguage,
expand: {}
)),
titleItem: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: environment.strings.Translate_Title, font: Font.semibold(17.0), textColor: theme.list.itemPrimaryTextColor)))
),
leftItem: AnyComponent(
GlassBarButtonComponent(
size: CGSize(width: 44.0, height: 44.0),
backgroundColor: nil,
isDark: theme.overallDarkAppearance,
state: .glass,
component: AnyComponentWithIdentity(id: "close", component: AnyComponent(
BundleIconComponent(
name: "Navigation/Close",
tintColor: theme.chat.inputPanel.panelControlColor
)
)),
action: { _ in
dismiss(true)
}
)
),
bottomItem: nil,
backgroundColor: .color(theme.list.modalBlocksBackgroundColor),
animateOut: animateOut
),
environment: {
environment
ResizableSheetComponentEnvironment(
theme: theme,
statusBarHeight: environment.statusBarHeight,
safeInsets: environment.safeInsets,
metrics: environment.metrics,
deviceMetrics: environment.deviceMetrics,
isDisplaying: environment.value.isVisible,
isCentered: environment.metrics.widthClass == .regular,
screenSize: context.availableSize,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { animated in
dismiss(animated)
}
)
},
availableSize: context.availableSize,
transition: context.transition
)
context.add(sheet
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
)
return context.availableSize
}
}
}
public final class TranslateScreen: ViewControllerComponentContainer {
private let context: AccountContext
public var pushController: (ViewController) -> Void = { _ in }
public var presentController: (ViewController) -> Void = { _ in }
public init(
context: AccountContext,
forceTheme: PresentationTheme? = nil,
text: String,
entities: [MessageTextEntity] = [],
canCopy: Bool,
fromLanguage: String?,
toLanguage: String? = nil,
ignoredLanguages: [String]? = nil
) {
self.context = context
let theme: ViewControllerComponentContainer.Theme
if let forceTheme {
theme = .custom(forceTheme)
} else {
theme = .default
}
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
var baseLanguageCode = presentationData.strings.baseLanguageCode
let rawSuffix = "-raw"
if baseLanguageCode.hasSuffix(rawSuffix) {
baseLanguageCode = String(baseLanguageCode.dropLast(rawSuffix.count))
}
let dontTranslateLanguages = effectiveIgnoredTranslationLanguages(context: context, ignoredLanguages: ignoredLanguages)
var toLanguage = toLanguage ?? baseLanguageCode
if toLanguage == fromLanguage {
if fromLanguage == "en" {
toLanguage = dontTranslateLanguages.first(where: { $0 != "en" }) ?? "en"
} else {
toLanguage = "en"
}
}
toLanguage = normalizeTranslationLanguage(toLanguage)
var copyTranslationImpl: ((String) -> Void)?
var changeLanguageImpl: ((String, String, @escaping (String, String) -> Void) -> Void)?
super.init(
context: context,
component: TranslateSheetComponent(
context: context,
text: text,
entities: entities,
fromLanguage: fromLanguage,
toLanguage: toLanguage,
copyTranslation: !canCopy ? nil : { text in
copyTranslationImpl?(text)
},
changeLanguage: { fromLang, toLang, completion in
changeLanguageImpl?(fromLang, toLang, completion)
}
),
navigationBarAppearance: .none,
statusBarStyle: .ignore,
theme: theme
)
self.statusBar.statusBarStyle = .Ignore
self.navigationPresentation = .flatModal
self.blocksBackgroundWhenInOverlay = true
self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
copyTranslationImpl = { [weak self] text in
UIPasteboard.general.string = text
let content = UndoOverlayContent.copy(text: presentationData.strings.Conversation_TextCopied)
self?.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root))
self?.dismiss(animated: true, completion: nil)
}
changeLanguageImpl = { [weak self] fromLang, toLang, completion in
let pushController = self?.pushController
let presentController = self?.presentController
let controller = languageSelectionController(context: context, forceTheme: forceTheme, fromLanguage: fromLang, toLanguage: toLang, completion: { fromLang, toLang in
let controller = TranslateScreen(context: context, forceTheme: forceTheme, text: text, canCopy: canCopy, fromLanguage: fromLang, toLanguage: toLang, ignoredLanguages: ignoredLanguages)
controller.pushController = pushController ?? { _ in }
controller.presentController = presentController ?? { _ in }
presentController?(controller)
})
self?.dismissAnimated()
pushController?(controller)
}
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func dismissAnimated() {
if let view = self.node.hostView.findTaggedView(tag: ResizableSheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? ResizableSheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
}
}
}
public func presentTranslateScreen(
context: AccountContext,
text: String,
entities: [MessageTextEntity] = [],
canCopy: Bool,
fromLanguage: String?,
toLanguage: String? = nil,
isExpanded: Bool = false,
ignoredLanguages: [String]? = nil,
pushController: @escaping (ViewController) -> Void = { _ in },
presentController: @escaping (ViewController) -> Void = { _ in },
wasDismissed: (() -> Void)? = nil,
display: (ViewController) -> Void
) {
let translationConfiguration = TranslationConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
var useSystemTranslation = SGSimpleSettings.shared.translationBackendEnum == .system
switch translationConfiguration.manual {
case .system:
if #available(iOS 18.0, *) {
useSystemTranslation = true
}
default:
break
}
if useSystemTranslation {
presentSystemTranslateScreen(context: context, text: text)
} else {
let controller = TranslateScreen(context: context, text: text, canCopy: canCopy, fromLanguage: fromLanguage, toLanguage: toLanguage, ignoredLanguages: ignoredLanguages)
controller.pushController = pushController
controller.presentController = presentController
controller.wasDismissed = wasDismissed
display(controller)
}
}
private func presentSystemTranslateScreen(context: AccountContext, text: String) {
if #available(iOS 18.0, *), let rootViewController = context.sharedContext.mainWindow?.viewController?.view.window?.rootViewController {
var dismissImpl: (() -> Void)?
let pickerView = TranslateScreenHostingView(text: text, completionHandler: { [weak rootViewController] in
DispatchQueue.main.async(execute: {
guard let presentedController = rootViewController?.presentedViewController, presentedController.isBeingDismissed == false else { return }
dismissImpl?()
})
})
let hostingController = UIHostingController(rootView: pickerView)
hostingController.view.isHidden = true
hostingController.modalPresentationStyle = .overCurrentContext
rootViewController.present(hostingController, animated: true)
dismissImpl = { [weak hostingController] in
Queue.mainQueue().after(0.4, {
hostingController?.dismiss(animated: false)
})
}
}
}
@available(iOS 18.0, *)
struct TranslateScreenHostingView: View {
@State var presented = true
var text: String
var handler: () -> Void
init(text: String, completionHandler: @escaping () -> Void) {
self.text = text
self.handler = completionHandler
}
var body: some View {
Spacer()
.translationPresentation(
isPresented: $presented,
text: text
)
.onChange(of: presented) { newValue in
if newValue == false {
handler()
}
}
}
}