From 9a547077c1beff72dd09852f5536b4c617ab1b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Sat, 30 Dec 2023 04:31:32 +0000 Subject: [PATCH] Hook up Damus Purple translation service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit integrates the Damus Purple translation service: - Automatically handles translation settings change after purchase - Asks for permission to override translation settings if the user already has translation setup - Translation settings can be changed with Damus Purple, if desired - Translation requests working with the Damus API server Testing -------- PASS Device: iPhone 15 simulator iOS: 17.2 Damus: This commit Damus Purple API server: `9397201d7d55ddcec4c18fcd337f759b61dce697` running on Ubuntu 22.04 LTS VM (npm run dev) iOS setting: English set as the only preferred language. Steps: 1. Enable Damus Purple feature flag on developer settings, set purple localhost mode, and restart app 2. Set translation setting to something other than none (e.g. DeepL) 3. Simulate Damus Purple purchase 4. Check that when dismissing welcome view, a confirmation prompt will ask the user whether they want to switch translator to Damus Purple. PASS 5. Click "Yes". 6. Go to translation settings. Check that translation settings are set to "Purple". PASS 7. Go to a non-English profile. Check that translations appear with "Mock translation" (Which is the translation text provided by the mock translation server). PASS 8. Reinstall app 9. Repeat the test, but this time starting with no translation settings. Make sure that translation settings will automatically switch to Damus Purple. PASS Feature flag testing -------------------- PASS Preconditions: Same as above Steps: 1. Turn off translation 2. Turn off Damus Purple feature flag 3. Go to translation settings. Make sure that Damus Purple is not an option. PASS Closes: https://github.com/damus-io/damus/issues/1836 Signed-off-by: Daniel D’Aquino Reviewed-by: William Casarin Signed-off-by: William Casarin --- damus/Components/TranslateView.swift | 6 +-- damus/Models/Purple/DamusPurple.swift | 39 +++++++++++++++++ damus/Models/TranslationService.swift | 3 ++ damus/Models/UserSettingsStore.swift | 2 + damus/Util/EventCache.swift | 3 +- damus/Util/Router.swift | 2 +- damus/Util/Translator.swift | 10 ++++- damus/Views/Purple/DamusPurpleView.swift | 43 +++++++++++++++---- .../Settings/TranslationSettingsView.swift | 11 ++++- damus/Views/SideMenuView.swift | 2 +- 10 files changed, 103 insertions(+), 18 deletions(-) diff --git a/damus/Components/TranslateView.swift b/damus/Components/TranslateView.swift index c44c0f89..38085580 100644 --- a/damus/Components/TranslateView.swift +++ b/damus/Components/TranslateView.swift @@ -64,7 +64,7 @@ struct TranslateView: View { guard let note_language = translations_model.note_language else { return } - let res = await translate_note(profiles: damus_state.profiles, keypair: damus_state.keypair, event: event, settings: damus_state.settings, note_lang: note_language) + let res = await translate_note(profiles: damus_state.profiles, keypair: damus_state.keypair, event: event, settings: damus_state.settings, note_lang: note_language, purple: damus_state.purple) DispatchQueue.main.async { self.translations_model.state = res } @@ -125,10 +125,10 @@ struct TranslateView_Previews: PreviewProvider { } } -func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, settings: UserSettingsStore, note_lang: String) async -> TranslateStatus { +func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, settings: UserSettingsStore, note_lang: String, purple: DamusPurple) async -> TranslateStatus { // If the note language is different from our preferred languages, send a translation request. - let translator = Translator(settings) + let translator = Translator(settings, purple: purple) let originalContent = event.get_content(keypair) let translated_note = try? await translator.translate(originalContent, from: note_lang, to: current_language()) diff --git a/damus/Models/Purple/DamusPurple.swift b/damus/Models/Purple/DamusPurple.swift index ce84996c..93a25cea 100644 --- a/damus/Models/Purple/DamusPurple.swift +++ b/damus/Models/Purple/DamusPurple.swift @@ -126,6 +126,36 @@ class DamusPurple: StoreObserverDelegate { } } } + + func translate(text: String, source source_language: String, target target_language: String) async throws -> String { + var url = environment.get_base_url() + url.append(path: "/translate") + url.append(queryItems: [ + .init(name: "source", value: source_language), + .init(name: "target", value: target_language), + .init(name: "q", value: text) + ]) + let (data, response) = try await make_nip98_authenticated_request( + method: .get, + url: url, + payload: nil, + payload_type: nil, + auth_keypair: self.keypair + ) + + if let httpResponse = response as? HTTPURLResponse { + switch httpResponse.statusCode { + case 200: + return try JSONDecoder().decode(TranslationResult.self, from: data).text + default: + Log.error("Translation error with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") + throw PurpleError.translation_error(status_code: httpResponse.statusCode, response: data) + } + } + else { + throw PurpleError.translation_no_response + } + } } // MARK: API types @@ -155,4 +185,13 @@ extension DamusPurple { } } } + + enum PurpleError: Error { + case translation_error(status_code: Int, response: Data) + case translation_no_response + } + + struct TranslationResult: Codable { + let text: String + } } diff --git a/damus/Models/TranslationService.swift b/damus/Models/TranslationService.swift index 896540d2..f2aab409 100644 --- a/damus/Models/TranslationService.swift +++ b/damus/Models/TranslationService.swift @@ -29,6 +29,7 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable { } case none + case purple case libretranslate case deepl case nokyctranslate @@ -38,6 +39,8 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable { switch self { case .none: return .init(tag: self.rawValue, displayName: NSLocalizedString("none_translation_service", value: "None", comment: "Dropdown option for selecting no translation service.")) + case .purple: + return .init(tag: self.rawValue, displayName: NSLocalizedString("Damus Purple", comment: "Dropdown option for selecting Damus Purple as a translation service.")) case .libretranslate: return .init(tag: self.rawValue, displayName: NSLocalizedString("LibreTranslate (Open Source)", comment: "Dropdown option for selecting LibreTranslate as the translation service.")) case .deepl: diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift index 74a00e14..f4d1b259 100644 --- a/damus/Models/UserSettingsStore.swift +++ b/damus/Models/UserSettingsStore.swift @@ -296,6 +296,8 @@ class UserSettingsStore: ObservableObject { switch translation_service { case .none: return false + case .purple: + return true case .libretranslate: return URLComponents(string: libretranslate_url) != nil case .deepl: diff --git a/damus/Util/EventCache.swift b/damus/Util/EventCache.swift index 82bb6466..bc502ce5 100644 --- a/damus/Util/EventCache.swift +++ b/damus/Util/EventCache.swift @@ -306,7 +306,6 @@ func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSet } func should_preload_translation(event: NostrEvent, our_keypair: Keypair, current_status: TranslateStatus, settings: UserSettingsStore, note_lang: String?) -> Bool { - switch current_status { case .havent_tried: return should_translate(event: event, our_keypair: our_keypair, settings: settings, note_lang: note_lang) && settings.auto_translate @@ -445,7 +444,7 @@ func preload_event(plan: PreloadPlan, state: DamusState) async { // We have to recheck should_translate here now that we have note_language if plan.load_translations && should_translate(event: plan.event, our_keypair: our_keypair, settings: settings, note_lang: note_language) && settings.auto_translate { - translations = await translate_note(profiles: profiles, keypair: our_keypair, event: plan.event, settings: settings, note_lang: note_language) + translations = await translate_note(profiles: profiles, keypair: our_keypair, event: plan.event, settings: settings, note_lang: note_language, purple: state.purple) } let ts = translations diff --git a/damus/Util/Router.swift b/damus/Util/Router.swift index ac6731e7..292f90bd 100644 --- a/damus/Util/Router.swift +++ b/damus/Util/Router.swift @@ -81,7 +81,7 @@ enum Route: Hashable { case .ZapSettings(let settings): ZapSettingsView(settings: settings) case .TranslationSettings(let settings): - TranslationSettingsView(settings: settings) + TranslationSettingsView(settings: settings, damus_state: damusState) case .ReactionsSettings(let settings): ReactionsSettingsView(settings: settings) case .SearchSettings(let settings): diff --git a/damus/Util/Translator.swift b/damus/Util/Translator.swift index 3b734f48..1681e665 100644 --- a/damus/Util/Translator.swift +++ b/damus/Util/Translator.swift @@ -12,16 +12,20 @@ import FoundationNetworking public struct Translator { private let userSettingsStore: UserSettingsStore + private let purple: DamusPurple private let session = URLSession.shared private let encoder = JSONEncoder() private let decoder = JSONDecoder() - init(_ userSettingsStore: UserSettingsStore) { + init(_ userSettingsStore: UserSettingsStore, purple: DamusPurple) { self.userSettingsStore = userSettingsStore + self.purple = purple } public func translate(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String? { switch userSettingsStore.translation_service { + case .purple: + return try await translateWithPurple(text, from: sourceLanguage, to: targetLanguage) case .libretranslate: return try await translateWithLibreTranslate(text, from: sourceLanguage, to: targetLanguage) case .nokyctranslate: @@ -90,6 +94,10 @@ public struct Translator { return response.translations.map { $0.text }.joined(separator: " ") } + private func translateWithPurple(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String? { + return try await self.purple.translate(text: text, source: sourceLanguage, target: targetLanguage) + } + private func translateWithNoKYCTranslate(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String? { let url = try makeURL("https://translate.nokyctranslate.com", path: "/translate") diff --git a/damus/Views/Purple/DamusPurpleView.swift b/damus/Views/Purple/DamusPurpleView.swift index c524aaff..bd101c38 100644 --- a/damus/Views/Purple/DamusPurpleView.swift +++ b/damus/Views/Purple/DamusPurpleView.swift @@ -42,7 +42,7 @@ struct PurchasedProduct { } struct DamusPurpleView: View { - let purple_api: DamusPurple + let damus_state: DamusState let keypair: Keypair @State var products: ProductState @@ -50,13 +50,14 @@ struct DamusPurpleView: View { @State var selection: DamusPurpleType = .yearly @State var show_welcome_sheet: Bool = false @State var show_manage_subscriptions = false + @State var show_settings_change_confirmation_dialog = false @Environment(\.dismiss) var dismiss - init(purple: DamusPurple, keypair: Keypair) { + init(damus_state: DamusState) { self._products = State(wrappedValue: .loading) - self.purple_api = purple - self.keypair = keypair + self.damus_state = damus_state + self.keypair = damus_state.keypair } var body: some View { @@ -94,12 +95,38 @@ struct DamusPurpleView: View { await load_products() } .ignoresSafeArea(.all) - .sheet(isPresented: $show_welcome_sheet, content: { + .sheet(isPresented: $show_welcome_sheet, onDismiss: { + update_user_settings_to_purple() + }, content: { DamusPurpleWelcomeView() }) + .confirmationDialog( + NSLocalizedString("It seems that you already have a translation service configured. Would you like to switch to Damus Purple as your translator?", comment: "Confirmation dialog question asking users if they want their translation settings to be automatically switched to the Damus Purple translation service"), + isPresented: $show_settings_change_confirmation_dialog, + titleVisibility: .visible + ) { + Button("Yes") { + set_translation_settings_to_purple() + }.keyboardShortcut(.defaultAction) + Button("No", role: .cancel) {} + } .manageSubscriptionsSheet(isPresented: $show_manage_subscriptions) } + func update_user_settings_to_purple() { + if damus_state.settings.translation_service == .none { + set_translation_settings_to_purple() + } + else { + show_settings_change_confirmation_dialog = true + } + } + + func set_translation_settings_to_purple() { + damus_state.settings.translation_service = .purple + damus_state.settings.auto_translate = true + } + func handle_transactions(products: [Product]) async { for await update in StoreKit.Transaction.updates { switch update { @@ -203,9 +230,9 @@ struct DamusPurpleView: View { switch result { case .success: - self.purple_api.starred_profiles_cache[keypair.pubkey] = nil + self.damus_state.purple.starred_profiles_cache[keypair.pubkey] = nil Task { - await self.purple_api.send_receipt() + await self.damus_state.purple.send_receipt() } default: break @@ -423,6 +450,6 @@ struct DamusPurpleView_Previews: PreviewProvider { ]) */ - DamusPurpleView(purple: test_damus_state.purple, keypair: test_damus_state.keypair) + DamusPurpleView(damus_state: test_damus_state) } } diff --git a/damus/Views/Settings/TranslationSettingsView.swift b/damus/Views/Settings/TranslationSettingsView.swift index be4699f4..b2733294 100644 --- a/damus/Views/Settings/TranslationSettingsView.swift +++ b/damus/Views/Settings/TranslationSettingsView.swift @@ -9,6 +9,7 @@ import SwiftUI struct TranslationSettingsView: View { @ObservedObject var settings: UserSettingsStore + var damus_state: DamusState @Environment(\.dismiss) var dismiss @@ -19,11 +20,17 @@ struct TranslationSettingsView: View { .toggleStyle(.switch) Picker(NSLocalizedString("Service", comment: "Prompt selection of translation service provider."), selection: $settings.translation_service) { - ForEach(TranslationService.allCases, id: \.self) { server in + ForEach(TranslationService.allCases.filter({ settings.enable_experimental_purple_api ? true : $0 != .purple }), id: \.self) { server in Text(server.model.displayName) .tag(server.model.tag) } } + + if settings.translation_service == .purple && settings.enable_experimental_purple_api { + NavigationLink(destination: DamusPurpleView(damus_state: damus_state)) { + Text(NSLocalizedString("Configure Damus Purple", comment: "Button to allow Damus Purple to be configured")) + } + } if settings.translation_service == .libretranslate { Picker(NSLocalizedString("Server", comment: "Prompt selection of LibreTranslate server to perform machine translations on notes"), selection: $settings.libretranslate_server) { @@ -103,6 +110,6 @@ struct TranslationSettingsView: View { struct TranslationSettingsView_Previews: PreviewProvider { static var previews: some View { - TranslationSettingsView(settings: UserSettingsStore()) + TranslationSettingsView(settings: UserSettingsStore(), damus_state: test_damus_state) } } diff --git a/damus/Views/SideMenuView.swift b/damus/Views/SideMenuView.swift index b47b0c83..4b2bbfc0 100644 --- a/damus/Views/SideMenuView.swift +++ b/damus/Views/SideMenuView.swift @@ -55,7 +55,7 @@ struct SideMenuView: View { } if damus_state.settings.enable_experimental_purple_api { - NavigationLink(destination: DamusPurpleView(purple: damus_state.purple, keypair: damus_state.keypair)) { + NavigationLink(destination: DamusPurpleView(damus_state: damus_state)) { HStack(spacing: 13) { Image("nostr-hashtag") Text("Purple")