diff --git a/Packages/RelayInterface/Sources/RelayInterface/Protocols/TimelineViewModelProtocol.swift b/Packages/RelayInterface/Sources/RelayInterface/Protocols/TimelineViewModelProtocol.swift index 815fecb..a6b7f14 100644 --- a/Packages/RelayInterface/Sources/RelayInterface/Protocols/TimelineViewModelProtocol.swift +++ b/Packages/RelayInterface/Sources/RelayInterface/Protocols/TimelineViewModelProtocol.swift @@ -172,4 +172,88 @@ public protocol TimelineViewModelProtocol: AnyObject, Observable { /// /// - Parameter eventId: The Matrix event ID of the message to unpin. func unpin(eventId: String) async + + // MARK: - Translation + + /// A monotonically increasing counter that is bumped whenever any + /// per-message translation state changes. SwiftUI views read this in + /// their bodies to participate in observation; the underlying state + /// dictionary is intentionally not observed (high-churn, would blow + /// up the registrar). + var translationsVersion: UInt { get } + + /// The current translation state for a given message, or `.idle` if + /// the user hasn't requested a translation yet. + func translationState(for messageId: String) -> MessageTranslationState + + /// Detects the message's dominant language and reports whether it's + /// already in the user's "readable" set (system preferred languages + /// + enabled keyboard input sources). Used by the context menu to + /// hide the Translate item when there's nothing to do. + func canTranslateMessage(_ messageId: String) -> Bool + + /// Translates the message identified by `messageId` to the user's + /// locale, on-device, via Apple's Translation framework. Updates + /// ``translationState(for:)`` and bumps ``translationsVersion`` on + /// every state transition. + func translateMessage(_ messageId: String) async + + /// Drops any cached translation for `messageId`, returning the row + /// to its original-language presentation. + func clearTranslation(_ messageId: String) + + /// Bumped whenever the pending-translation queue changes (a new + /// request was enqueued, or a slot claimed one). Translation slots + /// in `TimelineView` observe this to know when to try pulling more + /// work from the queue. + var pendingTranslationQueueVersion: UInt { get } + + /// Atomically pops the next queued translation request, if any. Must + /// be called on the main actor. Translation slots in `TimelineView` + /// call this when they become idle to claim the next pending unit + /// of work; because the call is `@MainActor`-bound, two slots cannot + /// race for the same request. + @MainActor func claimNextTranslation() -> PendingTranslationRequest? + + /// Runs a translation against a specific previously-claimed request. + /// Updates ``translationState(for:)`` on completion (or failure) and + /// bumps ``translationsVersion``. + /// + /// - Parameters: + /// - request: The request previously returned from + /// ``claimNextTranslation()``. + /// - translate: Closure that performs the actual translation using + /// the SwiftUI-provided `TranslationSession`. Defined as a + /// closure so this protocol doesn't need to import the + /// Translation framework. + @MainActor func runTranslation( + for request: PendingTranslationRequest, + translate: @MainActor @escaping (String) async throws -> String + ) async +} + +/// Description of an in-flight translation request the timeline wants +/// performed. Consumed by `TimelineView`'s `.translationTask` modifier. +public struct PendingTranslationRequest: Sendable, Equatable { + public let messageId: String + public let sourceLanguageTag: String + public let targetLanguageTag: String + public init(messageId: String, sourceLanguageTag: String, targetLanguageTag: String) { + self.messageId = messageId + self.sourceLanguageTag = sourceLanguageTag + self.targetLanguageTag = targetLanguageTag + } +} + +/// Per-message translation state surfaced by ``TimelineViewModelProtocol``. +public enum MessageTranslationState: Sendable, Equatable { + /// No translation has been requested. + case idle + /// A translation is in flight (either model download or analysis). + case loading + /// The message has been translated. + case translated(text: String, sourceLanguageTag: String) + /// The last translation attempt failed; the body falls back to the + /// original. Stored so the UI can offer a retry. + case failed(reason: String) } diff --git a/Relay.xcodeproj/project.pbxproj b/Relay.xcodeproj/project.pbxproj index dd9f960..2f2f5ad 100644 --- a/Relay.xcodeproj/project.pbxproj +++ b/Relay.xcodeproj/project.pbxproj @@ -711,6 +711,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; @@ -756,6 +757,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; diff --git a/Relay/ViewModels/PreviewRoomPreviewViewModel.swift b/Relay/ViewModels/PreviewRoomPreviewViewModel.swift index 943605c..fb9a532 100644 --- a/Relay/ViewModels/PreviewRoomPreviewViewModel.swift +++ b/Relay/ViewModels/PreviewRoomPreviewViewModel.swift @@ -79,6 +79,18 @@ final class PreviewRoomPreviewViewModel: RoomPreviewViewModelProtocol, TimelineV func pin(eventId: String) async {} func unpin(eventId: String) async {} + var translationsVersion: UInt { 0 } + var pendingTranslationQueueVersion: UInt { 0 } + func translationState(for messageId: String) -> MessageTranslationState { .idle } + func canTranslateMessage(_ messageId: String) -> Bool { false } + func translateMessage(_ messageId: String) async {} + func clearTranslation(_ messageId: String) {} + @MainActor func claimNextTranslation() -> PendingTranslationRequest? { nil } + @MainActor func runTranslation( + for request: PendingTranslationRequest, + translate: @MainActor @escaping (String) async throws -> String + ) async {} + static let sampleMessages: [TimelineMessage] = [ TimelineMessage( id: "$msg1", diff --git a/Relay/ViewModels/PreviewTimelineViewModel.swift b/Relay/ViewModels/PreviewTimelineViewModel.swift index bc0296b..5e17b5d 100644 --- a/Relay/ViewModels/PreviewTimelineViewModel.swift +++ b/Relay/ViewModels/PreviewTimelineViewModel.swift @@ -60,6 +60,19 @@ final class PreviewTimelineViewModel: TimelineViewModelProtocol { func pin(eventId: String) async {} func unpin(eventId: String) async {} + // MARK: - Translation (stubs) + var translationsVersion: UInt = 0 + var pendingTranslationQueueVersion: UInt = 0 + func translationState(for messageId: String) -> MessageTranslationState { .idle } + func canTranslateMessage(_ messageId: String) -> Bool { false } + func translateMessage(_ messageId: String) async {} + func clearTranslation(_ messageId: String) {} + @MainActor func claimNextTranslation() -> PendingTranslationRequest? { nil } + @MainActor func runTranslation( + for request: PendingTranslationRequest, + translate: @MainActor @escaping (String) async throws -> String + ) async {} + nonisolated static let sampleMessages: [TimelineMessage] = [ .init(id: "1", senderID: "@alice:matrix.org", senderDisplayName: "Alice", body: "Hey, has anyone tried the **new build**?", diff --git a/Relay/Views/Message/MessageBubbleContent.swift b/Relay/Views/Message/MessageBubbleContent.swift index a9e4efb..14ae621 100644 --- a/Relay/Views/Message/MessageBubbleContent.swift +++ b/Relay/Views/Message/MessageBubbleContent.swift @@ -28,6 +28,12 @@ struct MessageBubbleContent: View { /// The timeline message to render. let message: TimelineMessage + /// Per-message translation state. When `.translated`, the bubble body + /// renders the translated plain text instead of the original; all other + /// states render the original body. Defaults to `.idle` so non-timeline + /// callers (pinned messages, search results) are unaffected. + var translation: MessageTranslationState = .idle + /// Called to present the emoji reaction picker from within rich text context menus. var onPresentReactionPicker: (() -> Void)? @@ -204,6 +210,14 @@ struct MessageBubbleContent: View { /// The parsed message body as an `NSAttributedString`. Prefers `formatted_body` /// (HTML) when available, falling back to inline Markdown parsing of `body`. private var parsedBody: NSAttributedString { + // When translated, render the translated plain text. The source HTML + // is intentionally discarded — Apple's Translation framework returns + // plain `String`, so we re-parse it as Markdown for inline styling. + if case .translated(let text, _) = translation { + return Self.markdownCache.value(forKey: text) { + NSAttributedString(matrixMarkdown: text) + } ?? NSAttributedString(string: text) + } if let html = message.formattedBody { let cached = Self.htmlCache.value(forKey: html) { NSAttributedString(matrixHTML: html) diff --git a/Relay/Views/Message/MessageView.swift b/Relay/Views/Message/MessageView.swift index 3ca9385..5d95874 100644 --- a/Relay/Views/Message/MessageView.swift +++ b/Relay/Views/Message/MessageView.swift @@ -12,16 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. +import OSLog import RelayInterface import SwiftUI +private let logger = Logger(subsystem: "Relay", category: "MessageView.Translate") + /// Renders a single chat message row with avatar, sender name, bubble content, /// reply context, reactions, and emoji picker. This is the full "chrome" wrapper /// around ``MessageBubbleContent``. /// /// For contexts that only need the bubble content without interactive chrome /// (e.g. pinned messages, search results), use ``MessageBubbleContent`` directly. -struct MessageView: View { +struct MessageView: View { // swiftlint:disable:this type_body_length /// The timeline message to render. let message: TimelineMessage @@ -46,6 +49,12 @@ struct MessageView: View { return message.isOutgoing == replyIsOutgoing } + /// Per-message translation state. When `.translated`, the rendered + /// body and parsed Markdown/HTML come from the translation; in all + /// other states the original message body is used. The view also + /// shows a translate-glyph badge when `.translated` or `.loading`. + var translation: MessageTranslationState = .idle + @AppStorage("appearance.coloredBubbles") private var coloredBubbles = false @Environment(\.timelineActions) private var actions @Environment(\.swipeOffset) private var swipeOffset @@ -55,7 +64,7 @@ struct MessageView: View { /// Whether reaction badges overlap the top edge, requiring extra top /// padding to avoid clipping. private var hasTopOverlay: Bool { - !message.reactions.isEmpty + !message.reactions.isEmpty || showsTranslationBadge } var body: some View { @@ -129,6 +138,7 @@ struct MessageView: View { private var messageBubble: some View { MessageBubbleContent( message: message, + translation: translation, onPresentReactionPicker: { presentReactionPickerForBubble() } @@ -154,6 +164,14 @@ struct MessageView: View { ) } } + // Translation glyph on the corner opposite the reaction badges so + // the two never collide. + .overlay(alignment: message.isOutgoing ? .topTrailing : .topLeading) { + if showsTranslationBadge { + translationBadge + .offset(x: message.isOutgoing ? 6 : -6, y: -11) + } + } .padding(.top, hasTopOverlay ? 11 : 0) .onGeometryChange(for: CGRect.self) { proxy in proxy.frame(in: .named("timeline")) @@ -192,6 +210,37 @@ struct MessageView: View { .allowsHitTesting(swipeIsLocked) } + // MARK: - Translation Badge + + /// Whether the corner badge should be shown for translation state. + /// Only `.translated` and `.loading` get a badge; `.idle` and + /// `.failed` are visually invisible (failures surface as a toast). + private var showsTranslationBadge: Bool { + switch translation { + case .translated, .loading: true + case .idle, .failed: false + } + } + + /// Translation badge — small `translate` glyph over a tinted disc. + /// Loading state swaps the glyph for a spinner so the user sees + /// progress. + @ViewBuilder + private var translationBadge: some View { + switch translation { + case .loading: + ProgressView() + .controlSize(.mini) + .frame(width: 16, height: 16) + .background(.tint, in: Circle()) + default: + Image(systemName: "translate") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 16, height: 16) + .background(.tint, in: Circle()) + } + } } // MARK: - Previews diff --git a/Relay/Views/Timeline/MessageGrouping.swift b/Relay/Views/Timeline/MessageGrouping.swift index b401af8..9b9b914 100644 --- a/Relay/Views/Timeline/MessageGrouping.swift +++ b/Relay/Views/Timeline/MessageGrouping.swift @@ -51,16 +51,24 @@ struct MessageRow: Identifiable, Equatable { let message: TimelineMessage let info: MessageGroupInfo let isPaginationTrigger: Bool + /// Per-message translation state baked into the row so that the + /// table-view diff (`MessageRow == MessageRow`) catches translation + /// changes and reloads just the affected row. Without this, a + /// translation completing wouldn't differ in the row's payload and + /// `NSTableView`'s `reloadData(forRowIndexes:)` short-circuit would + /// skip the visible row. + let translation: MessageTranslationState /// When non-nil, this row represents a collapsed group of consecutive /// system events. The ``message`` field holds the first event in the /// run (used for ID stability and date header computation). let collapsedSystemEvents: [TimelineMessage]? - init(message: TimelineMessage, info: MessageGroupInfo, isPaginationTrigger: Bool, collapsedSystemEvents: [TimelineMessage]? = nil) { + init(message: TimelineMessage, info: MessageGroupInfo, isPaginationTrigger: Bool, translation: MessageTranslationState = .idle, collapsedSystemEvents: [TimelineMessage]? = nil) { self.message = message self.info = info self.isPaginationTrigger = isPaginationTrigger + self.translation = translation self.collapsedSystemEvents = collapsedSystemEvents } @@ -70,6 +78,7 @@ struct MessageRow: Identifiable, Equatable { lhs.message == rhs.message && lhs.info == rhs.info && lhs.isPaginationTrigger == rhs.isPaginationTrigger + && lhs.translation == rhs.translation && lhs.collapsedSystemEvents == rhs.collapsedSystemEvents } } @@ -92,7 +101,8 @@ enum MessageRowBuilder { /// runs so that each collapsed group stays within a single date section. static func buildRows( for messages: [TimelineMessage], - hasReachedStart: Bool + hasReachedStart: Bool, + translationState: (String) -> MessageTranslationState = { _ in .idle } ) -> [MessageRow] { guard !messages.isEmpty else { return [] } let calendar = Calendar.current @@ -164,7 +174,8 @@ enum MessageRowBuilder { result.append(MessageRow( message: message, info: info, - isPaginationTrigger: false + isPaginationTrigger: false, + translation: translationState(message.id) )) } diff --git a/Relay/Views/Timeline/TimelineLazyVStackView.swift b/Relay/Views/Timeline/TimelineLazyVStackView.swift index e935b49..892cf70 100644 --- a/Relay/Views/Timeline/TimelineLazyVStackView.swift +++ b/Relay/Views/Timeline/TimelineLazyVStackView.swift @@ -128,6 +128,8 @@ struct TimelineLazyVStackView: View { isUnreadDivider: isUnreadDivider(for: row), showURLPreviews: showURLPreviews, onAppear: { _ in }, + canTranslateProvider: { viewModel.canTranslateMessage($0) }, + translationsVersion: viewModel.translationsVersion, swipeOffset: swipeState.swipingMessageId == row.id ? swipeState.offset : 0, swipeIsLocked: swipeState.swipingMessageId == row.id && swipeState.isLocked ) diff --git a/Relay/Views/Timeline/TimelineMessageContextMenu.swift b/Relay/Views/Timeline/TimelineMessageContextMenu.swift index 5805838..c758e32 100644 --- a/Relay/Views/Timeline/TimelineMessageContextMenu.swift +++ b/Relay/Views/Timeline/TimelineMessageContextMenu.swift @@ -65,4 +65,9 @@ enum TimelineRowContextAction { case togglePin(String) case edit(TimelineMessage) case delete(TimelineMessage) + /// Run an on-device translation of the message into the user's locale. + case translate(TimelineMessage) + /// Drop a previously-applied translation, returning the row to the + /// original-language body. + case showOriginal(TimelineMessage) } diff --git a/Relay/Views/Timeline/TimelineRowView.swift b/Relay/Views/Timeline/TimelineRowView.swift index 94a6ab0..7d4b6f4 100644 --- a/Relay/Views/Timeline/TimelineRowView.swift +++ b/Relay/Views/Timeline/TimelineRowView.swift @@ -58,6 +58,18 @@ struct TimelineRowView: View, Equatable { /// Called when this row appears on screen (for read receipt advancement). var onAppear: (MessageRow) -> Void + /// Returns the current per-message translation state. Closure-typed so + /// the row doesn't need a reference to the whole timeline view model. + var translationStateProvider: (String) -> MessageTranslationState = { _ in .idle } + /// Returns whether the Translate item should appear in the context + /// menu for this message — false for non-text kinds, very short + /// bodies, or text whose detected language is in the user's + /// readable set. + var canTranslateProvider: (String) -> Bool = { _ in false } + /// Bumped by the view model whenever any translation state changes; + /// reading it in `body` participates in observation so the row + /// re-renders on state transitions. + var translationsVersion: UInt = 0 /// The horizontal swipe offset for this row, or 0 when not swiped. /// Pre-computed by the parent renderer from the shared swipe state so @@ -90,6 +102,8 @@ struct TimelineRowView: View, Equatable { lhs.row.message == rhs.row.message && lhs.row.info == rhs.row.info && lhs.row.isPaginationTrigger == rhs.row.isPaginationTrigger + && lhs.row.translation == rhs.row.translation + && lhs.translationsVersion == rhs.translationsVersion && lhs.isNewlyAppended == rhs.isNewlyAppended && lhs.swipeOffset == rhs.swipeOffset && lhs.swipeIsLocked == rhs.swipeIsLocked @@ -164,10 +178,17 @@ struct TimelineRowView: View, Equatable { message: message, isLastInGroup: info.isLastInGroup, showSenderName: info.showSenderName, - replyIsAdjacentAbove: info.replyIsAdjacentAbove + replyIsAdjacentAbove: info.replyIsAdjacentAbove, + // Translation state is baked into `row.translation` by the + // row builder, so reading it directly keeps the row the + // single source of truth when the table reloads it after a + // translation lands. + translation: row.translation ) .id(message.id) - .help(message.formattedTime) + // When translated, hover tooltip surfaces the original body + // (more useful than the timestamp on a translated row). + .help(row.translation.hoverHelpText(originalBody: message.body, fallback: message.formattedTime)) .onAppear { onAppear(row) } .onGeometryChange(for: CGRect.self) { proxy in proxy.frame(in: .named("timeline")) @@ -197,6 +218,40 @@ struct TimelineRowView: View, Equatable { ForEach(TimelineMessageContextMenu.entries(for: message, permissions: actions.permissions).enumerated(), id: \.offset) { _, entry in contextMenuEntry(entry) } + translationMenuEntry + } + + /// Translation toggle, appended once at the end of the row's context + /// menu. Hidden for non-translatable messages (already-readable + /// language, non-text kinds, very short text). The state determines + /// the label: idle/failed → "Translate", loading → disabled + /// "Translating…", translated → "Show Original". + @ViewBuilder + private var translationMenuEntry: some View { + switch row.translation { + case .idle, .failed: + if canTranslateProvider(message.id) { + Divider() + Button { + actions.contextAction(.translate(message)) + } label: { + Label("Translate Message", systemImage: "translate") + } + } + case .loading: + Divider() + Button { } label: { + Label("Translating…", systemImage: "translate") + } + .disabled(true) + case .translated: + Divider() + Button { + actions.contextAction(.showOriginal(message)) + } label: { + Label("Show Original", systemImage: "translate") + } + } } @ViewBuilder @@ -281,11 +336,23 @@ func dateSectionLabel(for date: Date) -> String { } } +extension MessageTranslationState { + /// Hover tooltip text. When translated we show the original body so + /// users can compare; otherwise we fall back to the message's + /// formatted timestamp like normal. + func hoverHelpText(originalBody: String, fallback: String) -> String { + if case .translated = self { + return originalBody + } + return fallback + } +} + // MARK: - Previews private func previewRow(_ message: TimelineMessage, info: MessageGroupInfo = .default) -> some View { TimelineRowView( - row: .init(message: message, info: info, isPaginationTrigger: false), + row: .init(message: message, info: info, isPaginationTrigger: false, translation: .idle), isNewlyAppended: false, isHighlighted: false, isUnreadDivider: false, diff --git a/Relay/Views/Timeline/TimelineTableViewRepresentable.swift b/Relay/Views/Timeline/TimelineTableViewRepresentable.swift index 0dd1d1b..3fa78e6 100644 --- a/Relay/Views/Timeline/TimelineTableViewRepresentable.swift +++ b/Relay/Views/Timeline/TimelineTableViewRepresentable.swift @@ -85,6 +85,8 @@ struct TimelineTableViewRepresentable: NSViewControllerRepresentable { isUnreadDivider: showUnreadMarker && row.message.id == firstUnreadMessageId, showURLPreviews: showURLPreviews, onAppear: onAppear, + canTranslateProvider: { viewModel.canTranslateMessage($0) }, + translationsVersion: viewModel.translationsVersion, swipeOffset: swipeOffset, swipeIsLocked: swipeIsLocked, injectedActions: actions diff --git a/Relay/Views/Timeline/TimelineView.swift b/Relay/Views/Timeline/TimelineView.swift index 9f8e6e6..4814a4c 100644 --- a/Relay/Views/Timeline/TimelineView.swift +++ b/Relay/Views/Timeline/TimelineView.swift @@ -15,6 +15,7 @@ import OSLog import RelayInterface import SwiftUI +import Translation import UniformTypeIdentifiers private let logger = Logger(subsystem: "Relay", category: "Timeline") @@ -78,6 +79,13 @@ struct TimelineView: View { // swiftlint:disable:this type_body_length @State private var memberRefreshTask: Task? @State private var cachedMessageRows: [MessageRow] @State private var isTimelineDropTargeted = false + /// Number of parallel translation slots. Each slot owns its own + /// `TranslationSession` via `.translationTask`, so up to this many + /// translations can run at once. 3 is a reasonable balance — the + /// Translation framework will happily run several sessions + /// concurrently, but we don't want to spam the system if the user + /// triggers Translate-all someday. + private static let translationSlotCount = 3 @State private var timelineActionsRef = TimelineActions() @State private var successorRoomId: String? @State private var reactionPickerMessageId: String? @@ -249,6 +257,25 @@ struct TimelineView: View { // swiftlint:disable:this type_body_length .transition(.opacity) } } + // MARK: Translation driver pool + // + // `.translationTask` is the only public entry point that handles + // model downloads, but it accepts only one `Configuration` per + // modifier. To run translations in parallel we render a fixed + // pool of `TranslationSlot` subviews, each carrying its own + // configuration state and its own `.translationTask`. Each slot + // pulls the next pending request from the view model when idle, + // so user-triggered translations never queue behind one another. + .background { + ZStack { + ForEach(0.. MessageTranslationState { .idle } + public func canTranslateMessage(_ messageId: String) -> Bool { false } + public func translateMessage(_ messageId: String) async {} + public func clearTranslation(_ messageId: String) {} + @MainActor public func claimNextTranslation() -> PendingTranslationRequest? { nil } + @MainActor public func runTranslation( + for request: PendingTranslationRequest, + translate: @MainActor @escaping (String) async throws -> String + ) async {} } diff --git a/RelayKit/Services/TimelineViewModel.swift b/RelayKit/Services/TimelineViewModel.swift index 91a8e29..1f07941 100644 --- a/RelayKit/Services/TimelineViewModel.swift +++ b/RelayKit/Services/TimelineViewModel.swift @@ -17,6 +17,7 @@ import AVFoundation import CoreGraphics import Foundation import ImageIO +import NaturalLanguage import RelayInterface import UniformTypeIdentifiers import os @@ -39,6 +40,14 @@ public final class TimelineViewModel: TimelineViewModelProtocol { public var firstUnreadMessageId: String? public private(set) var typingUsers: [TypingUser] = [] public private(set) var timelineFocus: TimelineFocusState = .live + public private(set) var translationsVersion: UInt = 0 + public private(set) var pendingTranslationQueueVersion: UInt = 0 + /// FIFO queue of translation requests waiting for a free slot. Drained + /// by ``claimNextTranslation()``; size of pool lives in `TimelineView`. + private var pendingTranslationQueue: [PendingTranslationRequest] = [] + /// MessageIds currently being translated by some slot. Used to dedup + /// "translate again while it's still running". + private var inFlightTranslations: Set = [] private let room: Room private let roomId: String @@ -95,6 +104,17 @@ public final class TimelineViewModel: TimelineViewModelProtocol { @ObservationIgnored private var paginationHandle: TaskHandle? @ObservationIgnored private var typingHandle: TaskHandle? + // MARK: - Translation + + /// Per-message translation state. `@ObservationIgnored` because the + /// dictionary churns on every result and observation-tracking it + /// would re-evaluate every body of every visible row on each tick. + /// `translationsVersion` is the observed signal SwiftUI listens to. + @ObservationIgnored + private var translationStates: [String: MessageTranslationState] = [:] + @ObservationIgnored + private lazy var translator = MessageTranslator() + /// Creates a new view model for the given room. /// /// - Parameters: @@ -1353,4 +1373,112 @@ public final class TimelineViewModel: TimelineViewModelProtocol { } } } + + // MARK: - Translation + + public func translationState(for messageId: String) -> MessageTranslationState { + translationStates[messageId] ?? .idle + } + + /// Whether the Translate affordance should be shown for this message. + /// Permissive on purpose — surfaces the action for any plain-text + /// kind with non-empty body. We deliberately skip language pre- + /// detection here: NLLanguageRecognizer mis-classifies short + /// messages with common loanwords often enough that gating the UI + /// on it leaves users wondering why the button vanished. If the + /// detected language turns out to match the user's readable set, + /// `translateMessage(_:)` short-circuits with `.alreadyReadable` + /// and silently keeps state at `.idle`. + public func canTranslateMessage(_ messageId: String) -> Bool { + guard let message = messageCache[messageId] else { return false } + switch message.kind { + case .text, .emote, .notice: + break + default: + return false + } + return !message.body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + public func translateMessage(_ messageId: String) async { + guard let body = messageCache[messageId]?.body, !body.isEmpty else { return } + + // Dedup: if this message is already queued or actively running, + // ignore. (User clicked Translate twice on the same row.) + if inFlightTranslations.contains(messageId) + || pendingTranslationQueue.contains(where: { $0.messageId == messageId }) + { + return + } + + let detectedSource: Locale.Language + do { + detectedSource = try translator.detectSourceLanguage(in: body) + } catch is MessageTranslator.DetectionError { + // .alreadyReadable / .undetectable / .empty all mean "no + // user-visible translation needed". Don't badge the row. + translationStates.removeValue(forKey: messageId) + translationsVersion &+= 1 + return + } catch { + translationStates[messageId] = .failed(reason: error.localizedDescription) + translationsVersion &+= 1 + return + } + + // Mark loading and enqueue. The SwiftUI translation slots in + // `TimelineView` watch `pendingTranslationQueueVersion` and + // call `claimNextTranslation()` when they have free capacity. + translationStates[messageId] = .loading + let request = PendingTranslationRequest( + messageId: messageId, + sourceLanguageTag: detectedSource.minimalIdentifier, + targetLanguageTag: translator.targetLanguage.minimalIdentifier + ) + pendingTranslationQueue.append(request) + pendingTranslationQueueVersion &+= 1 + translationsVersion &+= 1 + } + + @MainActor public func claimNextTranslation() -> PendingTranslationRequest? { + guard !pendingTranslationQueue.isEmpty else { return nil } + let request = pendingTranslationQueue.removeFirst() + inFlightTranslations.insert(request.messageId) + pendingTranslationQueueVersion &+= 1 + return request + } + + @MainActor public func runTranslation( + for request: PendingTranslationRequest, + translate: @MainActor @escaping (String) async throws -> String + ) async { + defer { + inFlightTranslations.remove(request.messageId) + translationsVersion &+= 1 + } + guard let body = messageCache[request.messageId]?.body else { + translationStates.removeValue(forKey: request.messageId) + return + } + + do { + let translated = try await translate(body) + translationStates[request.messageId] = .translated( + text: translated, + sourceLanguageTag: request.sourceLanguageTag + ) + } catch { + translationStates[request.messageId] = .failed(reason: error.localizedDescription) + timelineLogger.warning("Translation failed for \(request.sourceLanguageTag, privacy: .public)→\(request.targetLanguageTag, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + public func clearTranslation(_ messageId: String) { + guard translationStates.removeValue(forKey: messageId) != nil else { return } + translationsVersion &+= 1 + } } + +/// Logger for translation flow — separate from the file-level logger +/// so the diagnostic surface is easy to filter in Console. +private let timelineLogger = Logger(subsystem: "RelayKit", category: "Timeline.Translation") diff --git a/RelayKit/Translation/MessageTranslator.swift b/RelayKit/Translation/MessageTranslator.swift new file mode 100644 index 0000000..b660491 --- /dev/null +++ b/RelayKit/Translation/MessageTranslator.swift @@ -0,0 +1,119 @@ +// Copyright 2026 Link Dupont +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import NaturalLanguage +import Translation + +/// Source-language detection + readable-language heuristics for the +/// per-message translation feature. Does **not** drive translation +/// itself — that's done in the SwiftUI layer via `.translationTask`, +/// which is the only public entry point that handles the system +/// download prompt for missing language models. +/// +/// Responsibilities: +/// +/// - Detect the dominant language of a message body +/// (`NLLanguageRecognizer`, on-device). +/// - Build a "readable languages" set from the user's preferred +/// languages + every enabled keyboard input source so we can skip +/// translation when the source is something the user already reads. +/// - Convert detected source language to a normalised +/// `Locale.Language` for handoff to `TranslationSession.Configuration`. +@MainActor +public final class MessageTranslator { + /// The user's locale; the default translation target. + public let targetLanguage: Locale.Language + + /// Languages the user can already read on this Mac, derived from + /// `Locale.preferredLanguages` + enabled keyboard input sources. + public private(set) var readableLanguages: Set + + public init(targetLocale: Locale = .current) { + // Strip the region. Apple's Translation framework supports a + // fixed set of base languages (en, fr, de, es, ja…); region + // variants like `en-CA` or `fr-FR` sometimes resolve and + // sometimes don't. Passing the bare languageCode avoids the + // gamble — `en-CA` → `en` always works because the framework + // ships an `en` model. + let base: Locale.Language + if let code = targetLocale.language.languageCode { + base = Locale.Language(languageCode: code) + } else { + base = targetLocale.language + } + self.targetLanguage = base + self.readableLanguages = Self.computeReadableLanguages(target: base) + } + + public func refreshReadableLanguages() { + readableLanguages = Self.computeReadableLanguages(target: targetLanguage) + } + + public enum DetectionError: Swift.Error, LocalizedError { + case empty + case undetectable + case alreadyReadable(Locale.Language) + + public var errorDescription: String? { + switch self { + case .empty: + return "Message body was empty." + case .undetectable: + return "Couldn't detect the message's language." + case .alreadyReadable(let lang): + return "Already in a language you read (\(lang.minimalIdentifier))." + } + } + } + + /// Decides whether `text` warrants a translation request. If yes, + /// returns the detected source language; otherwise throws an + /// explanatory `DetectionError`. Caller plugs the result into a + /// `TranslationSession.Configuration`. + public func detectSourceLanguage(in text: String) throws -> Locale.Language { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw DetectionError.empty + } + + let recognizer = NLLanguageRecognizer() + recognizer.processString(trimmed) + guard let dominant = recognizer.dominantLanguage else { + throw DetectionError.undetectable + } + + let language = Locale.Language(identifier: dominant.rawValue) + if readableLanguages.contains(where: { $0.minimalIdentifier == language.minimalIdentifier }) { + throw DetectionError.alreadyReadable(language) + } + return language + } + + // MARK: - Readable-language computation + + /// The user's "readable" set. We deliberately keep this minimal — + /// just the current locale's language. Earlier versions also pulled + /// in `Locale.preferredLanguages` and every enabled keyboard input + /// source's claimed languages, but a single Latin-script keyboard + /// can advertise dozens of minor European languages it loosely + /// supports (German, Catalan, Swiss German, Basque, Sámi variants…), + /// which made the recogniser's `de` detection collide with the set + /// and silently skip translation. Sticking to `Locale.current` keeps + /// the heuristic honest: only the language the user has clearly + /// chosen for system text is treated as already-readable. + nonisolated private static func computeReadableLanguages(target: Locale.Language) -> Set { + [target] + } +}