Add server-provided translation

Closes #331
This commit is contained in:
Shadowfacts 2023-12-04 19:31:51 -05:00
parent 5e609aa40d
commit 28c1a9092b
9 changed files with 166 additions and 16 deletions

View File

@ -177,6 +177,10 @@ public final class Status: StatusProtocol, Decodable, Sendable {
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history") return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
} }
public static func translate(_ statusID: String) -> Request<Translation> {
return Request(method: .post, path: "/api/v1/statuses/\(statusID)/translate")
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id
case uri case uri

View File

@ -0,0 +1,22 @@
//
// Translation.swift
// Pachyderm
//
// Created by Shadowfacts on 12/4/23.
//
import Foundation
public struct Translation: Decodable, Sendable {
public let content: String
public let spoilerText: String?
public let detectedSourceLanguage: String
public let provider: String
private enum CodingKeys: String, CodingKey {
case content
case spoilerText
case detectedSourceLanguage = "detected_source_language"
case provider
}
}

View File

@ -54,6 +54,7 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
@NSManaged public var reblog: StatusMO? @NSManaged public var reblog: StatusMO?
@NSManaged public var localOnly: Bool @NSManaged public var localOnly: Bool
@NSManaged public var lastFetchedAt: Date? @NSManaged public var lastFetchedAt: Date?
@NSManaged public var language: String?
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData) @LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
public var attachments: [Attachment] public var attachments: [Attachment]
@ -139,6 +140,7 @@ extension StatusMO {
self.visibility = status.visibility self.visibility = status.visibility
self.poll = status.poll self.poll = status.poll
self.localOnly = status.localOnly ?? false self.localOnly = status.localOnly ?? false
self.language = status.language
if let existing = container.account(for: status.account.id, in: context) { if let existing = container.account(for: status.account.id, in: context) {
existing.updateFrom(apiAccount: status.account, container: container) existing.updateFrom(apiAccount: status.account, container: container)

View File

@ -110,6 +110,7 @@
<attribute name="id" attributeType="String"/> <attribute name="id" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/> <attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/> <attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="mentionsData" attributeType="Binary"/> <attribute name="mentionsData" attributeType="Binary"/>

View File

@ -15,6 +15,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
private let mastodonController: MastodonController private let mastodonController: MastodonController
private let mainStatusID: String private let mainStatusID: String
private let mainStatusState: CollapseState private let mainStatusState: CollapseState
private var mainStatusTranslation: Translation?
var statusIDToScrollToOnLoad: String var statusIDToScrollToOnLoad: String
var showStatusesAutomatically = false var showStatusesAutomatically = false
@ -88,11 +89,14 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil) cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
cell.setShowThreadLinks(prev: item.2, next: item.3) cell.setShowThreadLinks(prev: item.2, next: item.3)
} }
let mainStatusCell = UICollectionView.CellRegistration<ConversationMainStatusCollectionViewCell, (String, CollapseState, Bool)> { [unowned self] cell, indexPath, item in let mainStatusCell = UICollectionView.CellRegistration<ConversationMainStatusCollectionViewCell, (String, CollapseState, Translation?, Bool)> { [unowned self] cell, indexPath, item in
cell.delegate = self cell.delegate = self
cell.translateStatus = { [unowned self] in
self.translateMainStatus()
}
cell.showStatusAutomatically = self.showStatusesAutomatically cell.showStatusAutomatically = self.showStatusesAutomatically
cell.updateUI(statusID: item.0, state: item.1) cell.updateUI(statusID: item.0, state: item.1, translation: item.2)
cell.setShowThreadLinks(prev: item.2, next: false) cell.setShowThreadLinks(prev: item.3, next: false)
} }
let expandThreadCell = UICollectionView.CellRegistration<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in let expandThreadCell = UICollectionView.CellRegistration<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in
cell.updateUI(childThreads: item.0, inline: item.1) cell.updateUI(childThreads: item.0, inline: item.1)
@ -104,7 +108,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
switch itemIdentifier { switch itemIdentifier {
case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink): case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
if id == self.mainStatusID { if id == self.mainStatusID {
return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink)) return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, self.mainStatusTranslation, prevLink))
} else { } else {
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, prevLink, nextLink)) return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, prevLink, nextLink))
} }
@ -260,6 +264,30 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
} }
} }
private func translateMainStatus() {
Task { @MainActor in
let translation: Translation
do {
translation = try await mastodonController.run(Status.translate(mainStatusID)).0
} catch {
let config = ToastConfiguration(from: error, with: "Error Translating", in: self) { toast in
toast.dismissToast(animated: true)
self.translateMainStatus()
}
self.showToast(configuration: config, animated: true)
return
}
mainStatusTranslation = translation
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems(snapshot.itemIdentifiers(inSection: .mainStatus))
await MainActor.run {
dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
} }
extension ConversationCollectionViewController { extension ConversationCollectionViewController {

View File

@ -80,6 +80,7 @@ class AttachmentsContainerView: UIView {
} }
guard self.attachmentTokens != newTokens else { guard self.attachmentTokens != newTokens else {
self.isHidden = attachments.isEmpty
return return
} }

View File

@ -23,7 +23,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
weak var overrideMastodonController: MastodonController? weak var overrideMastodonController: MastodonController?
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController } var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
private var htmlConverter = HTMLConverter() private(set) var htmlConverter = HTMLConverter()
var defaultFont: UIFont { var defaultFont: UIFont {
_read { yield htmlConverter.font } _read { yield htmlConverter.font }
_modify { yield &htmlConverter.font } _modify { yield &htmlConverter.font }

View File

@ -141,6 +141,8 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
} }
} }
private var translateButton: TranslateButton?
let cardView = StatusCardView().configure { let cardView = StatusCardView().configure {
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true $0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
} }
@ -309,6 +311,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
var mastodonController: MastodonController! { delegate?.apiController } var mastodonController: MastodonController! { delegate?.apiController }
weak var delegate: StatusCollectionViewCellDelegate? weak var delegate: StatusCollectionViewCellDelegate?
var showStatusAutomatically = false var showStatusAutomatically = false
var translateStatus: (() -> Void)?
var statusID: String! var statusID: String!
var statusState: CollapseState! var statusState: CollapseState!
@ -368,7 +371,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
// MARK: Configure UI // MARK: Configure UI
func updateUI(statusID: String, state: CollapseState) { func updateUI(statusID: String, state: CollapseState, translation: Translation?) {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError() fatalError()
} }
@ -378,7 +381,17 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
self.statusID = statusID self.statusID = statusID
self.statusState = state self.statusState = state
doUpdateUI(status: status) let attributedTranslatedContent: NSAttributedString? = translation.map {
contentTextView.htmlConverter.convert($0.content)
}
doUpdateUI(status: status, precomputedContent: attributedTranslatedContent)
if !status.spoilerText.isEmpty,
let translated = translation?.spoilerText {
contentWarningLabel.text = translated
contentWarningLabel.setEmojis(status.emojis, identifier: "\(statusID)_translated")
}
accountDetailToContentWarningSpacer.isHidden = collapseButton.isHidden accountDetailToContentWarningSpacer.isHidden = collapseButton.isHidden
contentWarningToCollapseButtonSpacer.isHidden = collapseButton.isHidden contentWarningToCollapseButtonSpacer.isHidden = collapseButton.isHidden
@ -403,6 +416,31 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
} else { } else {
editTimestampButton.isHidden = true editTimestampButton.isHidden = true
} }
if mastodonController.instanceFeatures.translation,
let preferredLanguage = mastodonController.accountPreferences.serverDefaultLanguage,
preferredLanguage != status.language {
var config = UIButton.Configuration.tinted()
config.image = UIImage(systemName: "globe")!
if let translation {
let lang = Locale.current.localizedString(forLanguageCode: translation.detectedSourceLanguage) ?? translation.detectedSourceLanguage
config.title = "Translated from \(lang)"
} else {
config.title = "Translate"
}
if let translateButton {
translateButton.configuration = config
} else {
let button = TranslateButton(configuration: config)
button.addTarget(self, action: #selector(translatePressed), for: .touchUpInside)
translateButton = button
contentContainer.insertArrangedSubview(button, after: contentTextView)
}
translateButton!.isEnabled = translation == nil
} else if let translateButton {
contentContainer.removeArrangedSubview(translateButton)
}
} }
private func createObservers() { private func createObservers() {
@ -507,6 +545,10 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
delegate?.show(StatusEditHistoryViewController(statusID: statusID, mastodonController: mastodonController), sender: nil) delegate?.show(StatusEditHistoryViewController(statusID: statusID, mastodonController: mastodonController), sender: nil)
} }
@objc private func translatePressed() {
translateStatus?()
}
} }
private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement { private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement {
@ -575,3 +617,13 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
return nil return nil
} }
} }
private class TranslateButton: UIButton, StatusContentView {
var statusContentFillsHorizontally: Bool {
false
}
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
0
}
}

