Skip to content

Commit aaae7e9

Browse files
authored
Translate outgoing messages when live chat translation is enabled (#97)
* fix(ChatUI, ChatHistoryModel): translate outgoing messages when live chat translation is enabled * fix(ChatHistoryModel): skip outgoing translations already in target language * fix(ChatHistoryModel): skip own translations for unrecognized targets * fix(ChatHistoryModel): preserve reply translation and prepare pinned eligibility * fix(ChatUI): retain pinned translations across metadata refreshes * fix(ChatUI): avoid pin-switch animation on same-message updates * test(ChatUI): remove extracted pinned translation harness
1 parent 9b02db3 commit aaae7e9

7 files changed

Lines changed: 703 additions & 75 deletions

File tree

‎packages/ChatHistoryModel/Sources/ChatHistoryModel/ChatHistoryEntry.swift‎

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -899,9 +899,6 @@ public func messageEntries(_ messagesEntries: [MessageHistoryEntry], location: C
899899
}
900900
let emptyDetailsExpanded: Set<Int> = []
901901

902-
let translateCanTranslate = translate?.canTranslate ?? false
903-
let translateTranslate = translate?.translate ?? false
904-
let translateTo = translate?.to
905902
let canHighlightLinksDefault: Bool = {
906903
if peer?.isUser == false {
907904
return true
@@ -1228,25 +1225,10 @@ public func messageEntries(_ messagesEntries: [MessageHistoryEntry], location: C
12281225

12291226
var messageTranslate: ChatLiveTranslateContext.State.Result?
12301227
var replyTranslate: ChatLiveTranslateContext.State.Result?
1231-
let canDisplayTranslation = accountPeerId.flatMap { message.effectivelyIncoming($0) } ?? true
1232-
if let translate = translate, let translateTo = translateTo {
1233-
if canDisplayTranslation {
1234-
messageTranslate = translate.result[.Key(id: message.id, toLang: translateTo)]
1235-
}
1228+
if let translate = translate {
1229+
messageTranslate = translate.translationResult(for: message, accountPeerId: accountPeerId)
12361230
if let reply = message.replyAttribute, let replyMessage = message.associatedMessages[reply.messageId] {
1237-
replyTranslate = translate.result[.init(id: replyMessage.id, toLang: translateTo)]
1238-
} else {
1239-
replyTranslate = nil
1240-
}
1241-
if translateCanTranslate, translateTranslate {
1242-
if canDisplayTranslation, let _ = message.translationAttribute(toLang: translateTo) {
1243-
messageTranslate = .complete(toLang: translateTo)
1244-
}
1245-
if let reply = message.replyAttribute, let replyMessage = message.associatedMessages[reply.messageId] {
1246-
if let _ = replyMessage.translationAttribute(toLang: translateTo) {
1247-
replyTranslate = .complete(toLang: translateTo)
1248-
}
1249-
}
1231+
replyTranslate = translate.replyTranslationResult(for: replyMessage)
12501232
}
12511233
}
12521234

@@ -1260,10 +1242,7 @@ public func messageEntries(_ messagesEntries: [MessageHistoryEntry], location: C
12601242
let entryQuoteRevealed = quoteRevealedByMessage[message.id] ?? emptyQuoteRevealed
12611243
let entryDetailsExpanded = detailsExpandedByMessage[message.id] ?? emptyDetailsExpanded
12621244

1263-
var transcriptionTranslate: ChatLiveTranslateContext.State.TranscriptionResult?
1264-
if let translate = translate, let translateTo = translateTo, translateCanTranslate, translateTranslate {
1265-
transcriptionTranslate = translate.transcriptionResult[.Key(id: message.id, toLang: translateTo)]
1266-
}
1245+
let transcriptionTranslate = translate?.transcriptionTranslationResult(for: message, accountPeerId: accountPeerId)
12671246

12681247
let summarize = translate?.summarizeResult[.Key(id: message.id)]
12691248

‎packages/ChatHistoryModel/Sources/ChatHistoryModel/ChatLiveTranslateContext.swift‎

Lines changed: 256 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import NaturalLanguage
23
import Postbox
34
import SwiftSignalKit
45
import TelegramCore
@@ -111,6 +112,196 @@ public func updateChatTranslationStateInteractively(engine: TelegramEngine, peer
111112

112113

113114

115+
private func chatTranslationLanguageCode(_ code: String) -> String {
116+
let components = code.replacingOccurrences(of: "_", with: "-").lowercased().components(separatedBy: "-")
117+
let language = components.first ?? code
118+
switch language {
119+
case "zh":
120+
if components.contains("hans") {
121+
return "zh-CN"
122+
}
123+
if components.contains("hant") || components.contains("tw") || components.contains("hk") || components.contains("mo") {
124+
return "zh-TW"
125+
}
126+
return "zh-CN"
127+
case "iw":
128+
return "he"
129+
default:
130+
return Translate.normalizeLanguageCode(language)
131+
}
132+
}
133+
134+
@available(macOS 10.14, *)
135+
private let chatTranslationRecognizableLanguages: Set<String> = {
136+
var languages: [NLLanguage] = [
137+
.amharic, .arabic, .armenian, .bengali, .bulgarian, .burmese,
138+
.catalan, .cherokee, .croatian, .czech, .danish, .dutch,
139+
.english, .finnish, .french, .georgian, .german, .greek,
140+
.gujarati, .hebrew, .hindi, .hungarian, .icelandic, .indonesian,
141+
.italian, .japanese, .kannada, .khmer, .korean, .lao,
142+
.malay, .malayalam, .marathi, .mongolian, .norwegian, .oriya,
143+
.persian, .polish, .portuguese, .punjabi, .romanian, .russian,
144+
.simplifiedChinese, .sinhalese, .slovak, .spanish, .swedish,
145+
.tamil, .telugu, .thai, .tibetan, .traditionalChinese, .turkish,
146+
.ukrainian, .urdu, .vietnamese
147+
]
148+
if #available(macOS 13.0, *) {
149+
languages.append(.kazakh)
150+
}
151+
return Set(languages.map { chatTranslationLanguageCode($0.rawValue) })
152+
}()
153+
154+
private func chatTranslationNeedsTranslation(_ texts: [String], toLang: String) -> Bool {
155+
let target = chatTranslationLanguageCode(toLang)
156+
guard #available(macOS 10.14, *) else {
157+
return false
158+
}
159+
guard chatTranslationRecognizableLanguages.contains(target) else {
160+
return false
161+
}
162+
let recognizer = NLLanguageRecognizer()
163+
for text in texts {
164+
var needsTranslation = false
165+
text.enumerateSubstrings(in: text.startIndex ..< text.endIndex, options: .bySentences) { sentence, _, _, stop in
166+
var remaining = (sentence ?? "")[...]
167+
while !remaining.isEmpty {
168+
let sample = String(remaining.prefix(256))
169+
remaining = remaining.dropFirst(sample.count)
170+
recognizer.processString(sample)
171+
let language = recognizer.languageHypotheses(withMaximum: 1).first
172+
recognizer.reset()
173+
if let language = language, language.value >= 0.9, chatTranslationLanguageCode(language.key.rawValue) != target {
174+
needsTranslation = true
175+
stop = true
176+
break
177+
}
178+
}
179+
}
180+
if needsTranslation {
181+
return true
182+
}
183+
}
184+
return false
185+
}
186+
187+
private func chatTranslationTexts(_ blocks: [InstantPageBlock]) -> [String] {
188+
var texts: [String] = []
189+
for block in blocks {
190+
switch block {
191+
case let .title(text), let .subtitle(text), let .header(text), let .subheader(text),
192+
let .paragraph(text), let .footer(text), let .kicker(text), let .thinking(text),
193+
let .heading(text, _), let .authorDate(text, _), let .preformatted(text, _):
194+
texts.append(text.plainText)
195+
case let .list(items, _):
196+
for item in items {
197+
switch item {
198+
case let .text(text, _, _):
199+
texts.append(text.plainText)
200+
case let .blocks(blocks, _, _):
201+
texts.append(contentsOf: chatTranslationTexts(blocks))
202+
case .unknown:
203+
break
204+
}
205+
}
206+
case let .blockQuote(blocks, caption, _), let .details(caption, blocks, _):
207+
texts.append(caption.plainText)
208+
texts.append(contentsOf: chatTranslationTexts(blocks))
209+
case let .pullQuote(text, caption):
210+
texts.append(contentsOf: [text.plainText, caption.plainText])
211+
case let .cover(block):
212+
texts.append(contentsOf: chatTranslationTexts([block]))
213+
case let .table(title, rows, _, _, _):
214+
texts.append(title.plainText)
215+
for row in rows {
216+
texts.append(contentsOf: row.cells.compactMap { $0.text?.plainText })
217+
}
218+
case let .postEmbed(_, _, _, _, _, blocks, caption), let .collage(blocks, caption), let .slideshow(blocks, caption):
219+
texts.append(contentsOf: chatTranslationTexts(blocks))
220+
texts.append(contentsOf: [caption.text.plainText, caption.credit.plainText])
221+
case let .image(_, caption, _, _, _), let .video(_, caption, _, _, _),
222+
let .audio(_, caption), let .document(_, caption), let .webEmbed(_, _, _, caption, _, _, _),
223+
let .map(_, _, _, _, caption):
224+
texts.append(contentsOf: [caption.text.plainText, caption.credit.plainText])
225+
case let .buttonRow(_, buttons):
226+
texts.append(contentsOf: buttons.map { $0.text.plainText })
227+
case let .relatedArticles(title, _):
228+
texts.append(title.plainText)
229+
default:
230+
break
231+
}
232+
}
233+
return texts
234+
}
235+
236+
public func canTranslateChatMessage(_ message: Message, accountPeerId: PeerId?, toLang: String, transcription: Bool = false) -> Bool {
237+
guard let accountPeerId = accountPeerId, message.author?.id == accountPeerId || !message.effectivelyIncoming(accountPeerId) else {
238+
return true
239+
}
240+
if transcription {
241+
guard let transcription = message.audioTranscription, !transcription.isPending else {
242+
return false
243+
}
244+
return chatTranslationNeedsTranslation([transcription.text], toLang: toLang)
245+
}
246+
var texts = [message.text]
247+
if let rich = message.attributes.compactMap({ $0 as? RichTextMessageAttribute }).first {
248+
texts.append(contentsOf: chatTranslationTexts((rich.fullInstantPage ?? rich.instantPage).blocks))
249+
}
250+
if let poll = message.media.first as? TelegramMediaPoll {
251+
texts.append(poll.text)
252+
texts.append(contentsOf: poll.options.map { $0.text })
253+
if let solution = poll.results.solution {
254+
texts.append(solution.text)
255+
}
256+
}
257+
return chatTranslationNeedsTranslation(texts, toLang: toLang)
258+
}
259+
260+
public struct ChatTranslationSource: Equatable {
261+
private let text: String
262+
private let entities: [MessageTextEntity]
263+
private let richText: [InstantPageBlock]?
264+
private let pollTexts: [String]
265+
266+
public init(_ message: Message) {
267+
self.text = message.text
268+
self.entities = message.textEntitiesAttribute?.entities ?? []
269+
let richText = message.attributes.compactMap { $0 as? RichTextMessageAttribute }.first
270+
self.richText = richText.map { ($0.fullInstantPage ?? $0.instantPage).blocks }
271+
if let poll = message.media.first as? TelegramMediaPoll {
272+
self.pollTexts = [poll.text] + poll.options.map { $0.text } + (poll.results.solution.map { [$0.text] } ?? [])
273+
} else {
274+
self.pollTexts = []
275+
}
276+
}
277+
}
278+
279+
struct ChatTranslationMessages {
280+
let messages: [Message]
281+
let sources: [ChatLiveTranslateContext.State.Key: ChatTranslationSource]
282+
let eligibility: [ChatLiveTranslateContext.State.Key: ChatTranslationSource]
283+
284+
init(_ messages: [Message], replies: [Message], accountPeerId: PeerId?, toLang: String) {
285+
let eligible = messages.filter { canTranslateChatMessage($0, accountPeerId: accountPeerId, toLang: toLang) }
286+
var ids = Set<MessageId>()
287+
self.messages = (eligible + replies).filter { ids.insert($0.id).inserted }.sorted(by: { $0.id < $1.id })
288+
var sources: [ChatLiveTranslateContext.State.Key: ChatTranslationSource] = [:]
289+
for message in messages + replies {
290+
let key = ChatLiveTranslateContext.State.Key(id: message.id, toLang: toLang)
291+
if sources[key] == nil {
292+
sources[key] = ChatTranslationSource(message)
293+
}
294+
}
295+
self.sources = sources
296+
var eligibility: [ChatLiveTranslateContext.State.Key: ChatTranslationSource] = [:]
297+
for message in eligible {
298+
let key = ChatLiveTranslateContext.State.Key(id: message.id, toLang: toLang)
299+
eligibility[key] = sources[key]
300+
}
301+
self.eligibility = eligibility
302+
}
303+
}
304+
114305
public final class ChatLiveTranslateContext {
115306

116307
public struct State : Equatable {
@@ -209,13 +400,60 @@ public final class ChatLiveTranslateContext {
209400
}
210401

211402
public var result:[Key : Result]
403+
public var translationEligibility: [Key: ChatTranslationSource] = [:]
212404
public var transcriptionResult:[TranscriptionKey : TranscriptionResult]
213405
public var summarizeResult:[SummarizeKey : SummarizeResult]
214406

215407
public static var `default`: State {
216408
return .init(canTranslate: false, translate: false, autotranslate: false, from: "", to: "", paywall: false, tone: .neutral, result: [:], transcriptionResult: [:], summarizeResult: [:])
217409
}
218410

411+
public func translationResult(for message: Message, accountPeerId: PeerId?) -> Result? {
412+
guard canTranslate, translate, canTranslateChatMessage(message, accountPeerId: accountPeerId, toLang: to) else {
413+
return nil
414+
}
415+
if message.hasTranslationAttribute(toLang: to) {
416+
return .complete(toLang: to)
417+
}
418+
return result[.Key(id: message.id, toLang: to)]
419+
}
420+
421+
public func replyTranslationResult(for message: Message) -> Result? {
422+
if canTranslate, translate, message.hasTranslationAttribute(toLang: to) {
423+
return .complete(toLang: to)
424+
}
425+
return result[.Key(id: message.id, toLang: to)]
426+
}
427+
428+
mutating func updateTranslation(_ batch: ChatTranslationMessages, toLang: String) {
429+
guard canTranslate, translate, to == toLang else {
430+
return
431+
}
432+
for (key, source) in batch.sources {
433+
if let previous = translationEligibility[key], previous != source {
434+
result.removeValue(forKey: key)
435+
queued.removeAll(where: { $0.id == key.id })
436+
}
437+
}
438+
for message in batch.messages {
439+
let key = Key(id: message.id, toLang: toLang)
440+
if message.hasTranslationAttribute(toLang: toLang) {
441+
result[key] = .complete(toLang: toLang)
442+
} else if result[key] == .complete(toLang: toLang) {
443+
result.removeValue(forKey: key)
444+
queued.removeAll(where: { $0.id == message.id })
445+
}
446+
}
447+
translationEligibility = batch.eligibility
448+
}
449+
450+
public func transcriptionTranslationResult(for message: Message, accountPeerId: PeerId?) -> TranscriptionResult? {
451+
guard canTranslate, translate, canTranslateChatMessage(message, accountPeerId: accountPeerId, toLang: to, transcription: true) else {
452+
return nil
453+
}
454+
return transcriptionResult[.Key(id: message.id, toLang: to)]
455+
}
456+
219457
public struct Transcription : Equatable {
220458
public var messageId: MessageId
221459
public var string: String
@@ -347,6 +585,10 @@ public final class ChatLiveTranslateContext {
347585
current.paywall = isPremium ? false : (state?.paywall ?? false)
348586
if !current.canTranslate || !current.translate || toUpdated {
349587
current.result = [:]
588+
current.translationEligibility = [:]
589+
current.queued = []
590+
current.queuedTranscriptions = []
591+
self?.holder.removeAll()
350592
//current.transcriptionResult = [:]
351593
}
352594
return current
@@ -545,9 +787,9 @@ public final class ChatLiveTranslateContext {
545787
return statePromise.get()
546788
}
547789

548-
private var holder:[MessageId] = []
790+
private var holder: [MessageId: ChatTranslationSource] = [:]
549791

550-
public func translate(_ message: [Message]) {
792+
public func translate(_ message: [Message], replies: [Message] = []) {
551793
prepareQueue.justDispatch { [weak self] in
552794
guard let `self` = self else {
553795
return
@@ -557,29 +799,17 @@ public final class ChatLiveTranslateContext {
557799
if !isEnabled {
558800
return
559801
}
560-
let msgs = message.filter { !$0.hasTranslationAttribute(toLang: toLang) }.map { $0 }
561-
let translated = message.filter { $0.hasTranslationAttribute(toLang: toLang) }.map { $0 }
562-
563-
if !translated.isEmpty {
564-
self.updateState { current in
565-
var current = current
566-
if current.translate {
567-
for msg in translated {
568-
current.result[.Key(id: msg.id, toLang: toLang)] = .complete(toLang: toLang)
569-
}
570-
for msg in msgs {
571-
if !translated.contains(msg), case .complete(toLang: toLang) = current.result[.Key(id: msg.id, toLang: toLang)] {
572-
current.result.removeValue(forKey: .Key(id: msg.id, toLang: toLang))
573-
}
574-
}
575-
}
576-
return current
577-
}
578-
802+
let batch = ChatTranslationMessages(message, replies: replies, accountPeerId: self.context.peerId, toLang: toLang)
803+
let msgs = batch.messages.filter { !$0.hasTranslationAttribute(toLang: toLang) }
804+
805+
self.updateState { current in
806+
var current = current
807+
current.updateTranslation(batch, toLang: toLang)
808+
return current
579809
}
580-
let ids = msgs.map ({ $0.id })
581-
if self.holder != ids {
582-
self.holder = ids
810+
let sources = Dictionary(uniqueKeysWithValues: msgs.map { ($0.id, ChatTranslationSource($0)) })
811+
if self.holder != sources {
812+
self.holder = sources
583813
let signal = Signal<Void, NoError>.complete() |> delay(0.01, queue: prepareQueue)
584814

585815
self.holderDisposable.set(signal.start(completed: { [weak self] in
@@ -589,7 +819,7 @@ public final class ChatLiveTranslateContext {
589819
// self.holder.removeAll()
590820
self.updateState { current in
591821
var current = current
592-
if current.translate {
822+
if current.canTranslate, current.translate, current.to == toLang {
593823
for msg in msgs {
594824
if current.result[.Key(id: msg.id, toLang: current.to)] == nil {
595825
current.queued.append(msg)
@@ -640,7 +870,7 @@ public final class ChatLiveTranslateContext {
640870
let toLang = self.stateValue.with { $0.to }
641871

642872
let messagesToQueue = messages.compactMap { message -> State.Transcription? in
643-
guard let transcription = message.audioTranscription, !transcription.text.isEmpty else {
873+
guard canTranslateChatMessage(message, accountPeerId: self.context.peerId, toLang: toLang, transcription: true), let transcription = message.audioTranscription, !transcription.text.isEmpty else {
644874
return nil
645875
}
646876
let existingResult = self.stateValue.with { $0.transcriptionResult[.Key(id: message.id, toLang: toLang)] }

0 commit comments

Comments
 (0)