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 charsReservedPerURL = 23
@Published public private(set) var maxPollOptionChars: Int? @Published public private(set) var maxPollOptionChars: Int?
@Published public private(set) var maxPollOptionsCount: 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 { public var localOnlyPosts: Bool {
switch instanceType { switch instanceType {
@ -240,6 +241,7 @@ public class InstanceFeatures: ObservableObject {
maxPollOptionsCount = pollsConfig.maxOptions maxPollOptionsCount = pollsConfig.maxOptions
} }
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
translation = instance.translation
_featuresUpdated.send() _featuresUpdated.send()
} }

View File

@ -9,26 +9,39 @@ import Foundation
import Pachyderm import Pachyderm
public struct InstanceInfo { public struct InstanceInfo {
public let version: String public var version: String
public let maxStatusCharacters: Int? public var maxStatusCharacters: Int?
public let configuration: Instance.Configuration? public var configuration: InstanceV1.Configuration?
public let pollsConfiguration: Instance.PollsConfiguration? 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.version = version
self.maxStatusCharacters = maxStatusCharacters self.maxStatusCharacters = maxStatusCharacters
self.configuration = configuration self.configuration = configuration
self.pollsConfiguration = pollsConfiguration self.pollsConfiguration = pollsConfiguration
self.translation = translation
} }
} }
extension InstanceInfo { extension InstanceInfo {
public init(instance: Instance) { public init(v1 instance: InstanceV1) {
self.init( self.init(
version: instance.version, version: instance.version,
maxStatusCharacters: instance.maxStatusCharacters, maxStatusCharacters: instance.maxStatusCharacters,
configuration: instance.configuration, 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) return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
} }
public static func getInstance() -> Request<Instance> { public static func getInstanceV1() -> Request<InstanceV1> {
return Request<Instance>(method: .get, path: "/api/v1/instance") 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]> { public static func getCustomEmoji() -> Request<[Emoji]> {

View File

@ -1,5 +1,5 @@
// //
// Instance.swift // InstanceV1.swift
// Pachyderm // Pachyderm
// //
// Created by Shadowfacts on 9/9/18. // Created by Shadowfacts on 9/9/18.
@ -8,7 +8,7 @@
import Foundation import Foundation
public struct Instance: Decodable, Sendable { public struct InstanceV1: Decodable, Sendable {
public let uri: String public let uri: String
public let title: String public let title: String
public let description: String public let description: String
@ -92,7 +92,7 @@ public struct Instance: Decodable, Sendable {
} }
} }
extension Instance { extension InstanceV1 {
public struct Stats: Decodable, Sendable { public struct Stats: Decodable, Sendable {
public let domainCount: Int? public let domainCount: Int?
public let statusCount: Int? public let statusCount: Int?
@ -106,7 +106,7 @@ extension Instance {
} }
} }
extension Instance { extension InstanceV1 {
public struct Configuration: Codable, Sendable { public struct Configuration: Codable, Sendable {
public let statuses: StatusesConfiguration public let statuses: StatusesConfiguration
public let mediaAttachments: MediaAttachmentsConfiguration 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 struct StatusesConfiguration: Codable, Sendable {
public let maxCharacters: Int public let maxCharacters: Int
public let maxMediaAttachments: 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 struct MediaAttachmentsConfiguration: Codable, Sendable {
public let supportedMIMETypes: [String] public let supportedMIMETypes: [String]
public let imageSizeLimit: Int 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 struct PollsConfiguration: Codable, Sendable {
public let maxOptions: Int public let maxOptions: Int
public let maxCharactersPerOption: 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 struct Rule: Decodable, Identifiable, Sendable {
public let id: String public let id: String
public let text: 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") return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
} }
public static func translate(_ statusID: String) -> Request<Translation> {
return Request(method: .post, path: "/api/v1/statuses/\(statusID)/translate")
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id
case uri case uri

View File

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

View File

@ -28,7 +28,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
self.instanceFeatures = InstanceFeatures() self.instanceFeatures = InstanceFeatures()
Task { @MainActor in 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 async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in
self.client.nodeInfo { response in self.client.nodeInfo { response in
switch response { switch response {
@ -40,7 +40,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
} }
}) })
guard let instance = await instance else { return } 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 Task { @MainActor in

View File

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

View File

@ -22,18 +22,20 @@ public final class ActiveInstance: NSManagedObject {
@NSManaged public var maxStatusCharacters: Int @NSManaged public var maxStatusCharacters: Int
@NSManaged private var configurationData: Data? @NSManaged private var configurationData: Data?
@NSManaged private var pollsConfigurationData: Data? @NSManaged private var pollsConfigurationData: Data?
@NSManaged public var translation: Bool
@LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil) @LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil)
public var configuration: Instance.Configuration? public var configuration: InstanceV1.Configuration?
@LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil) @LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil)
public var pollsConfiguration: Instance.PollsConfiguration? public var pollsConfiguration: InstanceV1.PollsConfiguration?
func update(from instance: Instance) { func update(from info: InstanceInfo) {
self.version = instance.version self.version = info.version
self.maxStatusCharacters = instance.maxStatusCharacters ?? 500 self.maxStatusCharacters = info.maxStatusCharacters ?? 500
self.configuration = instance.configuration self.configuration = info.configuration
self.pollsConfiguration = instance.pollsConfiguration self.pollsConfiguration = info.pollsConfiguration
self.translation = info.translation
} }
} }
@ -43,7 +45,8 @@ extension InstanceInfo {
version: activeInstance.version, version: activeInstance.version,
maxStatusCharacters: activeInstance.maxStatusCharacters, maxStatusCharacters: activeInstance.maxStatusCharacters,
configuration: activeInstance.configuration, 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 reblog: StatusMO?
@NSManaged public var localOnly: Bool @NSManaged public var localOnly: Bool
@NSManaged public var lastFetchedAt: Date? @NSManaged public var lastFetchedAt: Date?
@NSManaged public var language: String?
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData) @LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
public var attachments: [Attachment] public var attachments: [Attachment]
@ -139,6 +140,7 @@ extension StatusMO {
self.visibility = status.visibility self.visibility = status.visibility
self.poll = status.poll self.poll = status.poll
self.localOnly = status.localOnly ?? false self.localOnly = status.localOnly ?? false
self.language = status.language
if let existing = container.account(for: status.account.id, in: context) { if let existing = container.account(for: status.account.id, in: context) {
existing.updateFrom(apiAccount: status.account, container: container) existing.updateFrom(apiAccount: status.account, container: container)

View File

@ -41,6 +41,7 @@
<attribute name="configurationData" optional="YES" attributeType="Binary"/> <attribute name="configurationData" optional="YES" attributeType="Binary"/>
<attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/> <attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/>
<attribute name="translation" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="version" optional="YES" attributeType="String"/> <attribute name="version" optional="YES" attributeType="String"/>
</entity> </entity>
<entity name="Filter" representedClassName="FilterMO" syncable="YES"> <entity name="Filter" representedClassName="FilterMO" syncable="YES">
@ -109,6 +110,7 @@
<attribute name="id" attributeType="String"/> <attribute name="id" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/> <attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/> <attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="mentionsData" attributeType="Binary"/> <attribute name="mentionsData" attributeType="Binary"/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,45 +8,11 @@
import UIKit import UIKit
protocol StatusContentPollView: UIView { class StatusContentContainer: UIView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat // TODO: this is a weird place for this
} static var cardViewHeight: CGFloat { 90 }
class StatusContentContainer<ContentView: ContentTextView, PollView: StatusContentPollView>: UIView { private var arrangedSubviews: [any StatusContentView]
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 isHiddenObservations: [NSKeyValueObservation] = [] private var isHiddenObservations: [NSKeyValueObservation] = []
@ -61,8 +27,12 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
subviews.filter { !$0.isHidden }.map(\.bounds.height).reduce(0, +) subviews.filter { !$0.isHidden }.map(\.bounds.height).reduce(0, +)
} }
init(useTopSpacer: Bool) { init(arrangedSubviews: [any StatusContentView], useTopSpacer: Bool) {
self.useTopSpacer = useTopSpacer var arrangedSubviews = arrangedSubviews
if useTopSpacer {
arrangedSubviews.insert(TopSpacerView(), at: 0)
}
self.arrangedSubviews = arrangedSubviews
super.init(frame: .zero) super.init(frame: .zero)
@ -70,10 +40,14 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
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
@ -82,17 +56,21 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
setNeedsUpdateConstraints() setNeedsUpdateConstraints()
isHiddenObservations = arrangedSubviews.map { updateObservations()
$0.observe(\.isHidden) { [unowned self] _, _ in
self.setNeedsUpdateConstraints()
}
}
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
private func updateObservations() {
isHiddenObservations = arrangedSubviews.map {
$0.observeIsHidden { [unowned self] in
self.setNeedsUpdateConstraints()
}
}
}
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 {
@ -129,6 +107,31 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
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
@ -147,18 +150,67 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
// just roughly inline with the content height // just roughly inline with the content height
func estimateVisibleSubviewHeight(effectiveWidth: CGFloat) -> CGFloat { func estimateVisibleSubviewHeight(effectiveWidth: CGFloat) -> CGFloat {
var height: CGFloat = 0 var height: CGFloat = 0
height += contentTextView.sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height for view in arrangedSubviews where !view.isHidden {
if !cardView.isHidden { height += view.estimateHeight(effectiveWidth: effectiveWidth)
height += StatusContentContainer.cardViewHeight
}
if !attachmentsView.isHidden {
height += effectiveWidth / attachmentsView.aspectRatio
}
if !pollView.isHidden {
let pollHeight = pollView.estimateHeight(effectiveWidth: effectiveWidth)
height += pollHeight
} }
return height 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) $0.setContentCompressionResistancePriority(.required, for: .vertical)
} }
let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: false).configure { private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont contentTextView,
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont cardView,
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle attachmentsView,
pollView,
] as! [any StatusContentView], useTopSpacer: false).configure {
$0.setContentHuggingPriority(.defaultLow, for: .vertical) $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 let cardView = StatusCardView().configure {
} $0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
private var attachmentsView: AttachmentsContainerView {
contentContainer.attachmentsView
}
private var pollView: StatusPollView {
contentContainer.pollView
} }
let attachmentsView = AttachmentsContainerView()
let pollView = StatusPollView()
private var placeholderReplyButtonLeadingConstraint: NSLayoutConstraint! private var placeholderReplyButtonLeadingConstraint: NSLayoutConstraint!
private lazy var actionsContainer = UIView().configure { private lazy var actionsContainer = UIView().configure {
replyButton.translatesAutoresizingMaskIntoConstraints = false replyButton.translatesAutoresizingMaskIntoConstraints = false