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 @@ -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;
Expand Down Expand Up @@ -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;
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
106 changes: 101 additions & 5 deletions Relay/Views/Message/MessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Bool> = .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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
)
}
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down
31 changes: 31 additions & 0 deletions Relay/Views/Pickers/EmojiPickerPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 12 additions & 2 deletions Relay/Views/Timeline/MessageGrouping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,21 @@ 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 }

nonisolated static func == (lhs: MessageRow, rhs: MessageRow) -> Bool {
lhs.message == rhs.message
&& lhs.info == rhs.info
&& lhs.isPaginationTrigger == rhs.isPaginationTrigger
&& lhs.translation == rhs.translation
}
}

Expand All @@ -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
Expand Down Expand Up @@ -129,7 +138,8 @@ extension TimelineView {
result.append(MessageRow(
message: message,
info: info,
isPaginationTrigger: false
isPaginationTrigger: false,
translation: translationState(message.id)
))
}
return result
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 @@ -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)
}
Loading