Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
@@ -0,0 +1,366 @@
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 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) -> Signal<Never, NoError> {
return context.account.postbox.transaction { transaction -> Signal<Never, NoError> in
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)
}
}
}
}
guard 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)
}
} else if let _ = message.media.first(where: { $0 is TelegramMediaPoll }) {
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
}
return context.engine.messages.translateMessages(messageIds: messageIdsToTranslate, fromLang: fromLang, toLang: toLang, enableLocalIfPossible: enableLocalIfPossible)
|> `catch` { _ -> Signal<Never, NoError> in
return .complete()
}
} |> switchToLatest
}
public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64?) -> 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 {
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) {
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 {
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) {
return state
} else {
return nil
}
}
)
}
}
}
} else {
return .single(nil)
}
}
@@ -0,0 +1,197 @@
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(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
}
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: .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,527 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import ActivityIndicator
import ChatListSearchItemNode
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, dynamicBounce: 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)
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
}
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
}
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,183 @@
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
context.add(title
.position(CGPoint(x: sideInset + title.size.width / 2.0, y: context.availableSize.height / 2.0))
)
context.add(icon
.position(CGPoint(x: context.availableSize.width - 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 = 10.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)
}
}
File diff suppressed because it is too large Load Diff