diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift index 1d59647a..f4c60bbf 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift @@ -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 { + return Request(method: .post, path: "/api/v1/statuses/\(statusID)/translate") + } + private enum CodingKeys: String, CodingKey { case id case uri diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Translation.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Translation.swift new file mode 100644 index 00000000..546abf26 --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Translation.swift @@ -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 + } +} diff --git a/Tusker/CoreData/StatusMO.swift b/Tusker/CoreData/StatusMO.swift index e37f64e5..364480cc 100644 --- a/Tusker/CoreData/StatusMO.swift +++ b/Tusker/CoreData/StatusMO.swift @@ -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) diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index 23c42c0d..815bbc0e 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -110,6 +110,7 @@ + diff --git a/Tusker/Screens/Conversation/ConversationCollectionViewController.swift b/Tusker/Screens/Conversation/ConversationCollectionViewController.swift index c0b68430..e31d85f8 100644 --- a/Tusker/Screens/Conversation/ConversationCollectionViewController.swift +++ b/Tusker/Screens/Conversation/ConversationCollectionViewController.swift @@ -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 { [unowned self] cell, indexPath, item in + let mainStatusCell = UICollectionView.CellRegistration { [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 { 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 { diff --git a/Tusker/Views/Attachments/AttachmentsContainerView.swift b/Tusker/Views/Attachments/AttachmentsContainerView.swift index ea59f6b8..f75b7937 100644 --- a/Tusker/Views/Attachments/AttachmentsContainerView.swift +++ b/Tusker/Views/Attachments/AttachmentsContainerView.swift @@ -80,6 +80,7 @@ class AttachmentsContainerView: UIView { } guard self.attachmentTokens != newTokens else { + self.isHidden = attachments.isEmpty return } diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index c736f739..75bcef68 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -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 } diff --git a/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift b/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift index 06d113cb..5e2c7f55 100644 --- a/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift @@ -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 + } +} diff --git a/Tusker/Views/Status/StatusContentContainer.swift b/Tusker/Views/Status/StatusContentContainer.swift index f8f7393d..84aebebe 100644 --- a/Tusker/Views/Status/StatusContentContainer.swift +++ b/Tusker/Views/Status/StatusContentContainer.swift @@ -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