diff --git a/Packages/Account/Sources/Account/AccountDetailContextMenu.swift b/Packages/Account/Sources/Account/AccountDetailContextMenu.swift index 7f5f0a12..86bb6b3b 100644 --- a/Packages/Account/Sources/Account/AccountDetailContextMenu.swift +++ b/Packages/Account/Sources/Account/AccountDetailContextMenu.swift @@ -150,6 +150,17 @@ public struct AccountDetailContextMenu: View { Divider() } + if let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier + { + Button { + Task { + await viewModel.translate(userLang: lang) + } + } label: { + Label("status.action.translate", systemImage: "captions.bubble") + } + } + if viewModel.relationship?.following == true { Button { routerPath.presentedSheet = .listAddAccount(account: account) diff --git a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift index e69ebe4b..2203f22b 100644 --- a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift +++ b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift @@ -202,12 +202,34 @@ struct AccountDetailHeaderView: View { routerPath.handle(url: url) }) + if let translation = viewModel.translation, !viewModel.isLoadingTranslation { + GroupBox { + VStack(alignment: .leading, spacing: 4) { + Text(translation.content.asSafeMarkdownAttributedString) + .font(.scaledBody) + Text(getLocalizedStringLabel(langCode: translation.detectedSourceLanguage, provider: translation.provider)) + .font(.footnote) + .foregroundColor(.gray) + } + } + .fixedSize(horizontal: false, vertical: true) + } + fieldsView } .padding(.horizontal, .layoutPadding) .offset(y: -40) } + private func getLocalizedStringLabel(langCode: String, provider: String) -> String { + if let localizedLanguage = Locale.current.localizedString(forLanguageCode: langCode) { + let format = NSLocalizedString("status.action.translated-label-from-%@-%@", comment: "") + return String.localizedStringWithFormat(format, localizedLanguage, provider) + } else { + return "status.action.translated-label-\(provider)" + } + } + private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int, needsBadge: Bool = false) -> some View { VStack { Text(count, format: .number.notation(.compactName)) diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index 3786711f..a8d80453 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -83,6 +83,9 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { } } + @Published var translation: Translation? + @Published var isLoadingTranslation = false + private(set) var account: Account? private var tabTask: Task? @@ -263,4 +266,22 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { func statusDidAppear(status _: Models.Status) {} func statusDidDisappear(status _: Status) {} + + func translate(userLang: String) async { + guard let account else { return } + withAnimation { + isLoadingTranslation = true + } + + let userAPIKey = DeepLUserAPIHandler.readIfAllowed() + let userAPIFree = UserPreferences.shared.userDeeplAPIFree + let deeplClient = DeepLClient(userAPIKey: userAPIKey, userAPIFree: userAPIFree) + + let translation = try? await deeplClient.request(target: userLang, text: account.note.asRawText) + + withAnimation { + self.translation = translation + isLoadingTranslation = false + } + } } diff --git a/Packages/Models/Sources/Models/StatusTranslation.swift b/Packages/Models/Sources/Models/Translation.swift similarity index 80% rename from Packages/Models/Sources/Models/StatusTranslation.swift rename to Packages/Models/Sources/Models/Translation.swift index 8b6390a8..2b16e5ff 100644 --- a/Packages/Models/Sources/Models/StatusTranslation.swift +++ b/Packages/Models/Sources/Models/Translation.swift @@ -1,6 +1,6 @@ import Foundation -public struct StatusTranslation: Decodable { +public struct Translation: Decodable { public let content: HTMLString public let detectedSourceLanguage: String public let provider: String @@ -12,4 +12,4 @@ public struct StatusTranslation: Decodable { } } -extension StatusTranslation: Sendable {} +extension Translation: Sendable {} diff --git a/Packages/Network/Sources/Network/DeepLClient.swift b/Packages/Network/Sources/Network/DeepLClient.swift index ea91d040..427db35d 100644 --- a/Packages/Network/Sources/Network/DeepLClient.swift +++ b/Packages/Network/Sources/Network/DeepLClient.swift @@ -48,7 +48,7 @@ public struct DeepLClient { deeplUserAPIFree = userAPIFree } - public func request(target: String, text: String) async throws -> StatusTranslation { + public func request(target: String, text: String) async throws -> Translation { do { var components = URLComponents(string: endpoint)! var queryItems: [URLQueryItem] = [] diff --git a/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift b/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift index 28f68a67..4eff791b 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift @@ -21,7 +21,7 @@ public class StatusRowViewModel: ObservableObject { @Published var isEmbedLoading: Bool = false @Published var isFiltered: Bool = false - @Published var translation: StatusTranslation? + @Published var translation: Translation? @Published var isLoadingTranslation: Bool = false @Published var showDeleteAlert: Bool = false @@ -294,7 +294,7 @@ public class StatusRowViewModel: ObservableObject { if !alwaysTranslateWithDeepl { do { // We first use instance translation API if available. - let translation: StatusTranslation = try await client.post(endpoint: Statuses.translate(id: finalStatus.id, + let translation: Translation = try await client.post(endpoint: Statuses.translate(id: finalStatus.id, lang: userLang)) withAnimation { self.translation = translation