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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
2 changes: 2 additions & 0 deletions Relay.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions Relay/ViewModels/PreviewRoomPreviewViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions Relay/ViewModels/PreviewTimelineViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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**?",
Expand Down
14 changes: 14 additions & 0 deletions Relay/Views/Message/MessageBubbleContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?

Expand Down Expand Up @@ -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)
Expand Down
53 changes: 51 additions & 2 deletions Relay/Views/Message/MessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -129,6 +138,7 @@ struct MessageView: View {
private var messageBubble: some View {
MessageBubbleContent(
message: message,
translation: translation,
onPresentReactionPicker: {
presentReactionPickerForBubble()
}
Expand All @@ -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"))
Expand Down Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions Relay/Views/Timeline/MessageGrouping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}
}
Expand All @@ -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
Expand Down Expand Up @@ -164,7 +174,8 @@ enum MessageRowBuilder {
result.append(MessageRow(
message: message,
info: info,
isPaginationTrigger: false
isPaginationTrigger: false,
translation: translationState(message.id)
))
}

Expand Down
2 changes: 2 additions & 0 deletions Relay/Views/Timeline/TimelineLazyVStackView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
5 changes: 5 additions & 0 deletions Relay/Views/Timeline/TimelineMessageContextMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading
Loading