Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import Foundation
import SwiftUI
import WebKit
import Translation
import NaturalLanguage
import Combine
import WordPressShared

@available(iOS 26, *)
@MainActor
public final class TranslationViewModel: ObservableObject {
@Published var configuration: TranslationSession.Configuration?

private var content: [String] = []
private var continuation: CheckedContinuation<[String], Error>?

public init() {}

public func translate(_ content: String, to targetLanguage: Locale.Language) async throws -> String {
let content = try await translate([content], to: targetLanguage)
guard let first = content.first else {
throw URLError(.unknown) // Should never happen
}
return first
}

/// Translate content to the specified target language.
///
/// This method detects the source language automatically and translates each string
/// in the content array independently.
public func translate(
_ content: [String],
from source: Locale.Language? = nil,
to target: Locale.Language = Locale.current.language
) async throws -> [String] {
wpAssert(continuation == nil, "Translation in progress")

self.content = content
return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems a bit risky to hold the continuation instances at the class level, instead of locally in the function.


// This will trigger the .translationTask in TranslationHostView
if self.configuration != nil {
// Yes, this is how you restart translation with the existing configuration
// in the Translation framework.
self.configuration?.invalidate()
} else {
self.configuration = TranslationSession.Configuration(source: source, target: target)
}
}
}

/// Check if translation is available for the given content.
public func checkAvailability(for content: String, to targetLanguage: Locale.Language = Locale.current.language) async -> TranslationAvailability {
// Important. The `Translation` framework is effective at translating
// HTML, but the `status(...)` method and `NLLanguageRecognizer`
// incorrectly identify dominant langauge as English if a post has a
// signifcant amount of HTML tags and/or CSS styles.
let content = (try? ContentExtractor.extractRelevantText(from: content)) ?? content

guard let identifier = IntelligenceService.detectLanguage(from: content) else {
return .unavailable
}
let sourceLanguage = Locale.Language(identifier: identifier)

let availability = LanguageAvailability()
let status = await availability.status(from: sourceLanguage, to: targetLanguage)
guard status == .installed || status == .supported else {
return .unavailable
}
return .available(sourceLanguage: sourceLanguage, targetLanguage: targetLanguage)
}

fileprivate func performTranslation(session: TranslationSession) async {
do {
var output: [String] = []
for string in content {
try Task.checkCancellation()
let result = try await session.translate(string)
output.append(result.targetText)
}
finish(with: .success(output))
} catch {
if (error as NSError).domain == NSCocoaErrorDomain && (error as NSError).code == NSUserCancelledError {
finish(with: .failure(CancellationError()))
} else {
finish(with: .failure(error))
}
}
}

private func finish(with result: Result<[String], Error>) {
content = []
if let continuation {
self.continuation = nil
continuation.resume(with: result)
}
}
}

public enum TranslationAvailability {
case unavailable
case available(sourceLanguage: Locale.Language, targetLanguage: Locale.Language)
}

// MARK: - TranslationHostView (SwiftUI)

/// SwiftUI view that hosts translation functionality using .translationTask()
///
/// This view manages the translation session lifecycle. It observes the view model's
/// configuration and triggers translation when it changes.
///
/// **IMPORTANT**: The `session` object must NEVER leave the `.translationTask` closure.
/// Capturing or storing the session causes crashes.
@available(iOS 26, *)
public struct TranslationHostView: View {
@ObservedObject var viewModel: TranslationViewModel

public init(viewModel: TranslationViewModel) {
self.viewModel = viewModel
}

public var body: some View {
Color.clear
.frame(width: 0, height: 0)
.translationTask(viewModel.configuration) { session in
await viewModel.performTranslation(session: session)
}
}
}

@available(iOS 18.0, *)
extension TranslationSession: @retroactive @unchecked(Sendable) {}
4 changes: 2 additions & 2 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
26.6
-----
* [**] Intelligence: Expand AI-based features to more locales [#25034]
* [**] Reader: Add "Freshly Pressed" to Discover [#24828]
* [*] Reader: Keep only one "Search" option in the main sidebar [#25116]
* [*] Reader: Fix Send button design in Comments [#25092]
* [**] Intelligence: Expand AI-based features to more locales [#25034]
* [*] Reader: Add translation support for posts [#25089]
* [*] Fix previewing posts on WordPress.com atomic sites [#25045]
* [*] Stats: Rename "External Links" to "Clicks" to be consistent with the web [#25090]
* [*] Stats: Fix locations data discrepancy with the web [#25105]



26.5
-----
* [*] Add "Status" field to the "Post Settings" screen to make it easier to move posts from one state to another [#24939]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ struct ReaderPostMenu {
copyPostLink,
viewPostInBrowser,
post.isSeenSupported ? toggleSeen : nil,
summarize
summarize,
translate
].compactMap { $0 })
}

Expand Down Expand Up @@ -132,6 +133,19 @@ struct ReaderPostMenu {
}
}

private var translate: UIAction? {
guard #available(iOS 26, *),
let detailVC = viewController as? ReaderDetailViewController,
case .available = detailVC.translationAvailability else {
return nil
}

return UIAction(Strings.translate, systemImage: "translate") {
detailVC.translatePost()
track(.translate)
}
}

private func manageNotifications(for siteID: Int) -> UIAction {
UIAction(Strings.manageNotifications, systemImage: "bell") {
guard let viewController else { return }
Expand Down Expand Up @@ -233,6 +247,7 @@ private enum ReaderPostMenuAnalyticsButton: String {
case markRead = "mark_read"
case markUnread = "mark_unread"
case summarize = "summarize"
case translate = "translate"
}

private enum Strings {
Expand All @@ -248,4 +263,5 @@ private enum Strings {
static let markRead = NSLocalizedString("reader.postContextMenu.markRead", value: "Mark as Read", comment: "Context menu action")
static let markUnread = NSLocalizedString("reader.postContextMenu.markUnread", value: "Mark as Unread", comment: "Context menu action")
static let summarize = NSLocalizedString("reader.postContextMenu.summarize", value: "Summarize", comment: "Context menu action")
static let translate = NSLocalizedString("reader.postContextMenu.translate", value: "Translate", comment: "Context menu action to translate post content")
}
Loading