From 866bd15358cc2e6797213bf63d9af257026a6d98 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 22 Dec 2025 16:53:33 -0500 Subject: [PATCH 1/8] Implement Reader Post translation --- .../UseCases/TranslationViewModel.swift | 112 ++++++++++++ RELEASE-NOTES.txt | 1 + .../ReaderPostActions/ReaderPostMenu.swift | 18 +- .../Detail/ReaderDetailViewController.swift | 166 ++++++++++++++++++ .../Detail/Views/ReaderDetailHeaderView.swift | 12 +- 5 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift diff --git a/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift new file mode 100644 index 000000000000..2621e034d52a --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift @@ -0,0 +1,112 @@ +import Foundation +import SwiftUI +import WebKit +import Translation +import NaturalLanguage +import Combine + +@available(iOS 26, *) +@MainActor +public 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], to targetLanguage: Locale.Language = Locale.current.language) async throws -> [String] { + // Store content for translation session + self.content = content + + // Use continuation to wait for translation result + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + + // This will trigger the .translationTask in TranslationHostView + self.configuration = TranslationSession.Configuration(source: nil, target: targetLanguage) + } + } + + /// Check if translation is available for the given content. + public func isTranslationAvailable(for content: String, to targetLanguage: Locale.Language = Locale.current.language) async -> Bool { + // 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 + do { + let availability = LanguageAvailability() + let status = try await availability.status(for: content, to: targetLanguage) + return status == .installed || status == .supported + } catch { + return false + } + } + + 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 { + finish(with: .failure(error)) + } + } + + private func finish(with result: Result<[String], Error>) { + configuration = nil + content = [] + if let continuation { + self.continuation = nil + continuation.resume(with: result) + } + } +} + +// 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 + Task { @MainActor in + await viewModel.performTranslation(session: session) + } + } + } +} + +@available(iOS 18.0, *) +extension TranslationSession: @retroactive @unchecked(Sendable) {} diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 43c4c49ecf15..e22134a8eed0 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,6 +1,7 @@ 26.6 ----- * [**] [Intelligence] Expand AI-based features to more locales [#25034] +* [*] Add translation support for posts in Reader [#25089] * [*] Fix previewing posts on WordPress.com atomic sites [#25045] 26.5 diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift index 12a8043b361a..63b8ede020ae 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift @@ -27,7 +27,8 @@ struct ReaderPostMenu { copyPostLink, viewPostInBrowser, post.isSeenSupported ? toggleSeen : nil, - summarize + summarize, + translate ].compactMap { $0 }) } @@ -132,6 +133,19 @@ struct ReaderPostMenu { } } + private var translate: UIAction? { + guard #available(iOS 26, *), + let detailVC = viewController as? ReaderDetailViewController, + detailVC.isTranslationAvailable 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 } @@ -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 { @@ -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") } diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift index 81ab8094a6d1..6fd1b45e1b97 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift @@ -1,9 +1,11 @@ import UIKit +import SwiftUI import WordPressUI import AutomatticTracks import WordPressReader import WordPressData import WordPressKit +import WordPressIntelligence import Combine @preconcurrency import WebKit @@ -160,6 +162,36 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { displaySettingStore.setting } + // Translation support + @available(iOS 26, *) + private var translationViewModel: TranslationViewModel { + _translationViewModel as! TranslationViewModel + } + + private lazy var _translationViewModel: Any = { + guard #available(iOS 26, *) else { return {} } + return TranslationViewModel() + }() + + @available(iOS 26, *) + private var translationHostView: TranslationHostView { + _translationHostView as! TranslationHostView + } + + private lazy var _translationHostView: Any = { + guard #available(iOS 26, *) else { return () } + return TranslationHostView(viewModel: translationViewModel) + }() + + private var translationAvailabilityTask: Task? + var isTranslationAvailable = false + + private lazy var translationSpinner: UIBarButtonItem = { + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.startAnimating() + return UIBarButtonItem(customView: activityIndicator) + }() + override func viewDidLoad() { super.viewDidLoad() @@ -174,6 +206,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { observeWebViewHeight() configureNotifications() configureCommentsTable() + configureTranslationIfAvailable() coordinator?.start() @@ -273,6 +306,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { header.configure(for: post) fetchLikes() fetchComments() + checkTranslationAvailability() if let postURLString = post.permaLink, let postURL = URL(string: postURLString) { @@ -470,6 +504,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { deinit { scrollObserver?.invalidate() toolbarUpdateTimer?.invalidate() + translationAvailabilityTask?.cancel() NotificationCenter.default.removeObserver(self) } @@ -710,6 +745,90 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { forCellReuseIdentifier: ReaderDetailNoCommentCell.defaultReuseID) } + private func configureTranslationIfAvailable() { + guard #available(iOS 26, *) else { + return + } + let hostingController = UIHostingController(rootView: translationHostView) + hostingController.view.isHidden = true + addChild(hostingController) + view.addSubview(hostingController.view) + hostingController.didMove(toParent: self) + } + + private func checkTranslationAvailability() { + guard #available(iOS 26, *) else { + return + } + translationAvailabilityTask?.cancel() + translationAvailabilityTask = Task { @MainActor in + self.isTranslationAvailable = await self.checkIsTranslationAvailable() + } + } + + @available(iOS 26, *) + private func checkIsTranslationAvailable() async -> Bool { + guard let post, let content = post.contentForDisplay() else { + return false + } + return await translationViewModel.isTranslationAvailable(for: content) + } + + @available(iOS 26, *) + func translatePost() { + Task { @MainActor in + do { + isTranslationAvailable = false + try await actuallyTranslatePost() + } catch { + isTranslationAvailable = true + Notice(error: error).post() + UINotificationFeedbackGenerator().notificationOccurred(.error) + DDLogError("Translation failed: \(error)") + } + } + } + + @available(iOS 26, *) + @MainActor + private func actuallyTranslatePost() async throws { + guard let post else { return } + + // Show spinner in navigation bar + showTranslationSpinner() + + let translationResults = try await translationViewModel.translate( + [post.postTitle ?? "", post.content ?? ""], + ) + + // Create blur effect + let blurEffect = UIBlurEffect(style: .light) + let blurView = UIVisualEffectView(effect: nil) + blurView.frame = webView.bounds + blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + webView.addSubview(blurView) + + // Fast blur in + UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseIn) { + blurView.effect = blurEffect + self.webView.alpha = 1.0 + } + + // Update the UI with translated content + header.configure(for: post, title: translationResults[0]) + try await webView.setBodyHTML(translationResults[1]) + + // Blur out + UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseOut) { + blurView.effect = nil + } completion: { _ in + blurView.removeFromSuperview() + } + + // Hide spinner in navigation bar + hideTranslationSpinner() + } + private func configureRelatedPosts() { relatedPostsTableView.isScrollEnabled = false relatedPostsTableView.separatorStyle = .none @@ -1171,6 +1290,20 @@ private extension ReaderDetailViewController { navigationItem.rightBarButtonItems = rightItems.compactMap({ $0 }) } + func showTranslationSpinner() { + guard var items = navigationItem.rightBarButtonItems, + !items.contains(translationSpinner) else { return } + items.append(translationSpinner) + navigationItem.setRightBarButtonItems(items, animated: true) + } + + func hideTranslationSpinner() { + guard var items = navigationItem.rightBarButtonItems, + let index = items.firstIndex(of: translationSpinner) else { return } + items.remove(at: index) + navigationItem.setRightBarButtonItems(items, animated: true) + } + /// Updates the left bar button item based on the current view controller's context in the navigation stack. /// If the view controller is presented modally and does not have a left bar button item, a dismiss button is set. /// If the view controller is not the root of the navigation stack, a back button is set. @@ -1336,3 +1469,36 @@ extension ReaderDetailViewController: ContentIdentifiable { return nil } } + +// MARK: - WKWebView Helpers + +/// Helper methods for working with WKWebView and translation. +private extension WKWebView { + + /// Get the body HTML content from the web view. + /// + /// This method extracts the innerHTML of the document body. + /// + /// - Returns: The HTML content as a String. + /// - Throws: An error if JavaScript evaluation fails. + func getBodyHTML() async throws -> String { + let script = "document.body.innerHTML || ''" + let html = try await evaluateJavaScript(script) as? String + return html ?? "" + } + + /// Set the body HTML content in the web view. + /// + /// This method replaces the innerHTML of the document body with the provided HTML. + /// + /// - Parameter html: The HTML content to set. + /// - Throws: An error if JavaScript evaluation fails. + func setBodyHTML(_ html: String) async throws { + _ = try await callAsyncJavaScript( + "document.body.innerHTML = html", + arguments: ["html": html], + in: nil, + contentWorld: .page + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift index d08173fcdebd..5577f65c6f7b 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift @@ -78,6 +78,12 @@ extension ReaderDetailHeaderHostingView { completion: refreshContainerLayout) } + func configure(for post: ReaderPost, title: String?) { + viewModel.configure(with: TaggedManagedObjectID(post), + customTitle: title, + completion: refreshContainerLayout) + } + func refreshFollowButton() { viewModel.refreshFollowState() } @@ -142,6 +148,10 @@ class ReaderDetailHeaderViewModel: ObservableObject { } func configure(with objectID: TaggedManagedObjectID, completion: (() -> Void)?) { + configure(with: objectID, customTitle: nil, completion: completion) + } + + func configure(with objectID: TaggedManagedObjectID, customTitle: String?, completion: (() -> Void)?) { postObjectID = objectID coreDataStack.performQuery { [weak self] context -> Void in guard let self, @@ -170,7 +180,7 @@ class ReaderDetailHeaderViewModel: ObservableObject { // context: https://github.com/wordpress-mobile/WordPress-iOS/pull/21674#issuecomment-1747202728 self.showsAuthorName = self.authorName != self.siteName && !self.authorName.isEmpty - self.postTitle = post.titleForDisplay() + self.postTitle = customTitle ?? post.titleForDisplay() self.likeCount = post.likeCount?.intValue self.commentCount = post.commentCount?.intValue self.tags = post.tagsForDisplay() ?? [] From fb7f6a44891ccb11be3ff488b006d704328b5b8f Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 5 Jan 2026 16:52:15 -0500 Subject: [PATCH 2/8] Add an assertion to check if translation is started --- .../UseCases/TranslationViewModel.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift index 2621e034d52a..227bf40447c4 100644 --- a/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift +++ b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift @@ -4,10 +4,11 @@ import WebKit import Translation import NaturalLanguage import Combine +import WordPressShared @available(iOS 26, *) @MainActor -public class TranslationViewModel: ObservableObject { +public final class TranslationViewModel: ObservableObject { @Published var configuration: TranslationSession.Configuration? private var content: [String] = [] @@ -28,10 +29,9 @@ public class TranslationViewModel: ObservableObject { /// This method detects the source language automatically and translates each string /// in the content array independently. public func translate(_ content: [String], to targetLanguage: Locale.Language = Locale.current.language) async throws -> [String] { - // Store content for translation session - self.content = content + wpAssert(continuation == nil, "Translation in progress") - // Use continuation to wait for translation result + self.content = content return try await withCheckedThrowingContinuation { continuation in self.continuation = continuation From df106764d726890bdc0839e674a8323e0fbe979e Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 5 Jan 2026 17:04:23 -0500 Subject: [PATCH 3/8] Add CancellationError support --- .../UseCases/TranslationViewModel.swift | 6 +++++- .../Reader/Detail/ReaderDetailViewController.swift | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift index 227bf40447c4..9ee7411b3eec 100644 --- a/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift +++ b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift @@ -66,7 +66,11 @@ public final class TranslationViewModel: ObservableObject { } finish(with: .success(output)) } catch { - finish(with: .failure(error)) + if (error as NSError).domain == NSCocoaErrorDomain && (error as NSError).code == NSUserCancelledError { + finish(with: .failure(CancellationError())) + } else { + finish(with: .failure(error)) + } } } diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift index 6fd1b45e1b97..fb500afc71af 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift @@ -782,8 +782,10 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { try await actuallyTranslatePost() } catch { isTranslationAvailable = true - Notice(error: error).post() - UINotificationFeedbackGenerator().notificationOccurred(.error) + if !(error is CancellationError) { + Notice(error: error).post() + UINotificationFeedbackGenerator().notificationOccurred(.error) + } DDLogError("Translation failed: \(error)") } } From 32839436c9e2ca48e4bb667fc3552e24de1f764d Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 5 Jan 2026 17:28:53 -0500 Subject: [PATCH 4/8] Fix an issue with translation not restarted when cancelled --- .../UseCases/TranslationViewModel.swift | 9 +++++-- .../Detail/ReaderDetailViewController.swift | 26 +++++++++---------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift index 9ee7411b3eec..8b11a205126d 100644 --- a/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift +++ b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift @@ -36,7 +36,13 @@ public final class TranslationViewModel: ObservableObject { self.continuation = continuation // This will trigger the .translationTask in TranslationHostView - self.configuration = TranslationSession.Configuration(source: nil, target: targetLanguage) + 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: nil, target: targetLanguage) + } } } @@ -75,7 +81,6 @@ public final class TranslationViewModel: ObservableObject { } private func finish(with result: Result<[String], Error>) { - configuration = nil content = [] if let continuation { self.continuation = nil diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift index fb500afc71af..530c1f5e1696 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift @@ -796,8 +796,10 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { private func actuallyTranslatePost() async throws { guard let post else { return } - // Show spinner in navigation bar showTranslationSpinner() + defer { + hideTranslationSpinner() + } let translationResults = try await translationViewModel.translate( [post.postTitle ?? "", post.content ?? ""], @@ -810,25 +812,23 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight] webView.addSubview(blurView) - // Fast blur in UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseIn) { blurView.effect = blurEffect self.webView.alpha = 1.0 + } completion: { _ in + UIView.animate(withDuration: 0.33, delay: 0.33, options: .curveEaseOut) { + blurView.effect = nil + } completion: { _ in + blurView.removeFromSuperview() + } } - // Update the UI with translated content header.configure(for: post, title: translationResults[0]) - try await webView.setBodyHTML(translationResults[1]) - - // Blur out - UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseOut) { - blurView.effect = nil - } completion: { _ in - blurView.removeFromSuperview() + do { + try await webView.setBodyHTML(translationResults[1]) + } catch { + DDLogError("Failed to set HTML: \(error)") } - - // Hide spinner in navigation bar - hideTranslationSpinner() } private func configureRelatedPosts() { From 496e272e6ef8c46e77118dacefc72aeb923964a7 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 5 Jan 2026 17:54:27 -0500 Subject: [PATCH 5/8] Add TranslationAvailability to improve language detection and ensure it never restarts the translation if already started --- .../UseCases/TranslationViewModel.swift | 32 +++++++++++++------ .../ReaderPostActions/ReaderPostMenu.swift | 2 +- .../Detail/ReaderDetailViewController.swift | 28 +++++++++------- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift index 8b11a205126d..1dceb7c7b0a3 100644 --- a/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift +++ b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift @@ -28,7 +28,11 @@ public final class TranslationViewModel: ObservableObject { /// /// This method detects the source language automatically and translates each string /// in the content array independently. - public func translate(_ content: [String], to targetLanguage: Locale.Language = Locale.current.language) async throws -> [String] { + 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 @@ -41,25 +45,30 @@ public final class TranslationViewModel: ObservableObject { // in the Translation framework. self.configuration?.invalidate() } else { - self.configuration = TranslationSession.Configuration(source: nil, target: targetLanguage) + self.configuration = TranslationSession.Configuration(source: source, target: target) } } } /// Check if translation is available for the given content. - public func isTranslationAvailable(for content: String, to targetLanguage: Locale.Language = Locale.current.language) async -> Bool { + 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 - do { - let availability = LanguageAvailability() - let status = try await availability.status(for: content, to: targetLanguage) - return status == .installed || status == .supported - } catch { - return false + + 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 { @@ -89,6 +98,11 @@ public final class TranslationViewModel: ObservableObject { } } +public enum TranslationAvailability { + case unavailable + case available(sourceLanguage: Locale.Language, targetLanguage: Locale.Language) +} + // MARK: - TranslationHostView (SwiftUI) /// SwiftUI view that hosts translation functionality using .translationTask() diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift index 63b8ede020ae..f97570f57443 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift @@ -136,7 +136,7 @@ struct ReaderPostMenu { private var translate: UIAction? { guard #available(iOS 26, *), let detailVC = viewController as? ReaderDetailViewController, - detailVC.isTranslationAvailable else { + case .available = detailVC.translationAvailability else { return nil } diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift index 530c1f5e1696..f03980bf04fe 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift @@ -184,7 +184,8 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { }() private var translationAvailabilityTask: Task? - var isTranslationAvailable = false + private var isTranslating = false + var translationAvailability: TranslationAvailability = .unavailable private lazy var translationSpinner: UIBarButtonItem = { let activityIndicator = UIActivityIndicatorView(style: .medium) @@ -761,27 +762,24 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { return } translationAvailabilityTask?.cancel() - translationAvailabilityTask = Task { @MainActor in - self.isTranslationAvailable = await self.checkIsTranslationAvailable() + translationAvailabilityTask = Task { @MainActor [weak self] in + await self?.updateTranslationAvailability() } } @available(iOS 26, *) - private func checkIsTranslationAvailable() async -> Bool { - guard let post, let content = post.contentForDisplay() else { - return false + private func updateTranslationAvailability() async { + if let post, let content = post.contentForDisplay() { + translationAvailability = await translationViewModel.checkAvailability(for: content) } - return await translationViewModel.isTranslationAvailable(for: content) } @available(iOS 26, *) func translatePost() { Task { @MainActor in do { - isTranslationAvailable = false try await actuallyTranslatePost() } catch { - isTranslationAvailable = true if !(error is CancellationError) { Notice(error: error).post() UINotificationFeedbackGenerator().notificationOccurred(.error) @@ -794,15 +792,23 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { @available(iOS 26, *) @MainActor private func actuallyTranslatePost() async throws { - guard let post else { return } - + guard let post, case let .available(source, target) = translationAvailability else { + return + } + guard !isTranslating else { + return + } + isTranslating = true showTranslationSpinner() defer { + isTranslating = false hideTranslationSpinner() } let translationResults = try await translationViewModel.translate( [post.postTitle ?? "", post.content ?? ""], + from: source, + to: target ) // Create blur effect From 2581508847e4660a9ad92322f3fcfcac8333c3dc Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 5 Jan 2026 18:12:50 -0500 Subject: [PATCH 6/8] Improve blur --- .../Detail/ReaderDetailViewController.swift | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift index f03980bf04fe..165dbafbece7 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift @@ -814,19 +814,17 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { // Create blur effect let blurEffect = UIBlurEffect(style: .light) let blurView = UIVisualEffectView(effect: nil) - blurView.frame = webView.bounds + blurView.frame = view.bounds blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - webView.addSubview(blurView) + view.addSubview(blurView) - UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseIn) { + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn) { blurView.effect = blurEffect - self.webView.alpha = 1.0 + } + UIView.animate(withDuration: 0.25, delay: 0.15, options: .curveEaseOut) { + blurView.effect = nil } completion: { _ in - UIView.animate(withDuration: 0.33, delay: 0.33, options: .curveEaseOut) { - blurView.effect = nil - } completion: { _ in - blurView.removeFromSuperview() - } + blurView.removeFromSuperview() } header.configure(for: post, title: translationResults[0]) From 77adb041fe129dd8c02ae3464c5c135535a2c851 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 6 Jan 2026 11:18:25 -0500 Subject: [PATCH 7/8] Remove redundant Task --- .../WordPressIntelligence/UseCases/TranslationViewModel.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift index 1dceb7c7b0a3..d85d8ae30b69 100644 --- a/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift +++ b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift @@ -124,9 +124,7 @@ public struct TranslationHostView: View { Color.clear .frame(width: 0, height: 0) .translationTask(viewModel.configuration) { session in - Task { @MainActor in - await viewModel.performTranslation(session: session) - } + await viewModel.performTranslation(session: session) } } } From 14cb49088078a58abbee3689727fd7977212ec21 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 6 Jan 2026 11:20:58 -0500 Subject: [PATCH 8/8] Add comments --- .../ViewRelated/Reader/Detail/ReaderDetailViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift index 165dbafbece7..f5885bb7ace7 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift @@ -746,6 +746,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { forCellReuseIdentifier: ReaderDetailNoCommentCell.defaultReuseID) } + // Translation framework doesn't support UIKit, so we have to jump through the hoops. private func configureTranslationIfAvailable() { guard #available(iOS 26, *) else { return