View File

@ -12,7 +12,7 @@ class StatusContentContainer: UIView {
// TODO: this is a weird place for this // TODO: this is a weird place for this
static var cardViewHeight: CGFloat { 90 } static var cardViewHeight: CGFloat { 90 }
private let arrangedSubviews: [any StatusContentView] private var arrangedSubviews: [any StatusContentView]
private var isHiddenObservations: [NSKeyValueObservation] = [] private var isHiddenObservations: [NSKeyValueObservation] = []
@ -40,10 +40,14 @@ class StatusContentContainer: UIView {
subview.translatesAutoresizingMaskIntoConstraints = false subview.translatesAutoresizingMaskIntoConstraints = false
addSubview(subview) addSubview(subview)
NSLayoutConstraint.activate([ if subview.statusContentFillsHorizontally {
subview.leadingAnchor.constraint(equalTo: leadingAnchor), NSLayoutConstraint.activate([
subview.trailingAnchor.constraint(equalTo: trailingAnchor), subview.leadingAnchor.constraint(equalTo: leadingAnchor),
]) subview.trailingAnchor.constraint(equalTo: trailingAnchor),
])
} else {
subview.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
}
} }
// this constraint needs to have low priority so that during the collapse/expand animation, the content container is the view that shrinks/expands // this constraint needs to have low priority so that during the collapse/expand animation, the content container is the view that shrinks/expands
@ -52,6 +56,14 @@ class StatusContentContainer: UIView {
setNeedsUpdateConstraints() setNeedsUpdateConstraints()
updateObservations()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateObservations() {
isHiddenObservations = arrangedSubviews.map { isHiddenObservations = arrangedSubviews.map {
$0.observeIsHidden { [unowned self] in $0.observeIsHidden { [unowned self] in
self.setNeedsUpdateConstraints() self.setNeedsUpdateConstraints()
@ -59,10 +71,6 @@ class StatusContentContainer: UIView {
} }
} }
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateConstraints() { override func updateConstraints() {
let visibleSubviews = IndexSet(arrangedSubviews.indices.filter { !arrangedSubviews[$0].isHidden }) let visibleSubviews = IndexSet(arrangedSubviews.indices.filter { !arrangedSubviews[$0].isHidden })
if self.visibleSubviews != visibleSubviews { if self.visibleSubviews != visibleSubviews {
@ -99,6 +107,31 @@ class StatusContentContainer: UIView {
super.updateConstraints() super.updateConstraints()
} }
func insertArrangedSubview(_ view: any StatusContentView, after: any StatusContentView) {
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
if view.statusContentFillsHorizontally {
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: leadingAnchor),
view.trailingAnchor.constraint(equalTo: trailingAnchor),
])
} else {
view.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
}
let index = arrangedSubviews.firstIndex(where: { $0 === after })!
arrangedSubviews.insert(view, at: index + 1)
setNeedsUpdateConstraints()
updateObservations()
}
func removeArrangedSubview(_ view: any StatusContentView) {
view.removeFromSuperview()
arrangedSubviews.removeAll(where: { $0 === view })
setNeedsUpdateConstraints()
updateObservations()
}
func setCollapsed(_ collapsed: Bool) { func setCollapsed(_ collapsed: Bool) {
guard collapsed != isCollapsed else { guard collapsed != isCollapsed else {
return return
@ -154,9 +187,16 @@ private extension UIView {
} }
protocol StatusContentView: UIView { protocol StatusContentView: UIView {
var statusContentFillsHorizontally: Bool { get }
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat func estimateHeight(effectiveWidth: CGFloat) -> CGFloat
} }
extension StatusContentView {
var statusContentFillsHorizontally: Bool {
true
}
}
extension ContentTextView: StatusContentView { extension ContentTextView: StatusContentView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat { func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height