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")
|
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
|
||||||
|
|
|
@ -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 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)
|
||||||
|
|
|
@ -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"/>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -80,6 +80,7 @@ class AttachmentsContainerView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
guard self.attachmentTokens != newTokens else {
|
guard self.attachmentTokens != newTokens else {
|
||||||
|
self.isHidden = attachments.isEmpty
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue