damus

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

commit 7d410bff346a27ea6803ef3178bbbdb09fef01d6
parent b25e2ff6c03c65ed441e00154ff0386c11aae0f1
Author: Terry Yiu <963907+tyiu@users.noreply.github.com>
Date:   Thu, 19 Jan 2023 21:59:37 -0500

merge "Add LibreTranslate translations"

Changelog-Added: LibreTranslate note translations

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 4++++
Mdamus/Components/InvoiceView.swift | 2+-
Mdamus/ContentView.swift | 1+
Adamus/Models/LibreTranslateServer.swift | 44++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Models/UserSettingsStore.swift | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Nostr/NostrEvent.swift | 6+++++-
Mdamus/Views/ConfigView.swift | 34++++++++++++++++++++++++++++++++++
Mdamus/Views/NoteContentView.swift | 176++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mdamus/Views/ProfileView.swift | 2+-
Mdamus/Views/SideMenuView.swift | 2+-
Mtranslations/en-US.xcloc/Localized Contents/en-US.xliff | 50++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 390 insertions(+), 5 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 3ACB685C297633BC00C46468 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3ACB685A297633BC00C46468 /* InfoPlist.strings */; }; 3ACB685F297633BC00C46468 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3ACB685D297633BC00C46468 /* Localizable.strings */; }; 3ACBCB78295FE5C70037388A /* TimeAgoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACBCB77295FE5C70037388A /* TimeAgoTests.swift */; }; + 3AE45AF6297BB2E700C1D842 /* LibreTranslateServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */; }; 4C06670128FC7C5900038D2A /* RelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C06670028FC7C5900038D2A /* RelayView.swift */; }; 4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4C06670328FC7EC500038D2A /* Kingfisher */; }; 4C06670628FCB08600038D2A /* ImageCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C06670528FCB08600038D2A /* ImageCarousel.swift */; }; @@ -241,6 +242,7 @@ 3ACB685B297633BC00C46468 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; 3ACB685E297633BC00C46468 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = "<group>"; }; 3ACBCB77295FE5C70037388A /* TimeAgoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeAgoTests.swift; sourceTree = "<group>"; }; + 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreTranslateServer.swift; sourceTree = "<group>"; }; 3AEB8003297CCEA800713A25 /* tr-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "tr-TR"; path = "tr-TR.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; 3AEB8004297CCEA800713A25 /* tr-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "tr-TR"; path = "tr-TR.lproj/Localizable.strings"; sourceTree = "<group>"; }; 3AEB8005297CCEA900713A25 /* tr-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "tr-TR"; path = "tr-TR.lproj/Localizable.stringsdict"; sourceTree = "<group>"; }; @@ -592,6 +594,7 @@ 7C45AE70297353390031D7BC /* KFImageModel.swift */, 4CF0ABD32980996B00D66079 /* Report.swift */, 4CF0ABDD2981A69500D66079 /* MutelistModel.swift */, + 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */, ); path = Models; sourceTree = "<group>"; @@ -1187,6 +1190,7 @@ 4C06670B28FDE64700038D2A /* damus.c in Sources */, 4FE60CDD295E1C5E00105A1F /* Wallet.swift in Sources */, 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */, + 3AE45AF6297BB2E700C1D842 /* LibreTranslateServer.swift in Sources */, 4C99737B28C92A9200E53835 /* ChatroomMetadata.swift in Sources */, 4CC7AAF4297F18B400430951 /* ReplyDescription.swift in Sources */, 4C75EFA427FA577B0006080F /* PostView.swift in Sources */, diff --git a/damus/Components/InvoiceView.swift b/damus/Components/InvoiceView.swift @@ -34,7 +34,7 @@ struct InvoiceView: View { let invoice: Invoice @State var showing_select_wallet: Bool = false - @ObservedObject var user_settings = UserSettingsStore() + @EnvironmentObject var user_settings: UserSettingsStore var PayButton: some View { Button { diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -284,6 +284,7 @@ struct ContentView: View { .padding([.bottom], 8) } } + .environmentObject(user_settings) .onAppear() { self.connect() //KingfisherManager.shared.cache.clearDiskCache() diff --git a/damus/Models/LibreTranslateServer.swift b/damus/Models/LibreTranslateServer.swift @@ -0,0 +1,44 @@ +// +// LibreTranslateServer.swift +// damus +// +// Created by Terry Yiu on 1/21/23. +// + +import Foundation + +enum LibreTranslateServer: String, CaseIterable, Identifiable { + var id: String { self.rawValue } + + struct Model: Identifiable, Hashable { + var id: String { self.tag } + var tag: String + var displayName: String + var url: String? + } + + case none + case argosopentech + case terraprint + case vern + case custom + + var model: Model { + switch self { + case .none: + return .init(tag: self.rawValue, displayName: NSLocalizedString("None", comment: "Dropdown option for selecting no translation server."), url: nil) + case .argosopentech: + return .init(tag: self.rawValue, displayName: "translate.argosopentech.com", url: "https://translate.argosopentech.com") + case .terraprint: + return .init(tag: self.rawValue, displayName: "translate.terraprint.co", url: "https://translate.terraprint.co") + case .vern: + return .init(tag: self.rawValue, displayName: "lt.vern.cc", url: "https://lt.vern.cc") + case .custom: + return .init(tag: self.rawValue, displayName: NSLocalizedString("Custom", comment: "Dropdown option for selecting a custom translation server."), url: nil) + } + } + + static var allModels: [Model] { + return Self.allCases.map { $0.model } + } +} diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift @@ -6,6 +6,7 @@ // import Foundation +import Vault class UserSettingsStore: ObservableObject { @Published var default_wallet: Wallet { @@ -26,6 +27,44 @@ class UserSettingsStore: ObservableObject { } } + @Published var libretranslate_server: LibreTranslateServer { + didSet { + if oldValue == libretranslate_server { + return + } + + UserDefaults.standard.set(libretranslate_server.rawValue, forKey: "libretranslate_server") + + libretranslate_api_key = "" + + if libretranslate_server == .custom || libretranslate_server == .none { + libretranslate_url = "" + } else { + libretranslate_url = libretranslate_server.model.url! + } + } + } + + @Published var libretranslate_url: String { + didSet { + UserDefaults.standard.set(libretranslate_url, forKey: "libretranslate_url") + } + } + + @Published var libretranslate_api_key: String { + didSet { + do { + if libretranslate_api_key == "" { + try clearLibreTranslateApiKey() + } else { + try saveLibreTranslateApiKey(libretranslate_api_key) + } + } catch { + // No-op. + } + } + } + init() { if let defaultWalletName = UserDefaults.standard.string(forKey: "default_wallet"), let default_wallet = Wallet(rawValue: defaultWalletName) @@ -37,5 +76,40 @@ class UserSettingsStore: ObservableObject { show_wallet_selector = UserDefaults.standard.object(forKey: "show_wallet_selector") as? Bool ?? true left_handed = UserDefaults.standard.object(forKey: "left_handed") as? Bool ?? false + + if let translationServerName = UserDefaults.standard.string(forKey: "libretranslate_server"), + let translationServer = LibreTranslateServer(rawValue: translationServerName) { + self.libretranslate_server = translationServer + libretranslate_url = translationServer.model.url ?? UserDefaults.standard.object(forKey: "libretranslate_url") as? String ?? "" + } else { + // Note from @tyiu: + // Default server is disabled by default for now until we gain some confidence that it is working well in production. + // Instead of throwing all Damus users onto feature immediately, allow for discovery of feature organically. + // Also, we are connecting to servers listed as mirrors on the official LibreTranslate GitHub README that do not require API keys. + // However, we have not asked them for permission to use, so we're trying to be good neighbors for now. + // Opportunity: spin up dedicated trusted LibreTranslate server that requires an API key for any access (or higher rate limit access). + libretranslate_server = .none + libretranslate_url = "" + } + + do { + libretranslate_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration()) + } catch { + libretranslate_api_key = "" + } } + + func saveLibreTranslateApiKey(_ apiKey: String) throws { + try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusLibreTranslateKeychainConfiguration()) + } + + func clearLibreTranslateApiKey() throws { + try Vault.deletePrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration()) + } +} + +struct DamusLibreTranslateKeychainConfiguration: KeychainConfiguration { + var serviceName = "damus" + var accessGroup: String? = nil + var accountName = "libretranslate_apikey" } diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift @@ -103,11 +103,15 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has if let bs = _blocks { return bs } - let blocks = parse_mentions(content: self.get_content(privkey), tags: self.tags) + let blocks = get_blocks(content: self.get_content(privkey)) self._blocks = blocks return blocks } + func get_blocks(content: String) -> [Block] { + return parse_mentions(content: content, tags: self.tags) + } + lazy var inner_event: NostrEvent? = { // don't try to deserialize an inner event if we know there won't be one if self.known_kind == .boost { diff --git a/damus/Views/ConfigView.swift b/damus/Views/ConfigView.swift @@ -16,6 +16,7 @@ struct ConfigView: View { @State var confirm_delete_account: Bool = false @State var new_relay: String = "" @State var show_privkey: Bool = false + @State var show_libretranslate_api_key: Bool = false @State var privkey: String @State var privkey_copied: Bool = false @State var pubkey_copied: Bool = false @@ -118,6 +119,39 @@ struct ConfigView: View { } } + Section(NSLocalizedString("LibreTranslate Translations", comment: "Section title for selecting the server that hosts the LibreTranslate machine translation API.")) { + Picker(NSLocalizedString("Server", comment: "Prompt selection of LibreTranslate server to perform machine translations on notes"), selection: $user_settings.libretranslate_server) { + ForEach(LibreTranslateServer.allCases, id: \.self) { server in + Text(server.model.displayName) + .tag(server.model.tag) + } + } + + if user_settings.libretranslate_server != .none { + TextField(NSLocalizedString("URL", comment: "Example URL to LibreTranslate server"), text: $user_settings.libretranslate_url) + .disableAutocorrection(true) + .disabled(user_settings.libretranslate_server != .custom) + .autocapitalization(UITextAutocapitalizationType.none) + HStack { + if show_libretranslate_api_key { + TextField(NSLocalizedString("API Key (optional)", comment: "Example URL to LibreTranslate server"), text: $user_settings.libretranslate_api_key) + .disableAutocorrection(true) + .autocapitalization(UITextAutocapitalizationType.none) + Button(NSLocalizedString("Hide API Key", comment: "Button to hide the LibreTranslate server API key.")) { + show_libretranslate_api_key = false + } + } else { + SecureField(NSLocalizedString("API Key (optional)", comment: "Example URL to LibreTranslate server"), text: $user_settings.libretranslate_api_key) + .disableAutocorrection(true) + .autocapitalization(UITextAutocapitalizationType.none) + Button(NSLocalizedString("Show API Key", comment: "Button to hide the LibreTranslate server API key.")) { + show_libretranslate_api_key = true + } + } + } + } + } + Section(NSLocalizedString("Left Handed", comment: "Moves the post button to the left side of the screen")) { Toggle(NSLocalizedString("Left Handed", comment: "Moves the post button to the left side of the screen"), isOn: $user_settings.left_handed) .toggleStyle(.switch) diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift @@ -8,6 +8,10 @@ import SwiftUI import LinkPresentation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + struct NoteArtifacts { let content: AttributedString let images: [URL] @@ -21,6 +25,10 @@ struct NoteArtifacts { func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) -> NoteArtifacts { let blocks = ev.blocks(privkey) + return render_blocks(blocks: blocks, profiles: profiles, privkey: privkey) +} + +func render_blocks(blocks: [Block], profiles: Profiles, privkey: String?) -> NoteArtifacts { var invoices: [Invoice] = [] var img_urls: [URL] = [] var link_urls: [URL] = [] @@ -47,7 +55,7 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) - } } } - + return NoteArtifacts(content: txt, images: img_urls, invoices: invoices, links: link_urls) } @@ -64,10 +72,18 @@ struct NoteContentView: View { let show_images: Bool + @State var checkingTranslationStatus: Bool = false + @State var language: String? = nil + @State var translated_note: String? = nil + @State var show_translated_note: Bool = false + @State var translated_artifacts: NoteArtifacts? = nil + @State var artifacts: NoteArtifacts @State var preview: LinkViewRepresentable? = nil let size: EventViewKind + + @EnvironmentObject var user_settings: UserSettingsStore func MainContent() -> some View { return VStack(alignment: .leading) { @@ -75,6 +91,29 @@ struct NoteContentView: View { .font(eventviewsize_to_font(size)) .fixedSize(horizontal: false, vertical: true) + if size == .selected && language != nil && translated_artifacts != nil { + let languageName = Locale.current.localizedString(forLanguageCode: language!) + if show_translated_note { + Button(NSLocalizedString("Translated from \(languageName!)", comment: "Button to indicate that the note has been translated from a different language.")) { + show_translated_note = false + } + .font(.footnote) + .contentShape(Rectangle()) + .padding(.top, 10) + + Text(translated_artifacts!.content) + .font(eventviewsize_to_font(size)) + .fixedSize(horizontal: false, vertical: true) + } else { + Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) { + show_translated_note = true + } + .font(.footnote) + .contentShape(Rectangle()) + .padding(.top, 10) + } + } + if show_images && artifacts.images.count > 0 { ImageCarousel(urls: artifacts.images) } else if !show_images && artifacts.images.count > 0 { @@ -142,6 +181,35 @@ struct NoteContentView: View { previews.store(evid: self.event.id, preview: view) self.preview = view } + + if size == .selected && language == nil && !checkingTranslationStatus && user_settings.libretranslate_url != "" { + checkingTranslationStatus = true + + let currentLanguage = Locale.current.languageCode ?? "en" + let translator = Translator(user_settings.libretranslate_url, apiKey: user_settings.libretranslate_api_key) + + do { + language = try await translator.detect(event.content) + + if language == nil { + language = currentLanguage + translated_note = nil + } else if language != currentLanguage { + translated_note = try await translator.translate(event.content, from: language!, to: currentLanguage) + + if translated_note != nil { + let blocks = event.get_blocks(content: translated_note!) + translated_artifacts = render_blocks(blocks: blocks, profiles: profiles, privkey: privkey) + } + } + } catch { + // If for whatever reason we're not able to figure out the language of the note, or translate the note, fail gracefully and do not retry. It's not the end of the world. Don't want to take down someone's translation server with an accidental denial of service attack. + language = currentLanguage + translated_note = nil + } + + checkingTranslationStatus = false + } } } @@ -196,6 +264,112 @@ func mention_str(_ m: Mention, profiles: Profiles) -> AttributedString { } +public struct Translator { + private let url: String + private let apiKey: String? + private let session = URLSession.shared + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init(_ url: String, apiKey: String? = nil) { + self.url = url + self.apiKey = apiKey + } + + public func detect(_ text: String) async throws -> String? { + let url = try makeURL(path: "/detect") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + struct RequestBody: Encodable { + let q: String + let api_key: String? + } + let body = RequestBody(q: text, api_key: apiKey) + request.httpBody = try encoder.encode(body) + + struct Response: Decodable { + let confidence: Double + let language: String + } + + let data = try await session.data(for: request) + let response = try decoder.decode([Response].self, from: data) + let language = response.first! + + if language.confidence >= 80 { + return language.language + } else { + return nil + } + } + + public func translate(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String { + let url = try makeURL(path: "/translate") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + struct RequestBody: Encodable { + let q: String + let source: String + let target: String + let api_key: String? + } + let body = RequestBody(q: text, source: sourceLanguage, target: targetLanguage, api_key: apiKey) + request.httpBody = try encoder.encode(body) + + struct Response: Decodable { + let translatedText: String + } + let response: Response = try await decodedData(for: request) + return response.translatedText + } + + private func makeURL(path: String) throws -> URL { + guard var components = URLComponents(string: url) else { + throw URLError(.badURL) + } + components.path = path + guard let url = components.url else { + throw URLError(.badURL) + } + return url + } + + private func decodedData<Output: Decodable>(for request: URLRequest) async throws -> Output { + let data = try await session.data(for: request) + let result = try decoder.decode(Output.self, from: data) + return result + } +} + +private extension URLSession { + func data(for request: URLRequest) async throws -> Data { + var task: URLSessionDataTask? + let onCancel = { task?.cancel() } + return try await withTaskCancellationHandler( + operation: { + try await withCheckedThrowingContinuation { continuation in + task = dataTask(with: request) { data, _, error in + guard let data = data else { + let error = error ?? URLError(.badServerResponse) + return continuation.resume(throwing: error) + } + continuation.resume(returning: data) + } + task?.resume() + } + }, + onCancel: { onCancel() } + ) + } +} + + struct NoteContentView_Previews: PreviewProvider { static var previews: some View { let state = test_damus_state() diff --git a/damus/Views/ProfileView.swift b/damus/Views/ProfileView.swift @@ -116,7 +116,7 @@ struct ProfileView: View { @State var is_zoomed: Bool = false @State var show_share_sheet: Bool = false @State var action_sheet_presented: Bool = false - @StateObject var user_settings = UserSettingsStore() + @EnvironmentObject var user_settings: UserSettingsStore @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme diff --git a/damus/Views/SideMenuView.swift b/damus/Views/SideMenuView.swift @@ -12,7 +12,7 @@ struct SideMenuView: View { @Binding var isSidebarVisible: Bool @State var confirm_logout: Bool = false - @StateObject var user_settings = UserSettingsStore() + @EnvironmentObject var user_settings: UserSettingsStore @State private var showQRCode = false diff --git a/translations/en-US.xcloc/Localized Contents/en-US.xliff b/translations/en-US.xcloc/Localized Contents/en-US.xliff @@ -105,6 +105,11 @@ Number of profiles a user is following.</note> <target>@</target> <note>Prefix character to username.</note> </trans-unit> + <trans-unit id="API Key (optional)" xml:space="preserve"> + <source>API Key (optional)</source> + <target>API Key (optional)</target> + <note>Example URL to LibreTranslate server</note> + </trans-unit> <trans-unit id="About" xml:space="preserve"> <source>About</source> <target>About</target> @@ -344,6 +349,11 @@ Number of profiles a user is following.</note> <target>Creator(s) of Bitcoin. Absolute legend.</target> <note>Example description about Bitcoin creator(s), Satoshi Nakamoto.</note> </trans-unit> + <trans-unit id="Custom" xml:space="preserve"> + <source>Custom</source> + <target>Custom</target> + <note>Dropdown option for selecting a custom translation server.</note> + </trans-unit> <trans-unit id="DM Type" xml:space="preserve"> <source>DM Type</source> <target>DM Type</target> @@ -472,6 +482,11 @@ Part of a larger sentence to describe how many profiles a user is following.</no <target>Hide</target> <note>Button to hide a post from a user who has been blocked.</note> </trans-unit> + <trans-unit id="Hide API Key" xml:space="preserve"> + <source>Hide API Key</source> + <target>Hide API Key</target> + <note>Button to hide the LibreTranslate server API key.</note> + </trans-unit> <trans-unit id="Home" xml:space="preserve"> <source>Home</source> <target>Home</target> @@ -507,6 +522,11 @@ Part of a larger sentence to describe how many profiles a user is following.</no <target>Let's go!</target> <note>Button to complete account creation and start using the app.</note> </trans-unit> + <trans-unit id="LibreTranslate Translations" xml:space="preserve"> + <source>LibreTranslate Translations</source> + <target>LibreTranslate Translations</target> + <note>Section title for selecting the server that hosts the LibreTranslate machine translation API.</note> + </trans-unit> <trans-unit id="Lightning Address or LNURL" xml:space="preserve"> <source>Lightning Address or LNURL</source> <target>Lightning Address or LNURL</target> @@ -555,6 +575,11 @@ Part of a larger sentence to describe how many profiles a user is following.</no <target>No block list found, create a new one? This will overwrite any previous block lists.</target> <note>Alert message prompt that asks if the user wants to create a new block list, overwriting previous block lists.</note> </trans-unit> + <trans-unit id="None" xml:space="preserve"> + <source>None</source> + <target>None</target> + <note>Dropdown option for selecting no translation server.</note> + </trans-unit> <trans-unit id="Nothing to see here. Check back later!" xml:space="preserve"> <source>Nothing to see here. Check back later!</source> <target>Nothing to see here. Check back later!</target> @@ -792,6 +817,11 @@ Part of a larger sentence to describe how many profiles a user is following.</no <target>Send a message to start the conversation...</target> <note>Text prompt for user to send a message to the other user.</note> </trans-unit> + <trans-unit id="Server" xml:space="preserve"> + <source>Server</source> + <target>Server</target> + <note>Prompt selection of LibreTranslate server to perform machine translations on notes</note> + </trans-unit> <trans-unit id="Settings" xml:space="preserve"> <source>Settings</source> <target>Settings</target> @@ -810,6 +840,11 @@ Part of a larger sentence to describe how many profiles a user is following.</no <note>Button to show a post from a user who has been blocked. Toggle to show or hide user's secret account login key.</note> </trans-unit> + <trans-unit id="Show API Key" xml:space="preserve"> + <source>Show API Key</source> + <target>Show API Key</target> + <note>Button to hide the LibreTranslate server API key.</note> + </trans-unit> <trans-unit id="Show wallet selector" xml:space="preserve"> <source>Show wallet selector</source> <target>Show wallet selector</target> @@ -861,11 +896,26 @@ Part of a larger sentence to describe how many profiles a user is following.</no <note>Navigation bar title for note thread. Navigation bar title for threaded event detail view.</note> </trans-unit> + <trans-unit id="Translate Note" xml:space="preserve"> + <source>Translate Note</source> + <target>Translate Note</target> + <note>Button to translate note from different language.</note> + </trans-unit> + <trans-unit id="Translated from (languageName!)" xml:space="preserve"> + <source>Translated from (languageName!)</source> + <target>Translated from (languageName!)</target> + <note>Button to indicate that the note has been translated from a different language.</note> + </trans-unit> <trans-unit id="Type your post here..." xml:space="preserve"> <source>Type your post here...</source> <target>Type your post here...</target> <note>Text box prompt to ask user to type their post.</note> </trans-unit> + <trans-unit id="URL" xml:space="preserve"> + <source>URL</source> + <target>URL</target> + <note>Example URL to LibreTranslate server</note> + </trans-unit> <trans-unit id="Unfollow" xml:space="preserve"> <source>Unfollow</source> <target>Unfollow</target>