diff --git a/Packages/RelayInterface/Sources/RelayInterface/Protocols/TimelineViewModelProtocol.swift b/Packages/RelayInterface/Sources/RelayInterface/Protocols/TimelineViewModelProtocol.swift index ba28d6d..dddb67f 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 68f8afd..2540b7c 100644 --- a/Relay.xcodeproj/project.pbxproj +++ b/Relay.xcodeproj/project.pbxproj @@ -532,6 +532,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 12; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; @@ -578,6 +579,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 12; 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 fe161ba..27cd106 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 bcba6aa..252e688 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/MessageView.swift b/Relay/Views/Message/MessageView.swift index 7eaaa10..b193602 100644 --- a/Relay/Views/Message/MessageView.swift +++ b/Relay/Views/Message/MessageView.swift @@ -12,9 +12,12 @@ // 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 bubble for a timeline message, with support for text, images, /// emotes, special types (encrypted, redacted, etc.), reactions, and inline reply context. struct MessageView: View { // swiftlint:disable:this type_body_length @@ -53,6 +56,24 @@ struct MessageView: View { // swiftlint:disable:this type_body_length /// Parent-driven reaction picker (e.g. SwiftUI row context menu). Ignored when `false`. var triggerReactionPickerFromParent: Binding = .constant(false) + /// 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 + + /// Whether this message is in a state where Translate is meaningful + /// (the language is detectable, isn't already in the user's + /// readable set, etc.). When `false` and the message isn't + /// already translated, we hide the long-press action. + var canTranslate: Bool = false + + /// Toggle action invoked from the long-press popover. Wired by the + /// caller to either translate the message (when `.idle`/`.failed`) + /// or revert to the original (when `.translated`); the popover's + /// label switches automatically based on ``translation``. + var onTranslationAction: (() -> Void)? + @AppStorage("appearance.coloredBubbles") private var coloredBubbles = false @Environment(\.swipeOffset) private var swipeOffset @State private var showEmojiPicker = false @@ -99,9 +120,13 @@ struct MessageView: View { // swiftlint:disable:this type_body_length if message.isHighlighted { highlightBadge .offset(x: 4, y: -4) + } else if showsTranslationBadge { + translationBadge + .padding(4) + .offset(x: 8, y: -8) } } - .padding(.top, message.isHighlighted && !showSenderName ? 4 : 0) + .padding(.top, (message.isHighlighted && !showSenderName) ? 4 : (showsTranslationBadge && !showSenderName ? 10 : 0)) .padding(message.replyDetail != nil ? 2 : 0) .background { if message.replyDetail != nil { @@ -117,10 +142,13 @@ struct MessageView: View { // swiftlint:disable:this type_body_length attachmentAnchor: .point(message.isOutgoing ? .topLeading : .topTrailing), arrowEdge: .top ) { - EmojiPickerPopover { emoji in - onToggleReaction?(emoji) - showEmojiPicker = false - } + EmojiPickerPopover( + onSelect: { emoji in + onToggleReaction?(emoji) + showEmojiPicker = false + }, + trailingAction: translationTrailingAction + ) } } } @@ -361,7 +389,17 @@ struct MessageView: View { // swiftlint:disable:this type_body_length /// The parsed message body as an `NSAttributedString`. Prefers `formatted_body` /// (HTML) when available, falling back to inline Markdown parsing of `body`. + /// When the message has been translated, renders the translated plain + /// text instead — the source HTML is intentionally discarded since + /// Apple's Translation framework returns plain `String` (the + /// `attributedSourceText` variants are macOS 26.4 only and add layout + /// complexity we're skipping in v1). private var parsedBody: NSAttributedString { + 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) @@ -418,6 +456,64 @@ struct MessageView: View { // swiftlint:disable:this type_body_length .background(.red, in: Circle()) } + /// Builds the long-press popover's translate action. Returns `nil` + /// when there's nothing the user can do — non-translatable text, + /// already-readable language, etc. — so the divider+button only + /// appears when meaningful. + private var translationTrailingAction: EmojiPickerPopover.TrailingAction? { + guard let perform = onTranslationAction else { return nil } + switch translation { + case .translated: + return EmojiPickerPopover.TrailingAction( + label: "Show Original", + systemImage: "translate", + perform: { perform(); showEmojiPicker = false } + ) + case .loading: + // No actionable button while a translation is in flight; + // the corner badge shows progress. + return nil + case .idle, .failed: + guard canTranslate else { return nil } + return EmojiPickerPopover.TrailingAction( + label: "Translate", + systemImage: "translate", + perform: { perform(); showEmojiPicker = false } + ) + } + } + + /// 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. + /// Mirrors the geometry of ``highlightBadge`` so they read as a + /// matching set. 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: - Bubble Color /// Whether this bubble should render with white text (outgoing, or colored incoming). diff --git a/Relay/Views/Pickers/EmojiPickerPopover.swift b/Relay/Views/Pickers/EmojiPickerPopover.swift index c794c85..8e8ac3d 100644 --- a/Relay/Views/Pickers/EmojiPickerPopover.swift +++ b/Relay/Views/Pickers/EmojiPickerPopover.swift @@ -23,6 +23,19 @@ struct EmojiPickerPopover: View { /// Called with the selected emoji string when the user taps an emoji. let onSelect: (String) -> Void + /// Optional trailing action button — used by long-pressed messages to + /// expose extra non-reaction affordances such as on-device translation. + /// Renders after the character-palette button when non-nil. + var trailingAction: TrailingAction? + + /// Description of an extra action to render at the trailing edge of + /// the popover (e.g. translate / show original). + struct TrailingAction { + let label: String + let systemImage: String + let perform: () -> Void + } + @State private var openCharacterPalette = false private static let emoji: [String] = [ @@ -50,6 +63,24 @@ struct EmojiPickerPopover: View { .contentShape(Circle()) } .buttonStyle(.plain) + + if let action = trailingAction { + Divider() + .frame(height: 20) + .padding(.horizontal, 2) + + Button { + action.perform() + } label: { + Image(systemName: action.systemImage) + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 32, height: 32) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help(action.label) + } } .padding(.horizontal, 4) .padding(.vertical, 2) diff --git a/Relay/Views/Timeline/MessageGrouping.swift b/Relay/Views/Timeline/MessageGrouping.swift index 34f6be3..9ae1c88 100644 --- a/Relay/Views/Timeline/MessageGrouping.swift +++ b/Relay/Views/Timeline/MessageGrouping.swift @@ -45,6 +45,13 @@ 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 var id: String { message.id } @@ -52,6 +59,7 @@ struct MessageRow: Identifiable, Equatable { lhs.message == rhs.message && lhs.info == rhs.info && lhs.isPaginationTrigger == rhs.isPaginationTrigger + && lhs.translation == rhs.translation } } @@ -63,7 +71,8 @@ extension TimelineView { /// representable so each cell receives its own lightweight `MessageRow`. 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 @@ -129,7 +138,8 @@ extension TimelineView { result.append(MessageRow( message: message, info: info, - isPaginationTrigger: false + isPaginationTrigger: false, + translation: translationState(message.id) )) } return result diff --git a/Relay/Views/Timeline/TimelineMessageContextMenu.swift b/Relay/Views/Timeline/TimelineMessageContextMenu.swift index e33e85c..488d2e9 100644 --- a/Relay/Views/Timeline/TimelineMessageContextMenu.swift +++ b/Relay/Views/Timeline/TimelineMessageContextMenu.swift @@ -54,4 +54,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 01ce1da..5473e44 100644 --- a/Relay/Views/Timeline/TimelineRowView.swift +++ b/Relay/Views/Timeline/TimelineRowView.swift @@ -64,6 +64,18 @@ struct TimelineRowView: View, Equatable { var onAppear: (MessageRow) -> Void var onContextAction: (TimelineRowContextAction) -> Void var onHighlightDismissed: () -> 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 /// Observable swipe state from the table view controller. When the user /// swipes this row, the offset and reply arrow are rendered here. @@ -82,6 +94,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.showUnreadMarker == rhs.showUnreadMarker && lhs.firstUnreadMessageId == rhs.firstUnreadMessageId @@ -185,6 +199,15 @@ struct TimelineRowView: View, Equatable { y: message.isOutgoing ? 0 : 8) } + // Translation state is baked into `row.translation` by + // `TimelineView.rebuildCachedRows`. Reading it directly + // (rather than via the view-model closure) means the row + // re-renders correctly when the table reloads it after a + // translation lands — the new MessageRow value is the + // single source of truth, so observation indirection + // can't go stale. + let translation = row.translation + MessageView( message: message, isLastInGroup: info.isLastInGroup, @@ -204,11 +227,24 @@ struct TimelineRowView: View, Equatable { onRoomTap: onRoomTap, currentUserID: currentUserID, onMessageContextAction: onContextAction, - triggerReactionPickerFromParent: $triggerReactionPicker + triggerReactionPickerFromParent: $triggerReactionPicker, + translation: translation, + canTranslate: canTranslateProvider(message.id), + onTranslationAction: { + // Single callback toggles based on current state: + // .translated → revert; otherwise → translate. + if case .translated = row.translation { + onContextAction(.showOriginal(message)) + } else { + onContextAction(.translate(message)) + } + } ) } .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) } .contextMenu { contextMenu @@ -233,6 +269,40 @@ struct TimelineRowView: View, Equatable { ForEach(TimelineMessageContextMenu.entries(for: message).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 { + onContextAction(.translate(message)) + } label: { + Label("Translate", systemImage: "translate") + } + } + case .loading: + Divider() + Button { } label: { + Label("Translating…", systemImage: "translate") + } + .disabled(true) + case .translated: + Divider() + Button { + onContextAction(.showOriginal(message)) + } label: { + Label("Show Original", systemImage: "translate") + } + } } @ViewBuilder @@ -314,11 +384,23 @@ struct TimelineRowView: View, Equatable { } } +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) -> TimelineRowView { TimelineRowView( - row: .init(message: message, info: info, isPaginationTrigger: false), + row: .init(message: message, info: info, isPaginationTrigger: false, translation: .idle), isNewlyAppended: false, showUnreadMarker: false, firstUnreadMessageId: nil, diff --git a/Relay/Views/Timeline/TimelineTableViewRepresentable.swift b/Relay/Views/Timeline/TimelineTableViewRepresentable.swift index 9dda638..f33996d 100644 --- a/Relay/Views/Timeline/TimelineTableViewRepresentable.swift +++ b/Relay/Views/Timeline/TimelineTableViewRepresentable.swift @@ -44,6 +44,9 @@ struct TimelineTableViewRepresentable: NSViewControllerRepresentable { var onNearBottomChanged: (Bool) -> Void var onPaginateBackward: () -> Void var onPaginateForward: () -> Void + var translationStateProvider: (String) -> MessageTranslationState = { _ in .idle } + var canTranslateProvider: (String) -> Bool = { _ in false } + var translationsVersion: UInt = 0 /// Proxy that the parent uses to trigger scroll actions on the table. var scrollProxy: TimelineTableProxy @@ -95,6 +98,9 @@ struct TimelineTableViewRepresentable: NSViewControllerRepresentable { onAppear: onAppear, onContextAction: onContextAction, onHighlightDismissed: onHighlightDismissed, + translationStateProvider: translationStateProvider, + canTranslateProvider: canTranslateProvider, + translationsVersion: translationsVersion, swipeState: swipeState ) } diff --git a/Relay/Views/Timeline/TimelineView.swift b/Relay/Views/Timeline/TimelineView.swift index 91e33d8..25e3681 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") @@ -72,6 +73,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 /// Number of membership events observed in the timeline, used to trigger /// a member list refresh when new joins/leaves arrive. @@ -179,6 +187,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 73f52a3..b518be6 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 OSLog import UniformTypeIdentifiers @@ -41,6 +42,14 @@ public final class TimelineViewModel: TimelineViewModelProtocol { public var firstUnreadMessageId: String? public private(set) var typingUserDisplayNames: [String] = [] 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 @@ -88,6 +97,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: @@ -1067,4 +1087,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] + } +}