diff --git a/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift new file mode 100644 index 000000000000..d85d8ae30b69 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/TranslationViewModel.swift @@ -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 + + // 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) {} diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index ddc650f27fae..8be3d8ef7efe 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -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] diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift index 12a8043b361a..f97570f57443 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, + 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 } @@ -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..f5885bb7ace7 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,37 @@ 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? + private var isTranslating = false + var translationAvailability: TranslationAvailability = .unavailable + + private lazy var translationSpinner: UIBarButtonItem = { + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.startAnimating() + return UIBarButtonItem(customView: activityIndicator) + }() + override func viewDidLoad() { super.viewDidLoad() @@ -174,6 +207,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { observeWebViewHeight() configureNotifications() configureCommentsTable() + configureTranslationIfAvailable() coordinator?.start() @@ -273,6 +307,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { header.configure(for: post) fetchLikes() fetchComments() + checkTranslationAvailability() if let postURLString = post.permaLink, let postURL = URL(string: postURLString) { @@ -470,6 +505,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { deinit { scrollObserver?.invalidate() toolbarUpdateTimer?.invalidate() + translationAvailabilityTask?.cancel() NotificationCenter.default.removeObserver(self) } @@ -710,6 +746,96 @@ 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 + } + 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 [weak self] in + await self?.updateTranslationAvailability() + } + } + + @available(iOS 26, *) + private func updateTranslationAvailability() async { + if let post, let content = post.contentForDisplay() { + translationAvailability = await translationViewModel.checkAvailability(for: content) + } + } + + @available(iOS 26, *) + func translatePost() { + Task { @MainActor in + do { + try await actuallyTranslatePost() + } catch { + if !(error is CancellationError) { + Notice(error: error).post() + UINotificationFeedbackGenerator().notificationOccurred(.error) + } + DDLogError("Translation failed: \(error)") + } + } + } + + @available(iOS 26, *) + @MainActor + private func actuallyTranslatePost() async throws { + 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 + let blurEffect = UIBlurEffect(style: .light) + let blurView = UIVisualEffectView(effect: nil) + blurView.frame = view.bounds + blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(blurView) + + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn) { + blurView.effect = blurEffect + } + UIView.animate(withDuration: 0.25, delay: 0.15, options: .curveEaseOut) { + blurView.effect = nil + } completion: { _ in + blurView.removeFromSuperview() + } + + header.configure(for: post, title: translationResults[0]) + do { + try await webView.setBodyHTML(translationResults[1]) + } catch { + DDLogError("Failed to set HTML: \(error)") + } + } + private func configureRelatedPosts() { relatedPostsTableView.isScrollEnabled = false relatedPostsTableView.separatorStyle = .none @@ -1171,6 +1297,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 +1476,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() ?? []