Compare commits

...

3 Commits

24 changed files with 560 additions and 178 deletions

View File

@ -21,7 +21,8 @@ public class InstanceFeatures: ObservableObject {
@Published public private(set) var charsReservedPerURL = 23
@Published public private(set) var maxPollOptionChars: Int?
@Published public private(set) var maxPollOptionsCount: Int?
@Published public private(set) var mediaAttachmentsConfiguration: Instance.MediaAttachmentsConfiguration?
@Published public private(set) var mediaAttachmentsConfiguration: InstanceV1.MediaAttachmentsConfiguration?
@Published public private(set) var translation: Bool = false
public var localOnlyPosts: Bool {
switch instanceType {
@ -240,6 +241,7 @@ public class InstanceFeatures: ObservableObject {
maxPollOptionsCount = pollsConfig.maxOptions
}
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
translation = instance.translation
_featuresUpdated.send()
}

View File

@ -9,26 +9,39 @@ import Foundation
import Pachyderm
public struct InstanceInfo {
public let version: String
public let maxStatusCharacters: Int?
public let configuration: Instance.Configuration?
public let pollsConfiguration: Instance.PollsConfiguration?
public var version: String
public var maxStatusCharacters: Int?
public var configuration: InstanceV1.Configuration?
public var pollsConfiguration: InstanceV1.PollsConfiguration?
public var translation: Bool
public init(version: String, maxStatusCharacters: Int?, configuration: Instance.Configuration?, pollsConfiguration: Instance.PollsConfiguration?) {
public init(
version: String,
maxStatusCharacters: Int?,
configuration: InstanceV1.Configuration?,
pollsConfiguration: InstanceV1.PollsConfiguration?,
translation: Bool
) {
self.version = version
self.maxStatusCharacters = maxStatusCharacters
self.configuration = configuration
self.pollsConfiguration = pollsConfiguration
self.translation = translation
}
}
extension InstanceInfo {
public init(instance: Instance) {
public init(v1 instance: InstanceV1) {
self.init(
version: instance.version,
maxStatusCharacters: instance.maxStatusCharacters,
configuration: instance.configuration,
pollsConfiguration: instance.pollsConfiguration
pollsConfiguration: instance.pollsConfiguration,
translation: false
)
}
public mutating func update(v2: InstanceV2) {
translation = v2.configuration.translation.enabled
}
}

View File

@ -231,8 +231,12 @@ public class Client {
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
}
public static func getInstance() -> Request<Instance> {
return Request<Instance>(method: .get, path: "/api/v1/instance")
public static func getInstanceV1() -> Request<InstanceV1> {
return Request<InstanceV1>(method: .get, path: "/api/v1/instance")
}
public static func getInstanceV2() -> Request<InstanceV2> {
return Request<InstanceV2>(method: .get, path: "/api/v2/instance")
}
public static func getCustomEmoji() -> Request<[Emoji]> {

View File

@ -1,5 +1,5 @@
//
// Instance.swift
// InstanceV1.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
@ -8,7 +8,7 @@
import Foundation
public struct Instance: Decodable, Sendable {
public struct InstanceV1: Decodable, Sendable {
public let uri: String
public let title: String
public let description: String
@ -92,7 +92,7 @@ public struct Instance: Decodable, Sendable {
}
}
extension Instance {
extension InstanceV1 {
public struct Stats: Decodable, Sendable {
public let domainCount: Int?
public let statusCount: Int?
@ -106,7 +106,7 @@ extension Instance {
}
}
extension Instance {
extension InstanceV1 {
public struct Configuration: Codable, Sendable {
public let statuses: StatusesConfiguration
public let mediaAttachments: MediaAttachmentsConfiguration
@ -121,7 +121,8 @@ extension Instance {
}
}
extension Instance {
extension InstanceV1 {
// note: also used by InstanceV2
public struct StatusesConfiguration: Codable, Sendable {
public let maxCharacters: Int
public let maxMediaAttachments: Int
@ -135,7 +136,8 @@ extension Instance {
}
}
extension Instance {
extension InstanceV1 {
// note: also used by InstanceV2
public struct MediaAttachmentsConfiguration: Codable, Sendable {
public let supportedMIMETypes: [String]
public let imageSizeLimit: Int
@ -155,7 +157,8 @@ extension Instance {
}
}
extension Instance {
extension InstanceV1 {
// note: also used by InstanceV2
public struct PollsConfiguration: Codable, Sendable {
public let maxOptions: Int
public let maxCharactersPerOption: Int
@ -171,7 +174,8 @@ extension Instance {
}
}
extension Instance {
extension InstanceV1 {
// note: also used by InstanceV2
public struct Rule: Decodable, Identifiable, Sendable {
public let id: String
public let text: String

View File

@ -0,0 +1,125 @@
//
// InstanceV2.swift
// Pachyderm
//
// Created by Shadowfacts on 12/4/23.
//
import Foundation
public struct InstanceV2: Decodable, Sendable {
public let domain: String
public let title: String
public let version: String
public let sourceURL: String
public let description: String
public let usage: Usage
public let thumbnail: Thumbnail
public let languages: [String]
public let configuration: Configuration
public let registrations: Registrations
public let contact: Contact
public let rules: [InstanceV1.Rule]
private enum CodingKeys: String, CodingKey {
case domain
case title
case version
case sourceURL = "source_url"
case description
case usage
case thumbnail
case languages
case configuration
case registrations
case contact
case rules
}
}
extension InstanceV2 {
public struct Usage: Decodable, Sendable {
public let users: Users
}
public struct Users: Decodable, Sendable {
public let activeMonth: Int
private enum CodingKeys: String, CodingKey {
case activeMonth = "active_month"
}
}
}
extension InstanceV2 {
public struct Thumbnail: Decodable, Sendable {
public let url: String
public let blurhash: String?
public let versions: ThumbnailVersions
}
public struct ThumbnailVersions: Decodable, Sendable {
public let oneX: String?
public let twoX: String?
private enum CodingKeys: String, CodingKey {
case oneX = "@1x"
case twoX = "@2x"
}
}
}
extension InstanceV2 {
public struct Configuration: Decodable, Sendable {
public let urls: URLs
public let accounts: Accounts
public let statuses: InstanceV1.StatusesConfiguration
public let mediaAttachments: InstanceV1.MediaAttachmentsConfiguration
public let polls: InstanceV1.PollsConfiguration
public let translation: Translation
private enum CodingKeys: String, CodingKey {
case urls
case accounts
case statuses
case mediaAttachments = "media_attachments"
case polls
case translation
}
}
public struct URLs: Decodable, Sendable {
// the docs incorrectly say the key for this is "streaming_api"
public let streaming: String
}
public struct Accounts: Decodable, Sendable {
public let maxFeaturedTags: Int
private enum CodingKeys: String, CodingKey {
case maxFeaturedTags = "max_featured_tags"
}
}
public struct Translation: Decodable, Sendable {
public let enabled: Bool
}
}
extension InstanceV2 {
public struct Registrations: Decodable, Sendable {
public let enabled: Bool
public let approvalRequired: Bool
public let message: String?
private enum CodingKeys: String, CodingKey {
case enabled
case approvalRequired = "approval_required"
case message
}
}
}
extension InstanceV2 {
public struct Contact: Decodable, Sendable {
public let email: String
public let account: Account
}
}

View File

@ -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

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

@ -28,7 +28,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
self.instanceFeatures = InstanceFeatures()
Task { @MainActor in
async let instance = try? await run(Client.getInstance()).0
async let instance = try? await run(Client.getInstanceV1()).0
async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in
self.client.nodeInfo { response in
switch response {
@ -40,7 +40,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
}
})
guard let instance = await instance else { return }
self.instanceFeatures.update(instance: InstanceInfo(instance: instance), nodeInfo: await nodeInfo)
self.instanceFeatures.update(instance: InstanceInfo(v1: instance), nodeInfo: await nodeInfo)
}
Task { @MainActor in

View File

@ -53,7 +53,8 @@ class MastodonController: ObservableObject {
let instanceFeatures = InstanceFeatures()
@Published private(set) var account: AccountMO?
@Published private(set) var instance: Instance?
@Published private(set) var instance: InstanceV1?
@Published private var instanceV2: InstanceV2?
@Published private(set) var instanceInfo: InstanceInfo!
@Published private(set) var nodeInfo: NodeInfo!
@Published private(set) var lists: [List] = []
@ -63,7 +64,7 @@ class MastodonController: ObservableObject {
private var cancellables = Set<AnyCancellable>()
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
private var pendingOwnInstanceRequestCallbacks = [(Result<InstanceV1, Client.Error>) -> Void]()
private var ownInstanceRequest: URLSessionTask?
var loggedIn: Bool {
@ -107,9 +108,14 @@ class MastodonController: ObservableObject {
$instance
.compactMap { $0 }
.sink { [unowned self] in
self.updateActiveInstance(from: $0)
self.instanceInfo = InstanceInfo(instance: $0)
.combineLatest($instanceV2)
.sink {[unowned self] (instance, v2) in
var info = InstanceInfo(v1: instance)
if let v2 {
info.update(v2: v2)
}
self.instanceInfo = info
self.updateActiveInstance(from: info)
}
.store(in: &cancellables)
@ -217,6 +223,10 @@ class MastodonController: ObservableObject {
_ = try await (ownAccount, ownInstance)
if instanceFeatures.hasMastodonVersion(4, 0, 0) {
async let _ = try? getOwnInstanceV2()
}
loadLists()
_ = await loadFilters()
await loadServerPreferences()
@ -277,7 +287,7 @@ class MastodonController: ObservableObject {
}
}
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0) {
if case let .success(instance) = $0 {
completion?(instance)
@ -286,7 +296,7 @@ class MastodonController: ObservableObject {
}
@MainActor
func getOwnInstance() async throws -> Instance {
func getOwnInstance() async throws -> InstanceV1 {
return try await withCheckedThrowingContinuation({ continuation in
getOwnInstanceInternal(retryAttempt: 0) { result in
continuation.resume(with: result)
@ -294,7 +304,7 @@ class MastodonController: ObservableObject {
})
}
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<Instance, Client.Error>) -> Void)?) {
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<InstanceV1, Client.Error>) -> Void)?) {
// this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
assert(Thread.isMainThread)
@ -306,7 +316,7 @@ class MastodonController: ObservableObject {
}
if ownInstanceRequest == nil {
let request = Client.getInstance()
let request = Client.getInstanceV1()
ownInstanceRequest = run(request) { (response) in
switch response {
case .failure(let error):
@ -361,6 +371,10 @@ class MastodonController: ObservableObject {
}
}
private func getOwnInstanceV2() async throws {
self.instanceV2 = try await client.run(Client.getInstanceV2()).0
}
// MainActor because the accountPreferences instance is bound to the view context
@MainActor
private func loadServerPreferences() async {
@ -376,13 +390,13 @@ class MastodonController: ObservableObject {
}
}
private func updateActiveInstance(from instance: Instance) {
private func updateActiveInstance(from info: InstanceInfo) {
persistentContainer.performBackgroundTask { context in
if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first {
existing.update(from: instance)
existing.update(from: info)
} else {
let new = ActiveInstance(context: context)
new.update(from: instance)
new.update(from: info)
}
if context.hasChanges {
try? context.save()

View File

@ -22,18 +22,20 @@ public final class ActiveInstance: NSManagedObject {
@NSManaged public var maxStatusCharacters: Int
@NSManaged private var configurationData: Data?
@NSManaged private var pollsConfigurationData: Data?
@NSManaged public var translation: Bool
@LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil)
public var configuration: Instance.Configuration?
public var configuration: InstanceV1.Configuration?
@LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil)
public var pollsConfiguration: Instance.PollsConfiguration?
public var pollsConfiguration: InstanceV1.PollsConfiguration?
func update(from instance: Instance) {
self.version = instance.version
self.maxStatusCharacters = instance.maxStatusCharacters ?? 500
self.configuration = instance.configuration
self.pollsConfiguration = instance.pollsConfiguration
func update(from info: InstanceInfo) {
self.version = info.version
self.maxStatusCharacters = info.maxStatusCharacters ?? 500
self.configuration = info.configuration
self.pollsConfiguration = info.pollsConfiguration
self.translation = info.translation
}
}
@ -43,7 +45,8 @@ extension InstanceInfo {
version: activeInstance.version,
maxStatusCharacters: activeInstance.maxStatusCharacters,
configuration: activeInstance.configuration,
pollsConfiguration: activeInstance.pollsConfiguration
pollsConfiguration: activeInstance.pollsConfiguration,
translation: activeInstance.translation
)
}
}

View File

@ -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)

View File

@ -41,6 +41,7 @@
<attribute name="configurationData" optional="YES" attributeType="Binary"/>
<attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/>
<attribute name="translation" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="version" optional="YES" attributeType="String"/>
</entity>
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
@ -109,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"/>

View File

@ -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 {

View File

@ -162,7 +162,7 @@ class InstanceSelectorTableViewController: UITableViewController {
}
let client = Client(baseURL: url, session: .appDefault)
let request = Client.getInstance()
let request = Client.getInstanceV1()
client.run(request) { (response) in
var snapshot = self.dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil {
@ -309,7 +309,7 @@ extension InstanceSelectorTableViewController {
case recommendedInstances
}
enum Item: Equatable, Hashable {
case selected(URL, Instance)
case selected(URL, InstanceV1)
case recommended(InstanceSelector.Instance)
static func ==(lhs: Item, rhs: Item) -> Bool {

View File

@ -61,13 +61,34 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
}
private let contentContainer = StatusContentContainer<StatusEditContentTextView, StatusEditPollView>(useTopSpacer: false).configure {
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
private lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
contentTextView,
cardView,
attachmentsView,
pollView,
] as! [any StatusContentView], useTopSpacer: false).configure {
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
}
private let contentTextView = StatusEditContentTextView().configure {
$0.adjustsFontForContentSizeCategory = true
$0.isScrollEnabled = false
$0.backgroundColor = nil
$0.isEditable = false
$0.isSelectable = false
$0.defaultFont = TimelineStatusCollectionViewCell.contentFont
$0.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
$0.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
}
private let cardView = StatusCardView().configure {
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
}
private let attachmentsView = AttachmentsContainerView()
private let pollView = StatusEditPollView()
weak var delegate: StatusEditCollectionViewCellDelegate?
private var mastodonController: MastodonController! { delegate?.apiController }
@ -108,7 +129,7 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
}
str += "collapsed"
} else {
str += AttributedString(contentContainer.contentTextView.attributedText)
str += AttributedString(contentTextView.attributedText)
if edit.attachments.count > 0 {
let includeDescriptions: Bool
@ -170,13 +191,13 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
timestampLabel.text = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: edit.createdAt)
contentContainer.contentTextView.setTextFrom(edit: edit, index: index)
contentContainer.contentTextView.navigationDelegate = delegate
contentContainer.attachmentsView.delegate = self
contentContainer.attachmentsView.updateUI(attachments: edit.attachments)
contentContainer.pollView.isHidden = edit.poll == nil
contentContainer.pollView.updateUI(poll: edit.poll, emojis: edit.emojis)
contentContainer.cardView.isHidden = true
contentTextView.setTextFrom(edit: edit, index: index)
contentTextView.navigationDelegate = delegate
attachmentsView.delegate = self
attachmentsView.updateUI(attachments: edit.attachments)
pollView.isHidden = edit.poll == nil
pollView.updateUI(poll: edit.poll, emojis: edit.emojis)
cardView.isHidden = true
contentWarningLabel.text = edit.spoilerText
contentWarningLabel.isHidden = edit.spoilerText.isEmpty
@ -219,9 +240,9 @@ extension StatusEditCollectionViewCell: AttachmentViewDelegate {
guard let delegate else {
return nil
}
let attachments = contentContainer.attachmentsView.attachments!
let attachments = attachmentsView.attachments!
let sourceViews = attachments.map {
contentContainer.attachmentsView.getAttachmentView(for: $0)
attachmentsView.getAttachmentView(for: $0)
}
let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index)
return gallery

View File

@ -9,7 +9,7 @@
import UIKit
import Pachyderm
class StatusEditPollView: UIStackView, StatusContentPollView {
class StatusEditPollView: UIStackView, StatusContentView {
private var titleLabels: [EmojiLabel] = []

View File

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

View File

@ -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 }

View File

@ -16,7 +16,7 @@ class InstanceTableViewCell: UITableViewCell {
@IBOutlet weak var adultLabel: UILabel!
@IBOutlet weak var descriptionTextView: ContentTextView!
var instance: Instance?
var instance: InstanceV1?
var selectorInstance: InstanceSelector.Instance?
var thumbnailURL: URL?
@ -53,7 +53,7 @@ class InstanceTableViewCell: UITableViewCell {
updateThumbnail(url: instance.proxiedThumbnailURL)
}
func updateUI(instance: Instance) {
func updateUI(instance: InstanceV1) {
self.instance = instance
self.selectorInstance = nil

View File

@ -9,7 +9,7 @@
import UIKit
import Pachyderm
class StatusPollView: UIView, StatusContentPollView {
class StatusPollView: UIView, StatusContentView {
private static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()

View File

@ -117,18 +117,40 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
}
let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: true).configure {
$0.contentTextView.defaultFont = ConversationMainStatusCollectionViewCell.contentFont
$0.contentTextView.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont
$0.contentTextView.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle
$0.contentTextView.isSelectable = true
$0.contentTextView.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber]
if #available(iOS 16.0, *) {
$0.contentTextView.dataDetectorTypes.formUnion([.money, .physicalValue])
}
private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
contentTextView,
cardView,
attachmentsView,
pollView,
] as! [any StatusContentView], useTopSpacer: true).configure {
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
}
let contentTextView = StatusContentTextView().configure {
$0.adjustsFontForContentSizeCategory = true
$0.isScrollEnabled = false
$0.backgroundColor = nil
$0.isEditable = false
$0.isSelectable = true
$0.defaultFont = ConversationMainStatusCollectionViewCell.contentFont
$0.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont
$0.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle
$0.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber]
if #available(iOS 16.0, *) {
$0.dataDetectorTypes.formUnion([.money, .physicalValue])
}
}
private var translateButton: TranslateButton?
let cardView = StatusCardView().configure {
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
}
let attachmentsView = AttachmentsContainerView()
let pollView = StatusPollView()
private lazy var favoritesCountButton = UIButton().configure {
$0.titleLabel!.adjustsFontForContentSizeCategory = true
$0.addTarget(self, action: #selector(favoritesCountPressed), for: .touchUpInside)
@ -289,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!
@ -318,9 +341,9 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
accountDetailAccessibilityElement,
contentWarningLabel,
collapseButton,
contentContainer.contentTextView,
contentContainer.attachmentsView,
contentContainer.pollView,
contentTextView,
attachmentsView,
pollView,
favoritesCountButton,
reblogsCountButton,
timestampAndClientLabel,
@ -348,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()
}
@ -358,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
@ -383,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() {
@ -487,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 {
@ -555,3 +617,13 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
return nil
}
}
private class TranslateButton: UIButton, StatusContentView {
var statusContentFillsHorizontally: Bool {
false
}
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
0
}
}

View File

@ -24,7 +24,11 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
var usernameLabel: UILabel { get }
var contentWarningLabel: EmojiLabel { get }
var collapseButton: StatusCollapseButton { get }
var contentContainer: StatusContentContainer<StatusContentTextView, StatusPollView> { get }
var contentContainer: StatusContentContainer { get }
var contentTextView: StatusContentTextView { get }
var pollView: StatusPollView { get }
var cardView: StatusCardView { get }
var attachmentsView: AttachmentsContainerView { get }
var replyButton: UIButton { get }
var favoriteButton: ToggleableButton { get }
var reblogButton: ToggleableButton { get }
@ -90,20 +94,20 @@ extension StatusCollectionViewCell {
updateAccountUI(account: status.account)
contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent)
contentContainer.contentTextView.navigationDelegate = delegate
contentTextView.setTextFrom(status: status, precomputed: precomputedContent)
contentTextView.navigationDelegate = delegate
self.updateAttachmentsUI(status: status)
contentContainer.pollView.isHidden = status.poll == nil
contentContainer.pollView.mastodonController = mastodonController
contentContainer.pollView.delegate = delegate
contentContainer.pollView.updateUI(status: status, poll: status.poll)
pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController
pollView.delegate = delegate
pollView.updateUI(status: status, poll: status.poll)
if Preferences.shared.showLinkPreviews {
contentContainer.cardView.updateUI(status: status)
contentContainer.cardView.isHidden = status.card == nil
contentContainer.cardView.navigationDelegate = delegate
contentContainer.cardView.actionProvider = delegate
cardView.updateUI(status: status)
cardView.isHidden = status.card == nil
cardView.navigationDelegate = delegate
cardView.actionProvider = delegate
} else {
contentContainer.cardView.isHidden = true
cardView.isHidden = true
}
updateUIForPreferences(status: status)
@ -168,8 +172,8 @@ extension StatusCollectionViewCell {
}
func updateAttachmentsUI(status: StatusMO) {
contentContainer.attachmentsView.delegate = self
contentContainer.attachmentsView.updateUI(attachments: status.attachments)
attachmentsView.delegate = self
attachmentsView.updateUI(attachments: status.attachments)
}
func updateAccountUI(account: AccountMO) {
@ -182,20 +186,20 @@ extension StatusCollectionViewCell {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
let newCardHidden = !Preferences.shared.showLinkPreviews || status.card == nil
if contentContainer.cardView.isHidden != newCardHidden {
if cardView.isHidden != newCardHidden {
delegate?.statusCellNeedsReconfigure(self, animated: false, completion: nil)
}
switch Preferences.shared.attachmentBlurMode {
case .never:
contentContainer.attachmentsView.contentHidden = false
attachmentsView.contentHidden = false
case .always:
contentContainer.attachmentsView.contentHidden = true
attachmentsView.contentHidden = true
default:
if status.sensitive {
contentContainer.attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
} else {
contentContainer.attachmentsView.contentHidden = false
attachmentsView.contentHidden = false
}
}
@ -211,8 +215,8 @@ extension StatusCollectionViewCell {
// only called when isGrayscale does not match the pref
func updateGrayscaleableUI(status: StatusMO) {
isGrayscale = Preferences.shared.grayscaleImages
if contentContainer.contentTextView.hasEmojis {
contentContainer.contentTextView.setEmojis(status.emojis, identifier: status.id)
if contentTextView.hasEmojis {
contentTextView.setEmojis(status.emojis, identifier: status.id)
}
displayNameLabel.updateForAccountDisplayName(account: status.account)
}
@ -235,10 +239,10 @@ extension StatusCollectionViewCell {
// do not include reply action here, because the cell already contains a button for it
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
contentContainer.pollView.isHidden = status.poll == nil
contentContainer.pollView.mastodonController = mastodonController
contentContainer.pollView.delegate = delegate
contentContainer.pollView.updateUI(status: status, poll: status.poll)
pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController
pollView.delegate = delegate
pollView.updateUI(status: status, poll: status.poll)
}
func setShowThreadLinks(prev: Bool, next: Bool) {
@ -327,7 +331,7 @@ extension StatusCollectionViewCell {
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
guard let delegate = delegate,
let status = mastodonController.persistentContainer.status(for: statusID) else { return nil }
let sourceViews = status.attachments.map(contentContainer.attachmentsView.getAttachmentView(for:))
let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
// TODO: PiP
// gallery.avPlayerViewControllerDelegate = self

View File

@ -8,45 +8,11 @@
import UIKit
protocol StatusContentPollView: UIView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat
}
class StatusContentContainer<ContentView: ContentTextView, PollView: StatusContentPollView>: UIView {
class StatusContentContainer: UIView {
// TODO: this is a weird place for this
static var cardViewHeight: CGFloat { 90 }
private let useTopSpacer: Bool
private lazy var topSpacer = UIView().configure {
$0.backgroundColor = .clear
// other 4pt is provided by this view's own spacing
$0.heightAnchor.constraint(equalToConstant: 4).isActive = true
}
let contentTextView = ContentView().configure {
$0.adjustsFontForContentSizeCategory = true
$0.isScrollEnabled = false
$0.backgroundColor = nil
$0.isEditable = false
$0.isSelectable = false
}
private static var cardViewHeight: CGFloat { 90 }
let cardView = StatusCardView().configure {
NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight),
])
}
let attachmentsView = AttachmentsContainerView()
let pollView = PollView()
private var arrangedSubviews: [UIView] {
if useTopSpacer {
return [topSpacer, contentTextView, cardView, attachmentsView, pollView]
} else {
return [contentTextView, cardView, attachmentsView, pollView]
}
}
private var arrangedSubviews: [any StatusContentView]
private var isHiddenObservations: [NSKeyValueObservation] = []
@ -61,8 +27,12 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
subviews.filter { !$0.isHidden }.map(\.bounds.height).reduce(0, +)
}
init(useTopSpacer: Bool) {
self.useTopSpacer = useTopSpacer
init(arrangedSubviews: [any StatusContentView], useTopSpacer: Bool) {
var arrangedSubviews = arrangedSubviews
if useTopSpacer {
arrangedSubviews.insert(TopSpacerView(), at: 0)
}
self.arrangedSubviews = arrangedSubviews
super.init(frame: .zero)
@ -70,10 +40,14 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
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
@ -82,17 +56,21 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
setNeedsUpdateConstraints()
isHiddenObservations = arrangedSubviews.map {
$0.observe(\.isHidden) { [unowned self] _, _ in
self.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()
}
}
}
override func updateConstraints() {
let visibleSubviews = IndexSet(arrangedSubviews.indices.filter { !arrangedSubviews[$0].isHidden })
if self.visibleSubviews != visibleSubviews {
@ -129,6 +107,31 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
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
@ -147,18 +150,67 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
// just roughly inline with the content height
func estimateVisibleSubviewHeight(effectiveWidth: CGFloat) -> CGFloat {
var height: CGFloat = 0
height += contentTextView.sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height
if !cardView.isHidden {
height += StatusContentContainer.cardViewHeight
}
if !attachmentsView.isHidden {
height += effectiveWidth / attachmentsView.aspectRatio
}
if !pollView.isHidden {
let pollHeight = pollView.estimateHeight(effectiveWidth: effectiveWidth)
height += pollHeight
for view in arrangedSubviews where !view.isHidden {
height += view.estimateHeight(effectiveWidth: effectiveWidth)
}
return height
}
}
extension StatusContentContainer {
private class TopSpacerView: UIView, StatusContentView {
init() {
super.init(frame: .zero)
backgroundColor = .clear
// other 4pt is provided by this view's own spacing
heightAnchor.constraint(equalToConstant: 4).isActive = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
4
}
}
}
private extension UIView {
func observeIsHidden(_ f: @escaping () -> Void) -> NSKeyValueObservation {
self.observe(\.isHidden) { _, _ in
f()
}
}
}
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
}
}
extension StatusCardView: StatusContentView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
StatusContentContainer.cardViewHeight
}
}
extension AttachmentsContainerView: StatusContentView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
effectiveWidth / aspectRatio
}
}

View File

@ -186,25 +186,34 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.setContentCompressionResistancePriority(.required, for: .vertical)
}
let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: false).configure {
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
contentTextView,
cardView,
attachmentsView,
pollView,
] as! [any StatusContentView], useTopSpacer: false).configure {
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
}
private var contentTextView: StatusContentTextView {
contentContainer.contentTextView
let contentTextView = StatusContentTextView().configure {
$0.adjustsFontForContentSizeCategory = true
$0.isScrollEnabled = false
$0.backgroundColor = nil
$0.isEditable = false
$0.isSelectable = false
$0.defaultFont = TimelineStatusCollectionViewCell.contentFont
$0.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
$0.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
}
private var cardView: StatusCardView {
contentContainer.cardView
}
private var attachmentsView: AttachmentsContainerView {
contentContainer.attachmentsView
}
private var pollView: StatusPollView {
contentContainer.pollView
let cardView = StatusCardView().configure {
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
}
let attachmentsView = AttachmentsContainerView()
let pollView = StatusPollView()
private var placeholderReplyButtonLeadingConstraint: NSLayoutConstraint!
private lazy var actionsContainer = UIView().configure {
replyButton.translatesAutoresizingMaskIntoConstraints = false