damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit e5c0400b544ee9bf20829d682344f80888427dc5
parent c6c47e824a3b6b247c698a79c14a272991493f07
Author: Terry Yiu <963907+tyiu@users.noreply.github.com>
Date:   Mon, 27 Mar 2023 10:43:17 -0400

Merge remote-tracking branch 'terry/tyiu/filter-language'

Changelog-Added: Auto Translation

Diffstat:
Mdamus/Components/TranslateView.swift | 37++++++++++++++++---------------------
Mdamus/Models/UserSettingsStore.swift | 14++++++++++++++
Mdamus/Nostr/NostrEvent.swift | 20++++++++++++++++++++
Mdamus/Util/LocalizationUtil.swift | 11+++++++++++
Mdamus/Views/ConfigView.swift | 8++++++++
Mdamus/Views/NoteContentView.swift | 24+++++++++++++-----------
Mdamus/Views/SearchHomeView.swift | 30+++++++++++++++++++++++++-----
7 files changed, 107 insertions(+), 37 deletions(-)

diff --git a/damus/Components/TranslateView.swift b/damus/Components/TranslateView.swift @@ -18,6 +18,8 @@ struct TranslateView: View { @State var translated_note: String? = nil @State var show_translated_note: Bool = false @State var translated_artifacts: NoteArtifacts? = nil + + let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) }) var TranslateButton: some View { Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) { @@ -80,24 +82,7 @@ struct TranslateView: View { currentLanguage = Locale.current.languageCode ?? "en" } - // Rely on Apple's NLLanguageRecognizer to tell us which language it thinks the note is in - // and filter on only the text portions of the content as URLs and hashtags confuse the language recognizer. - let originalBlocks = event.blocks(damus_state.keypair.privkey) - let originalOnlyText = originalBlocks.compactMap { $0.is_text }.joined(separator: " ") - - // Only accept language recognition hypothesis if there's at least a 50% probability that it's accurate. - let languageRecognizer = NLLanguageRecognizer() - languageRecognizer.processString(originalOnlyText) - noteLanguage = languageRecognizer.languageHypotheses(withMaximum: 1).first(where: { $0.value >= 0.5 })?.key.rawValue ?? currentLanguage - - if let lang = noteLanguage, noteLanguage != currentLanguage { - // If the detected dominant language is a variant, remove the variant component and just take the language part as translation services typically only supports the variant-less language. - if #available(iOS 16, *) { - noteLanguage = Locale.LanguageCode(stringLiteral: lang).identifier(.alpha2) - } else { - noteLanguage = NSLocale(localeIdentifier: lang).languageCode - } - } + noteLanguage = event.note_language(damus_state.keypair.privkey) ?? currentLanguage guard let note_lang = noteLanguage else { noteLanguage = currentLanguage @@ -106,9 +91,9 @@ struct TranslateView: View { return } - if note_lang != currentLanguage { + if !preferredLanguages.contains(note_lang) { do { - // If the note language is different from our language, send a translation request. + // If the note language is different from our preferred languages, send a translation request. let translator = Translator(damus_state.settings) let originalContent = event.get_content(damus_state.keypair.privkey) translated_note = try await translator.translate(originalContent, from: note_lang, to: currentLanguage) @@ -132,11 +117,21 @@ struct TranslateView: View { } checkingTranslationStatus = false - + + show_translated_note = damus_state.settings.auto_translate } } } +extension View { + func translate_button_style() -> some View { + return self + .font(.footnote) + .contentShape(Rectangle()) + .padding([.top, .bottom], 10) + } +} + struct TranslateView_Previews: PreviewProvider { static var previews: some View { let ds = test_damus_state() diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift @@ -128,6 +128,18 @@ class UserSettingsStore: ObservableObject { } } + @Published var auto_translate: Bool { + didSet { + UserDefaults.standard.set(auto_translate, forKey: "auto_translate") + } + } + + @Published var show_only_preferred_languages: Bool { + didSet { + UserDefaults.standard.set(show_only_preferred_languages, forKey: "show_only_preferred_languages") + } + } + @Published var translation_service: TranslationService { didSet { UserDefaults.standard.set(translation_service.rawValue, forKey: "translation_service") @@ -210,6 +222,8 @@ class UserSettingsStore: ObservableObject { left_handed = UserDefaults.standard.object(forKey: "left_handed") as? Bool ?? false zap_vibration = UserDefaults.standard.object(forKey: "zap_vibration") as? Bool ?? false disable_animation = should_disable_image_animation() + auto_translate = UserDefaults.standard.object(forKey: "auto_translate") as? Bool ?? false + show_only_preferred_languages = UserDefaults.standard.object(forKey: "show_only_preferred_languages") as? Bool ?? false // Note from @tyiu: // Default translation service is disabled by default for now until we gain some confidence that it is working well in production. diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift @@ -10,6 +10,7 @@ import CommonCrypto import secp256k1 import secp256k1_implementation import CryptoKit +import NaturalLanguage @@ -259,6 +260,25 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has return event_is_reply(self, privkey: privkey) } + func note_language(_ privkey: String?) -> String? { + // Rely on Apple's NLLanguageRecognizer to tell us which language it thinks the note is in + // and filter on only the text portions of the content as URLs and hashtags confuse the language recognizer. + let originalBlocks = blocks(privkey) + let originalOnlyText = originalBlocks.compactMap { $0.is_text }.joined(separator: " ") + + // Only accept language recognition hypothesis if there's at least a 50% probability that it's accurate. + let languageRecognizer = NLLanguageRecognizer() + languageRecognizer.processString(originalOnlyText) + + guard let locale = languageRecognizer.languageHypotheses(withMaximum: 1).first(where: { $0.value >= 0.5 })?.key.rawValue else { + return nil + } + + // Remove the variant component and just take the language part as translation services typically only supports the variant-less language. + // Moreover, speakers of one variant can generally understand other variants. + return localeToLanguage(locale) + } + public var referenced_ids: [ReferencedId] { return get_referenced_ids(key: "e") } diff --git a/damus/Util/LocalizationUtil.swift b/damus/Util/LocalizationUtil.swift @@ -21,3 +21,14 @@ func localizedStringFormat(key: String, locale: Locale?) -> String { let fallback = bundleForLocale(locale: Locale(identifier: "en-US")).localizedString(forKey: key, value: nil, table: nil) return bundle.localizedString(forKey: key, value: fallback, table: nil) } + +/** + Removes the variant part of a locale code so that it contains only the language code. + */ +func localeToLanguage(_ locale: String) -> String? { + if #available(iOS 16, *) { + return Locale.LanguageCode(stringLiteral: locale).identifier(.alpha2) + } else { + return NSLocale(localeIdentifier: locale).languageCode + } +} diff --git a/damus/Views/ConfigView.swift b/damus/Views/ConfigView.swift @@ -153,6 +153,9 @@ struct ConfigView: View { } Section(NSLocalizedString("Translations", comment: "Section title for selecting the translation service.")) { + Toggle(NSLocalizedString("Show only preferred languages on Universe feed", comment: "Toggle to show notes that are only in the device's preferred languages on the Universe feed and hide notes that are in other languages."), isOn: $settings.show_only_preferred_languages) + .toggleStyle(.switch) + Picker(NSLocalizedString("Service", comment: "Prompt selection of translation service provider."), selection: $settings.translation_service) { ForEach(TranslationService.allCases, id: \.self) { server in Text(server.model.displayName) @@ -197,6 +200,11 @@ struct ConfigView: View { Link(NSLocalizedString("Get API Key", comment: "Button to navigate to DeepL website to get a translation API key."), destination: URL(string: "https://www.deepl.com/pro-api")!) } } + + if settings.translation_service != .none { + Toggle(NSLocalizedString("Automatically translate notes", comment: "Toggle to automatically translate notes."), isOn: $settings.auto_translate) + .toggleStyle(.switch) + } } Section(NSLocalizedString("Miscellaneous", comment: "Section header for miscellaneous user configuration")) { diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift @@ -61,6 +61,10 @@ struct NoteContentView: View { var invoicesView: some View { InvoicesView(our_pubkey: damus_state.keypair.pubkey, invoices: artifacts.invoices) } + + var translateView: some View { + TranslateView(damus_state: damus_state, event: event) + } var previewView: some View { Group { @@ -82,7 +86,6 @@ struct NoteContentView: View { VStack(alignment: .leading) { if size == .selected { SelectableText(attributedString: artifacts.content) - TranslateView(damus_state: damus_state, event: event) } else { if with_padding { truncatedText @@ -92,6 +95,15 @@ struct NoteContentView: View { } } + if size == .selected || damus_state.settings.auto_translate { + if with_padding { + translateView + .padding(.horizontal) + } else { + translateView + } + } + if show_images && artifacts.images.count > 0 { ImageCarousel(urls: artifacts.images) } else if !show_images && artifacts.images.count > 0 { @@ -217,16 +229,6 @@ struct NoteContentView_Previews: PreviewProvider { } } - -extension View { - func translate_button_style() -> some View { - return self - .font(.footnote) - .contentShape(Rectangle()) - .padding([.top, .bottom], 10) - } -} - struct NoteArtifacts { let content: AttributedString let images: [URL] diff --git a/damus/Views/SearchHomeView.swift b/damus/Views/SearchHomeView.swift @@ -7,12 +7,15 @@ import SwiftUI import CryptoKit +import NaturalLanguage struct SearchHomeView: View { let damus_state: DamusState @StateObject var model: SearchHomeModel @State var search: String = "" @FocusState private var isFocused: Bool + + let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) }) var SearchInput: some View { HStack { @@ -41,12 +44,29 @@ struct SearchHomeView: View { } var GlobalContent: some View { - return TimelineView(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true, filter: { _ in true }) - .refreshable { - // Fetch new information by unsubscribing and resubscribing to the relay - model.unsubscribe() - model.subscribe() + return TimelineView( + events: model.events, + loading: $model.loading, + damus: damus_state, + show_friend_icon: true, + filter: { + if damus_state.settings.show_only_preferred_languages == false { + return true + } + + // If we can't determine the note's language with 50%+ confidence, lean on the side of caution and show it anyway. + guard let noteLanguage = $0.note_language(damus_state.keypair.privkey) else { + return true + } + + return preferredLanguages.contains(noteLanguage) } + ) + .refreshable { + // Fetch new information by unsubscribing and resubscribing to the relay + model.unsubscribe() + model.subscribe() + } } var SearchContent: some View {