forked from shadowfacts/Tusker
parent
5e609aa40d
commit
28c1a9092b
|
@ -177,6 +177,10 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
|||
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 {
|
||||
case id
|
||||
case uri
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -54,6 +54,7 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
|
|||
@NSManaged public var reblog: StatusMO?
|
||||
@NSManaged public var localOnly: Bool
|
||||
@NSManaged public var lastFetchedAt: Date?
|
||||
@NSManaged public var language: String?
|
||||
|
||||
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
|
||||
public var attachments: [Attachment]
|
||||
|
@ -139,6 +140,7 @@ extension StatusMO {
|
|||
self.visibility = status.visibility
|
||||
self.poll = status.poll
|
||||
self.localOnly = status.localOnly ?? false
|
||||
self.language = status.language
|
||||
|
||||
if let existing = container.account(for: status.account.id, in: context) {
|
||||
existing.updateFrom(apiAccount: status.account, container: container)
|
||||
|
|
|
@ -110,6 +110,7 @@
|
|||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="inReplyToAccountID" 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="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="mentionsData" attributeType="Binary"/>
|
||||
|
|
|
@ -15,6 +15,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
private let mastodonController: MastodonController
|
||||
private let mainStatusID: String
|
||||
private let mainStatusState: CollapseState
|
||||
private var mainStatusTranslation: Translation?
|
||||
var statusIDToScrollToOnLoad: String
|
||||
var showStatusesAutomatically = false
|
||||
|
||||
|
@ -88,11 +89,14 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
|
||||
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.translateStatus = { [unowned self] in
|
||||
self.translateMainStatus()
|
||||
}
|
||||
cell.showStatusAutomatically = self.showStatusesAutomatically
|
||||
cell.updateUI(statusID: item.0, state: item.1)
|
||||
cell.setShowThreadLinks(prev: item.2, next: false)
|
||||
cell.updateUI(statusID: item.0, state: item.1, translation: item.2)
|
||||
cell.setShowThreadLinks(prev: item.3, next: false)
|
||||
}
|
||||
let expandThreadCell = UICollectionView.CellRegistration<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in
|
||||
cell.updateUI(childThreads: item.0, inline: item.1)
|
||||
|
@ -104,7 +108,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
switch itemIdentifier {
|
||||
case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
|
||||
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 {
|
||||
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 {
|
||||
|
|
|
@ -80,6 +80,7 @@ class AttachmentsContainerView: UIView {
|
|||
}
|
||||
|
||||
guard self.attachmentTokens != newTokens else {
|
||||
self.isHidden = attachments.isEmpty
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
weak var overrideMastodonController: MastodonController?
|
||||
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
|
||||
|
||||
private var htmlConverter = HTMLConverter()
|
||||
private(set) var htmlConverter = HTMLConverter()
|
||||
var defaultFont: UIFont {
|
||||
_read { yield htmlConverter.font }
|
||||
_modify { yield &htmlConverter.font }
|
||||
|
|
|
@ -141,6 +141,8 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
}
|
||||
}
|
||||
|
||||
private var translateButton: TranslateButton?
|
||||
|
||||
let cardView = StatusCardView().configure {
|
||||
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
|
||||
}
|
||||
|
@ -309,6 +311,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
var mastodonController: MastodonController! { delegate?.apiController }
|
||||
weak var delegate: StatusCollectionViewCellDelegate?
|
||||
var showStatusAutomatically = false
|
||||
var translateStatus: (() -> Void)?
|
||||
|
||||
var statusID: String!
|
||||
var statusState: CollapseState!
|
||||
|
@ -368,7 +371,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
|
||||
// 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 {
|
||||
fatalError()
|
||||
}
|
||||
|
@ -378,7 +381,17 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
self.statusID = statusID
|
||||
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
|
||||
contentWarningToCollapseButtonSpacer.isHidden = collapseButton.isHidden
|
||||
|
||||
|
@ -403,6 +416,31 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
} else {
|
||||
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() {
|
||||
|
@ -507,6 +545,10 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
delegate?.show(StatusEditHistoryViewController(statusID: statusID, mastodonController: mastodonController), sender: nil)
|
||||
}
|
||||
|
||||
@objc private func translatePressed() {
|
||||
translateStatus?()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement {
|
||||
|
@ -575,3 +617,13 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private class TranslateButton: UIButton, StatusContentView {
|
||||
var statusContentFillsHorizontally: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ class StatusContentContainer: UIView {
|
|||
// TODO: this is a weird place for this
|
||||
static var cardViewHeight: CGFloat { 90 }
|
||||
|
||||
private let arrangedSubviews: [any StatusContentView]
|
||||
private var arrangedSubviews: [any StatusContentView]
|
||||
|
||||
private var isHiddenObservations: [NSKeyValueObservation] = []
|
||||
|
||||
|
@ -40,10 +40,14 @@ class StatusContentContainer: UIView {
|
|||
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(subview)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
if subview.statusContentFillsHorizontally {
|
||||
NSLayoutConstraint.activate([
|
||||
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
|
||||
|
@ -52,6 +56,14 @@ class StatusContentContainer: UIView {
|
|||
|
||||
setNeedsUpdateConstraints()
|
||||
|
||||
updateObservations()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func updateObservations() {
|
||||
isHiddenObservations = arrangedSubviews.map {
|
||||
$0.observeIsHidden { [unowned self] in
|
||||
self.setNeedsUpdateConstraints()
|
||||
|
@ -59,10 +71,6 @@ class StatusContentContainer: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func updateConstraints() {
|
||||
let visibleSubviews = IndexSet(arrangedSubviews.indices.filter { !arrangedSubviews[$0].isHidden })
|
||||
if self.visibleSubviews != visibleSubviews {
|
||||
|
@ -99,6 +107,31 @@ class StatusContentContainer: UIView {
|
|||
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) {
|
||||
guard collapsed != isCollapsed else {
|
||||
return
|
||||
|
@ -154,9 +187,16 @@ private extension UIView {
|
|||
}
|
||||
|
||||
protocol StatusContentView: UIView {
|
||||
var statusContentFillsHorizontally: Bool { get }
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat
|
||||
}
|
||||
|
||||
extension StatusContentView {
|
||||
var statusContentFillsHorizontally: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
extension ContentTextView: StatusContentView {
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||
sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height
|
||||
|
|
Loading…
Reference in New Issue