Compare commits

..

No commits in common. "e8576277e0dce1eac4cb463a83b28158565a8f30" and "99caaa0f2846c23d98ffc0692e98e4673ae2b12f" have entirely different histories.

37 changed files with 289 additions and 803 deletions

View File

@ -1,18 +1,5 @@
# Changelog # Changelog
## 2023.8 (109)
Features/Improvements:
- Add Translate action to conversations (on supported Mastodon instances)
- Improve share extension launch speed
- Add preference for hiding attachments in timelines
Bugfixes:
- Fix crash during state restoration when reblogged statuses are present
- Fix timeline state restoration using incorrect scroll position in certain circumstances
- Fix status that is reblogged and contains a followed hashtag not showing reblogger label
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
- macOS: Fix images copied from Safari not pasting on Compose screen
## 2023.8 (107) ## 2023.8 (107)
Features/Improvements: Features/Improvements:
- Style blockquotes in statuses - Style blockquotes in statuses

View File

@ -54,20 +54,14 @@ class ToolbarController: ViewController {
cwButton cwButton
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly) MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
#if !targetEnvironment(macCatalyst) // the button has a bunch of extra space by default, but combined with what we add it's too much
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8) .padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil) .disabled(draft.editedStatusID != nil)
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly) .disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
if composeController.mastodonController.instanceFeatures.localOnlyPosts { if composeController.mastodonController.instanceFeatures.localOnlyPosts {
localOnlyPicker localOnlyPicker
#if targetEnvironment(macCatalyst)
.padding(.leading, 4)
#else
.padding(.horizontal, -8) .padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil) .disabled(draft.editedStatusID != nil)
} }

View File

@ -157,11 +157,10 @@ extension DraftAttachment: NSItemProviderReading {
var data = data var data = data
var type = UTType(typeIdentifier)! var type = UTType(typeIdentifier)!
// the type is .image in certain circumstances: // this seems to only occur when the item is a UIImage, rather than just image data,
// - macOS: image copied from macOS Safari -> only UIImage(data: data) works // which seems to only occur when sharing a screenshot directly from the markup screen
// - iOS: sharing screenshot from markup -> only NSKeyedUnarchiver works
if type == .image, if type == .image,
let image = UIImage(data: data) ?? (try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data)), let image = try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data),
let pngData = image.pngData() { let pngData = image.pngData() {
data = pngData data = pngData
type = .png type = .png

View File

@ -21,8 +21,7 @@ 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: InstanceV1.MediaAttachmentsConfiguration? @Published public private(set) var mediaAttachmentsConfiguration: Instance.MediaAttachmentsConfiguration?
@Published public private(set) var translation: Bool = false
public var localOnlyPosts: Bool { public var localOnlyPosts: Bool {
switch instanceType { switch instanceType {
@ -241,7 +240,6 @@ 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,39 +9,26 @@ import Foundation
import Pachyderm import Pachyderm
public struct InstanceInfo { public struct InstanceInfo {
public var version: String public let version: String
public var maxStatusCharacters: Int? public let maxStatusCharacters: Int?
public var configuration: InstanceV1.Configuration? public let configuration: Instance.Configuration?
public var pollsConfiguration: InstanceV1.PollsConfiguration? public let pollsConfiguration: Instance.PollsConfiguration?
public var translation: Bool
public init( public init(version: String, maxStatusCharacters: Int?, configuration: Instance.Configuration?, pollsConfiguration: Instance.PollsConfiguration?) {
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(v1 instance: InstanceV1) { public init(instance: Instance) {
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,12 +231,8 @@ 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 getInstanceV1() -> Request<InstanceV1> { public static func getInstance() -> Request<Instance> {
return Request<InstanceV1>(method: .get, path: "/api/v1/instance") return Request<Instance>(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 @@
// //
// InstanceV1.swift // Instance.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 InstanceV1: Decodable, Sendable { public struct Instance: 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 InstanceV1: Decodable, Sendable {
} }
} }
extension InstanceV1 { extension Instance {
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 InstanceV1 {
} }
} }
extension InstanceV1 { extension Instance {
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,8 +121,7 @@ extension InstanceV1 {
} }
} }
extension InstanceV1 { extension Instance {
// 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
@ -136,8 +135,7 @@ extension InstanceV1 {
} }
} }
extension InstanceV1 { extension Instance {
// 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
@ -157,8 +155,7 @@ extension InstanceV1 {
} }
} }
extension InstanceV1 { extension Instance {
// 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
@ -174,8 +171,7 @@ extension InstanceV1 {
} }
} }
extension InstanceV1 { extension Instance {
// 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

@ -1,125 +0,0 @@
//
// 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,10 +177,6 @@ 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

@ -1,22 +0,0 @@
//
// 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

@ -26,26 +26,13 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
} }
public func makeUIView(context: Context) -> UIButton { public func makeUIView(context: Context) -> UIButton {
let button = UIButton(configuration: makeConfiguration()) let button = UIButton()
button.showsMenuAsPrimaryAction = true button.showsMenuAsPrimaryAction = true
button.setContentHuggingPriority(.required, for: .horizontal) button.setContentHuggingPriority(.required, for: .horizontal)
return button return button
} }
public func updateUIView(_ button: UIButton, context: Context) { public func updateUIView(_ button: UIButton, context: Context) {
button.configuration = makeConfiguration()
button.menu = UIMenu(children: options.map { opt in
let action = UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in
selection = opt.value
}
action.accessibilityLabel = opt.accessibilityLabel
return action
})
button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title
button.isPointerInteractionEnabled = buttonStyle == .iconOnly
}
private func makeConfiguration() -> UIButton.Configuration {
var config = UIButton.Configuration.borderless() var config = UIButton.Configuration.borderless()
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
config.indicator = .popup config.indicator = .popup
@ -56,10 +43,16 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
if buttonStyle.hasLabel { if buttonStyle.hasLabel {
config.title = selectedOption.title config.title = selectedOption.title
} }
#if targetEnvironment(macCatalyst) button.configuration = config
config.macIdiomStyle = .bordered button.menu = UIMenu(children: options.map { opt in
#endif let action = UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in
return config selection = opt.value
}
action.accessibilityLabel = opt.accessibilityLabel
return action
})
button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title
button.isPointerInteractionEnabled = buttonStyle == .iconOnly
} }
public struct Option { public struct Option {

View File

@ -62,7 +62,6 @@ public final class Preferences: Codable, ObservableObject {
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) { if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
self.defaultPostVisibility = .visibility(existing) self.defaultPostVisibility = .visibility(existing)
@ -128,7 +127,6 @@ public final class Preferences: Codable, ObservableObject {
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions) try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode) try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
try container.encode(underlineTextLinks, forKey: .underlineTextLinks) try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline)
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility) try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility) try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
@ -184,7 +182,6 @@ public final class Preferences: Codable, ObservableObject {
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode @Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
@Published public var underlineTextLinks = false @Published public var underlineTextLinks = false
@Published public var showAttachmentsInTimeline = true
// MARK: Composing // MARK: Composing
@Published public var defaultPostVisibility = PostVisibility.serverDefault @Published public var defaultPostVisibility = PostVisibility.serverDefault
@ -256,7 +253,6 @@ public final class Preferences: Codable, ObservableObject {
case trailingStatusSwipeActions case trailingStatusSwipeActions
case widescreenNavigationMode case widescreenNavigationMode
case underlineTextLinks case underlineTextLinks
case showAttachmentsInTimeline
case defaultPostVisibility case defaultPostVisibility
case defaultReplyVisibility case defaultReplyVisibility

View File

@ -16,11 +16,6 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
public private(set) var username: String! public private(set) var username: String!
public let accessToken: String public let accessToken: String
// Sort of hack to be able to access these from the share extension.
public internal(set) var serverDefaultLanguage: String?
public internal(set) var serverDefaultVisibility: String?
public internal(set) var serverDefaultFederation: Bool?
fileprivate static let tempAccountID = "temp" fileprivate static let tempAccountID = "temp"
static func id(instanceURL: URL, username: String?) -> String { static func id(instanceURL: URL, username: String?) -> String {
@ -52,47 +47,21 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
self.accessToken = accessToken self.accessToken = accessToken
} }
init?(userDefaultsDict dict: [String: Any]) { init?(userDefaultsDict dict: [String: String]) {
guard let id = dict["id"] as? String, guard let id = dict["id"],
let instanceURL = dict["instanceURL"] as? String, let instanceURL = dict["instanceURL"],
let url = URL(string: instanceURL), let url = URL(string: instanceURL),
let clientID = dict["clientID"] as? String, let clientID = dict["clientID"],
let secret = dict["clientSecret"] as? String, let secret = dict["clientSecret"],
let accessToken = dict["accessToken"] as? String else { let accessToken = dict["accessToken"] else {
return nil return nil
} }
self.id = id self.id = id
self.instanceURL = url self.instanceURL = url
self.clientID = clientID self.clientID = clientID
self.clientSecret = secret self.clientSecret = secret
self.username = dict["username"] as? String self.username = dict["username"]
self.accessToken = accessToken self.accessToken = accessToken
self.serverDefaultLanguage = dict["serverDefaultLanguage"] as? String
self.serverDefaultVisibility = dict["serverDefaultVisibility"] as? String
self.serverDefaultFederation = dict["serverDefaultFederation"] as? Bool
}
var userDefaultsDict: [String: Any] {
var dict: [String: Any] = [
"id": id,
"instanceURL": instanceURL.absoluteString,
"clientID": clientID,
"clientSecret": clientSecret,
"accessToken": accessToken,
]
if let username {
dict["username"] = username
}
if let serverDefaultLanguage {
dict["serverDefaultLanguage"] = serverDefaultLanguage
}
if let serverDefaultVisibility {
dict["serverDefaultVisibility"] = serverDefaultVisibility
}
if let serverDefaultFederation {
dict["serverDefaultFederation"] = serverDefaultFederation
}
return dict
} }
/// A filename-safe string for this account /// A filename-safe string for this account

View File

@ -46,7 +46,19 @@ public class UserAccountsManager: ObservableObject {
} }
set { set {
objectWillChange.send() objectWillChange.send()
let array = newValue.map(\.userDefaultsDict) let array = newValue.map { (info) -> [String: String] in
var res = [
"id": info.id,
"instanceURL": info.instanceURL.absoluteString,
"clientID": info.clientID,
"clientSecret": info.clientSecret,
"accessToken": info.accessToken
]
if let username = info.username {
res["username"] = username
}
return res
}
defaults.set(array, forKey: accountsKey) defaults.set(array, forKey: accountsKey)
} }
} }
@ -134,17 +146,6 @@ public class UserAccountsManager: ObservableObject {
public func setMostRecentAccount(_ account: UserAccountInfo?) { public func setMostRecentAccount(_ account: UserAccountInfo?) {
mostRecentAccountID = account?.id mostRecentAccountID = account?.id
} }
public func updateServerPreferences(_ account: UserAccountInfo, defaultLanguage: String?, defaultVisibility: String?, defaultFederation: Bool?) {
guard let index = accounts.firstIndex(where: { $0.id == account.id }) else {
return
}
var account = account
account.serverDefaultLanguage = defaultLanguage
account.serverDefaultVisibility = defaultVisibility
account.serverDefaultFederation = defaultFederation
accounts[index] = account
}
} }

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.getInstanceV1()).0 async let instance = try? await run(Client.getInstance()).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(v1: instance), nodeInfo: await nodeInfo) self.instanceFeatures.update(instance: InstanceInfo(instance: instance), nodeInfo: await nodeInfo)
} }
Task { @MainActor in Task { @MainActor in

View File

@ -53,14 +53,18 @@ class ShareViewController: UIViewController {
private func createDraft(account: UserAccountInfo) async -> Draft { private func createDraft(account: UserAccountInfo) async -> Draft {
async let (text, attachments) = getDraftConfigurationFromExtensionContext() async let (text, attachments) = getDraftConfigurationFromExtensionContext()
// TODO: I really don't like that there's a network request in the hot path here, but we don't have easy access to AccountPreferences :/
let serverPrefs = try? await Client(baseURL: account.instanceURL, accessToken: account.accessToken).run(Client.getPreferences()).0
let visibility = Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverPrefs?.postingDefaultVisibility)
let draft = DraftsPersistentContainer.shared.createDraft( let draft = DraftsPersistentContainer.shared.createDraft(
accountID: account.id, accountID: account.id,
text: await text, text: await text,
contentWarning: "", contentWarning: "",
inReplyToID: nil, inReplyToID: nil,
visibility: Preferences.shared.defaultPostVisibility.resolved(withServerDefault: account.serverDefaultVisibility.flatMap(Visibility.init(rawValue:))), visibility: visibility,
language: account.serverDefaultLanguage, language: serverPrefs?.postingDefaultLanguage,
localOnly: !(account.serverDefaultFederation ?? true) localOnly: !(serverPrefs?.postingDefaultFederation ?? true)
) )
for attachment in await attachments { for attachment in await attachments {

View File

@ -53,8 +53,7 @@ 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: InstanceV1? @Published private(set) var instance: Instance?
@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] = []
@ -64,7 +63,7 @@ class MastodonController: ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var pendingOwnInstanceRequestCallbacks = [(Result<InstanceV1, Client.Error>) -> Void]() private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
private var ownInstanceRequest: URLSessionTask? private var ownInstanceRequest: URLSessionTask?
var loggedIn: Bool { var loggedIn: Bool {
@ -108,14 +107,9 @@ class MastodonController: ObservableObject {
$instance $instance
.compactMap { $0 } .compactMap { $0 }
.combineLatest($instanceV2) .sink { [unowned self] in
.sink {[unowned self] (instance, v2) in self.updateActiveInstance(from: $0)
var info = InstanceInfo(v1: instance) self.instanceInfo = InstanceInfo(instance: $0)
if let v2 {
info.update(v2: v2)
}
self.instanceInfo = info
self.updateActiveInstance(from: info)
} }
.store(in: &cancellables) .store(in: &cancellables)
@ -223,10 +217,6 @@ 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()
@ -287,7 +277,7 @@ class MastodonController: ObservableObject {
} }
} }
func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) { func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0) { getOwnInstanceInternal(retryAttempt: 0) {
if case let .success(instance) = $0 { if case let .success(instance) = $0 {
completion?(instance) completion?(instance)
@ -296,7 +286,7 @@ class MastodonController: ObservableObject {
} }
@MainActor @MainActor
func getOwnInstance() async throws -> InstanceV1 { func getOwnInstance() async throws -> Instance {
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)
@ -304,7 +294,7 @@ class MastodonController: ObservableObject {
}) })
} }
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<InstanceV1, Client.Error>) -> Void)?) { private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<Instance, 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)
@ -316,7 +306,7 @@ class MastodonController: ObservableObject {
} }
if ownInstanceRequest == nil { if ownInstanceRequest == nil {
let request = Client.getInstanceV1() let request = Client.getInstance()
ownInstanceRequest = run(request) { (response) in ownInstanceRequest = run(request) { (response) in
switch response { switch response {
case .failure(let error): case .failure(let error):
@ -371,10 +361,6 @@ 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 {
@ -385,18 +371,15 @@ class MastodonController: ObservableObject {
accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage
accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility
accountPreferences!.serverDefaultFederation = prefs.postingDefaultFederation ?? true accountPreferences!.serverDefaultFederation = prefs.postingDefaultFederation ?? true
if let accountInfo {
UserAccountsManager.shared.updateServerPreferences(accountInfo, defaultLanguage: prefs.postingDefaultLanguage, defaultVisibility: prefs.postingDefaultVisibility.rawValue, defaultFederation: prefs.postingDefaultFederation)
}
} }
private func updateActiveInstance(from info: InstanceInfo) { private func updateActiveInstance(from instance: Instance) {
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: info) existing.update(from: instance)
} else { } else {
let new = ActiveInstance(context: context) let new = ActiveInstance(context: context)
new.update(from: info) new.update(from: instance)
} }
if context.hasChanges { if context.hasChanges {
try? context.save() try? context.save()

View File

@ -22,20 +22,18 @@ 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: InstanceV1.Configuration? public var configuration: Instance.Configuration?
@LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil) @LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil)
public var pollsConfiguration: InstanceV1.PollsConfiguration? public var pollsConfiguration: Instance.PollsConfiguration?
func update(from info: InstanceInfo) { func update(from instance: Instance) {
self.version = info.version self.version = instance.version
self.maxStatusCharacters = info.maxStatusCharacters ?? 500 self.maxStatusCharacters = instance.maxStatusCharacters ?? 500
self.configuration = info.configuration self.configuration = instance.configuration
self.pollsConfiguration = info.pollsConfiguration self.pollsConfiguration = instance.pollsConfiguration
self.translation = info.translation
} }
} }
@ -45,8 +43,7 @@ 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,7 +54,6 @@ 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]
@ -140,7 +139,6 @@ 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

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23B92" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="23A344" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES"> <entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/> <attribute name="acct" attributeType="String"/>
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@ -41,7 +41,6 @@
<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">
@ -110,7 +109,6 @@
<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"/>
@ -125,8 +123,7 @@
<attribute name="url" optional="YES" attributeType="URI"/> <attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibilityString" attributeType="String"/> <attribute name="visibilityString" attributeType="String"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/> <relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogs" inverseEntity="Status"/> <relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
<relationship name="reblogs" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
<relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/> <relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/>
<uniquenessConstraints> <uniquenessConstraints>
<uniquenessConstraint> <uniquenessConstraint>

View File

@ -33,37 +33,41 @@ public struct LazilyDecoding<Enclosing: NSManagedObject, Value: Codable> {
public static subscript(_enclosingInstance instance: Enclosing, wrapped wrappedKeyPath: ReferenceWritableKeyPath<Enclosing, Value>, storage storageKeyPath: ReferenceWritableKeyPath<Enclosing, Self>) -> Value { public static subscript(_enclosingInstance instance: Enclosing, wrapped wrappedKeyPath: ReferenceWritableKeyPath<Enclosing, Value>, storage storageKeyPath: ReferenceWritableKeyPath<Enclosing, Self>) -> Value {
get { get {
var wrapper = instance[keyPath: storageKeyPath] instance.performOnContext {
if let value = wrapper.value { var wrapper = instance[keyPath: storageKeyPath]
return value if let value = wrapper.value {
} else { return value
guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback } } else {
do { guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback }
let value = try decoder.decode(Box.self, from: data) do {
wrapper.value = value.value let value = try decoder.decode(Box.self, from: data)
wrapper.observation = instance.observe(wrapper.keyPath, changeHandler: { instance, _ in wrapper.value = value.value
var wrapper = instance[keyPath: storageKeyPath] wrapper.observation = instance.observe(wrapper.keyPath, changeHandler: { instance, _ in
if wrapper.skipClearingOnNextUpdate { var wrapper = instance[keyPath: storageKeyPath]
wrapper.skipClearingOnNextUpdate = false if wrapper.skipClearingOnNextUpdate {
} else { wrapper.skipClearingOnNextUpdate = false
wrapper.removeCachedValue() } else {
} wrapper.removeCachedValue()
}
instance[keyPath: storageKeyPath] = wrapper
})
instance[keyPath: storageKeyPath] = wrapper instance[keyPath: storageKeyPath] = wrapper
}) return value.value
instance[keyPath: storageKeyPath] = wrapper } catch {
return value.value return wrapper.fallback
} catch { }
return wrapper.fallback
} }
} }
} }
set { set {
var wrapper = instance[keyPath: storageKeyPath] instance.performOnContext {
wrapper.value = newValue var wrapper = instance[keyPath: storageKeyPath]
wrapper.skipClearingOnNextUpdate = true wrapper.value = newValue
instance[keyPath: storageKeyPath] = wrapper wrapper.skipClearingOnNextUpdate = true
let newData = try! encoder.encode(Box(value: newValue)) instance[keyPath: storageKeyPath] = wrapper
instance[keyPath: wrapper.keyPath] = newData let newData = try! encoder.encode(Box(value: newValue))
instance[keyPath: wrapper.keyPath] = newData
}
} }
} }
@ -74,6 +78,16 @@ public struct LazilyDecoding<Enclosing: NSManagedObject, Value: Codable> {
} }
extension NSManagedObject {
fileprivate func performOnContext<V>(_ f: () -> V) -> V {
if let managedObjectContext {
managedObjectContext.performAndWait(f)
} else {
f()
}
}
}
extension LazilyDecoding { extension LazilyDecoding {
init<T>(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) where Value == [T] { init<T>(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) where Value == [T] {
self.init(from: keyPath, fallback: []) self.init(from: keyPath, fallback: [])

View File

@ -148,7 +148,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
minDate.addTimeInterval(-7 * 24 * 60 * 60) minDate.addTimeInterval(-7 * 24 * 60 * 60)
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest() let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
statusReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (reblogs.@count = 0)", minDate as NSDate) statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq) let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
deleteStatusReq.resultType = .resultTypeCount deleteStatusReq.resultType = .resultTypeCount
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult { if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {

View File

@ -15,7 +15,6 @@ 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
@ -89,14 +88,11 @@ 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, Translation?, Bool)> { [unowned self] cell, indexPath, item in let mainStatusCell = UICollectionView.CellRegistration<ConversationMainStatusCollectionViewCell, (String, CollapseState, 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, translation: item.2) cell.updateUI(statusID: item.0, state: item.1)
cell.setShowThreadLinks(prev: item.3, next: false) cell.setShowThreadLinks(prev: item.2, 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)
@ -108,7 +104,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, self.mainStatusTranslation, prevLink)) return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, 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))
} }
@ -264,30 +260,6 @@ 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.getInstanceV1() let request = Client.getInstance()
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, InstanceV1) case selected(URL, Instance)
case recommended(InstanceSelector.Instance) case recommended(InstanceSelector.Instance)
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {

View File

@ -118,15 +118,12 @@ struct AppearancePrefsView : View {
Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) { Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) {
Text("Always Show Status Visibility Icons") Text("Always Show Status Visibility Icons")
} }
Toggle(isOn: $preferences.showLinkPreviews) {
Text("Show Link Previews")
}
Toggle(isOn: $preferences.showAttachmentsInTimeline) {
Text("Show Attachments on Timeline")
}
Toggle(isOn: $preferences.hideActionsInTimeline) { Toggle(isOn: $preferences.hideActionsInTimeline) {
Text("Hide Actions on Timeline") Text("Hide Actions on Timeline")
} }
Toggle(isOn: $preferences.showLinkPreviews) {
Text("Show Link Previews")
}
Toggle(isOn: $preferences.underlineTextLinks) { Toggle(isOn: $preferences.underlineTextLinks) {
Text("Underline Links") Text("Underline Links")
} }

View File

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

View File

@ -164,18 +164,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
.store(in: &cancellables) .store(in: &cancellables)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
Preferences.shared.$showAttachmentsInTimeline
// skip the initial value
.dropFirst()
// publisher fires on willChange, wait the change is actually made
.receive(on: DispatchQueue.main)
.sink { [unowned self] _ in
var snapshot = self.dataSource.snapshot()
snapshot.reconfigureItems(snapshot.itemIdentifiers)
self.dataSource.apply(snapshot, animatingDifferences: false)
}
.store(in: &cancellables)
if userActivity != nil { if userActivity != nil {
userActivityNeedsUpdate userActivityNeedsUpdate
.debounce(for: .seconds(1), scheduler: DispatchQueue.main) .debounce(for: .seconds(1), scheduler: DispatchQueue.main)
@ -192,7 +180,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// separate method because InstanceTimelineViewController needs to be able to customize it // separate method because InstanceTimelineViewController needs to be able to customize it
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) { func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
cell.delegate = self cell.delegate = self
cell.showAttachmentsInline = Preferences.shared.showAttachmentsInTimeline
if case .home = timeline { if case .home = timeline {
cell.showFollowedHashtags = true cell.showFollowedHashtags = true
} else { } else {
@ -420,7 +407,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
let hasStatusesToRestore = await loadStatusesToRestore(position: position) let hasStatusesToRestore = await loadStatusesToRestore(position: position)
if hasStatusesToRestore { if hasStatusesToRestore {
await applyItemsToRestore(position: position) applyItemsToRestore(position: position)
loaded = true loaded = true
} }
case .mastodon: case .mastodon:
@ -443,7 +430,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
position.centerStatusID = centerStatusID position.centerStatusID = centerStatusID
let hasStatusesToRestore = await loadStatusesToRestore(position: position) let hasStatusesToRestore = await loadStatusesToRestore(position: position)
if hasStatusesToRestore { if hasStatusesToRestore {
await applyItemsToRestore(position: position) applyItemsToRestore(position: position)
} }
mastodonController.persistentContainer.viewContext.delete(position) mastodonController.persistentContainer.viewContext.delete(position)
} }
@ -458,16 +445,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if let status = mastodonController.persistentContainer.status(for: id) { if let status = mastodonController.persistentContainer.status(for: id) {
// touch the status so that, even if it's old, it doesn't get pruned when we go into the background // touch the status so that, even if it's old, it doesn't get pruned when we go into the background
status.touch() status.touch()
// there was a bug where th the reblogged status would get pruned even when it was still refernced by the reblog
// as a temporary workaround, until there are no longer user db's in this state,
// check if the reblog is invalid and reload the status if so
if let reblog = status.reblog,
// force the fault to fire
case _ = reblog.id,
reblog.isDeleted {
unloaded.append(id)
}
} else { } else {
unloaded.append(id) unloaded.append(id)
} }
@ -532,7 +509,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
@MainActor @MainActor
private func applyItemsToRestore(position: TimelinePosition) async { private func applyItemsToRestore(position: TimelinePosition) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses]) snapshot.appendSections([.statuses])
let statusIDs = position.statusIDs let statusIDs = position.statusIDs
@ -545,13 +522,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
"statusIDs": position.statusIDs "statusIDs": position.statusIDs
] ]
SentrySDK.addBreadcrumb(crumb) SentrySDK.addBreadcrumb(crumb)
await apply(snapshot, animatingDifferences: false) dataSource.apply(snapshot, animatingDifferences: false) {
if let centerStatusID, if let centerStatusID,
let index = statusIDs.firstIndex(of: centerStatusID) { let index = statusIDs.firstIndex(of: centerStatusID) {
self.scrollToItem(item: items[index]) self.scrollToItem(item: items[index])
stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)") stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)")
} else { } else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID") stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
}
} }
} }

View File

@ -30,8 +30,8 @@ class AttachmentsContainerView: UIView {
} }
} }
private var blurView: UIVisualEffectView? var blurView: UIVisualEffectView?
private var hideButtonView: UIVisualEffectView? var hideButtonView: UIVisualEffectView?
var contentHidden: Bool! { var contentHidden: Bool! {
didSet { didSet {
guard let blurView = blurView, guard let blurView = blurView,
@ -42,8 +42,6 @@ class AttachmentsContainerView: UIView {
} }
} }
private var label: UILabel?
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
commonInit() commonInit()
@ -69,26 +67,21 @@ class AttachmentsContainerView: UIView {
// MARK: - User Interaface // MARK: - User Interaface
func updateUI(attachments: [Attachment], labelOnly: Bool = false) { func updateUI(attachments: [Attachment]) {
let newTokens = attachments.map { AttachmentToken(attachment: $0) } let newTokens = attachments.map { AttachmentToken(attachment: $0) }
guard !labelOnly else {
self.attachments = attachments
self.attachmentTokens = newTokens
updateLabel(attachments: attachments)
return
}
guard self.attachmentTokens != newTokens else { guard self.attachmentTokens != newTokens else {
self.isHidden = attachments.isEmpty
return return
} }
self.attachments = attachments self.attachments = attachments
self.attachmentTokens = newTokens self.attachmentTokens = newTokens
removeAttachmentViews() attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
hideButtonView?.isHidden = false attachmentViews.removeAllObjects()
attachmentStacks.allObjects.forEach { $0.removeFromSuperview() }
attachmentStacks.removeAllObjects()
moreView?.removeFromSuperview()
var accessibilityElements = [Any]() var accessibilityElements = [Any]()
@ -291,14 +284,6 @@ class AttachmentsContainerView: UIView {
self.accessibilityElements = accessibilityElements self.accessibilityElements = accessibilityElements
} }
private func removeAttachmentViews() {
attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
attachmentViews.removeAllObjects()
attachmentStacks.allObjects.forEach { $0.removeFromSuperview() }
attachmentStacks.removeAllObjects()
moreView?.removeFromSuperview()
}
private func createAttachmentView(index: Int, hSize: RelativeSize, vSize: RelativeSize) -> AttachmentView { private func createAttachmentView(index: Int, hSize: RelativeSize, vSize: RelativeSize) -> AttachmentView {
let attachmentView = AttachmentView(attachment: attachments[index], index: index) let attachmentView = AttachmentView(attachment: attachments[index], index: index)
attachmentView.delegate = delegate attachmentView.delegate = delegate
@ -411,35 +396,6 @@ class AttachmentsContainerView: UIView {
]) ])
} }
private func updateLabel(attachments: [Attachment]) {
removeAttachmentViews()
blurView?.isHidden = true
hideButtonView?.isHidden = true
aspectRatioConstraint?.isActive = false
if label == nil {
if attachments.isEmpty {
accessibilityElements = []
return
}
label = UILabel()
label!.font = .preferredFont(forTextStyle: .body)
label!.adjustsFontForContentSizeCategory = true
label!.textColor = .secondaryLabel
label!.translatesAutoresizingMaskIntoConstraints = false
addSubview(label!)
NSLayoutConstraint.activate([
label!.leadingAnchor.constraint(equalTo: leadingAnchor),
label!.trailingAnchor.constraint(equalTo: trailingAnchor),
label!.topAnchor.constraint(equalTo: topAnchor),
label!.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
label!.text = "\(attachments.count) attachment\(attachments.count == 1 ? "" : "s")"
accessibilityElements = [label!]
}
// MARK: - Interaction // MARK: - Interaction
@objc func blurViewTapped() { @objc func blurViewTapped() {

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(set) var htmlConverter = HTMLConverter() private 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: InstanceV1? var instance: Instance?
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: InstanceV1) { func updateUI(instance: Instance) {
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, StatusContentView { class StatusPollView: UIView, StatusContentPollView {
private static let formatter: DateComponentsFormatter = { private static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter() let f = DateComponentsFormatter()

View File

@ -117,40 +117,18 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside) $0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
} }
private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [ let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: true).configure {
contentTextView, $0.contentTextView.defaultFont = ConversationMainStatusCollectionViewCell.contentFont
cardView, $0.contentTextView.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont
attachmentsView, $0.contentTextView.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle
pollView, $0.contentTextView.isSelectable = true
] as! [any StatusContentView], useTopSpacer: true).configure { $0.contentTextView.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber]
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)
@ -311,7 +289,6 @@ 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!
@ -341,9 +318,9 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
accountDetailAccessibilityElement, accountDetailAccessibilityElement,
contentWarningLabel, contentWarningLabel,
collapseButton, collapseButton,
contentTextView, contentContainer.contentTextView,
attachmentsView, contentContainer.attachmentsView,
pollView, contentContainer.pollView,
favoritesCountButton, favoritesCountButton,
reblogsCountButton, reblogsCountButton,
timestampAndClientLabel, timestampAndClientLabel,
@ -371,7 +348,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
// MARK: Configure UI // MARK: Configure UI
func updateUI(statusID: String, state: CollapseState, translation: Translation?) { func updateUI(statusID: String, state: CollapseState) {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError() fatalError()
} }
@ -381,17 +358,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
self.statusID = statusID self.statusID = statusID
self.statusState = state self.statusState = state
let attributedTranslatedContent: NSAttributedString? = translation.map { doUpdateUI(status: status)
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
@ -416,31 +383,6 @@ 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() {
@ -545,18 +487,6 @@ 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() {
guard let translateButton,
let translateStatus else {
return
}
var config = translateButton.configuration!
config.showsActivityIndicator = true
translateButton.configuration = config
// activity indicator will be hidden when translation finishes and the cell is reconfigured
translateStatus()
}
} }
private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement { private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement {
@ -625,13 +555,3 @@ 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,11 +24,7 @@ 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 { get } var contentContainer: StatusContentContainer<StatusContentTextView, StatusPollView> { 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 }
@ -48,7 +44,6 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
var isGrayscale: Bool { get set } var isGrayscale: Bool { get set }
var cancellables: Set<AnyCancellable> { get set } var cancellables: Set<AnyCancellable> { get set }
func updateAttachmentsUI(status: StatusMO)
func updateUIForPreferences(status: StatusMO) func updateUIForPreferences(status: StatusMO)
func updateStatusState(status: StatusMO) func updateStatusState(status: StatusMO)
func estimateContentHeight() -> CGFloat func estimateContentHeight() -> CGFloat
@ -94,20 +89,21 @@ extension StatusCollectionViewCell {
updateAccountUI(account: status.account) updateAccountUI(account: status.account)
contentTextView.setTextFrom(status: status, precomputed: precomputedContent) contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent)
contentTextView.navigationDelegate = delegate contentContainer.contentTextView.navigationDelegate = delegate
self.updateAttachmentsUI(status: status) contentContainer.attachmentsView.delegate = self
pollView.isHidden = status.poll == nil contentContainer.attachmentsView.updateUI(attachments: status.attachments)
pollView.mastodonController = mastodonController contentContainer.pollView.isHidden = status.poll == nil
pollView.delegate = delegate contentContainer.pollView.mastodonController = mastodonController
pollView.updateUI(status: status, poll: status.poll) contentContainer.pollView.delegate = delegate
contentContainer.pollView.updateUI(status: status, poll: status.poll)
if Preferences.shared.showLinkPreviews { if Preferences.shared.showLinkPreviews {
cardView.updateUI(status: status) contentContainer.cardView.updateUI(status: status)
cardView.isHidden = status.card == nil contentContainer.cardView.isHidden = status.card == nil
cardView.navigationDelegate = delegate contentContainer.cardView.navigationDelegate = delegate
cardView.actionProvider = delegate contentContainer.cardView.actionProvider = delegate
} else { } else {
cardView.isHidden = true contentContainer.cardView.isHidden = true
} }
updateUIForPreferences(status: status) updateUIForPreferences(status: status)
@ -171,11 +167,6 @@ extension StatusCollectionViewCell {
return true return true
} }
func updateAttachmentsUI(status: StatusMO) {
attachmentsView.delegate = self
attachmentsView.updateUI(attachments: status.attachments)
}
func updateAccountUI(account: AccountMO) { func updateAccountUI(account: AccountMO) {
avatarImageView.update(for: account.avatar) avatarImageView.update(for: account.avatar)
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)
@ -186,20 +177,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 cardView.isHidden != newCardHidden { if contentContainer.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:
attachmentsView.contentHidden = false contentContainer.attachmentsView.contentHidden = false
case .always: case .always:
attachmentsView.contentHidden = true contentContainer.attachmentsView.contentHidden = true
default: default:
if status.sensitive { if status.sensitive {
attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning contentContainer.attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
} else { } else {
attachmentsView.contentHidden = false contentContainer.attachmentsView.contentHidden = false
} }
} }
@ -215,8 +206,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 contentTextView.hasEmojis { if contentContainer.contentTextView.hasEmojis {
contentTextView.setEmojis(status.emojis, identifier: status.id) contentContainer.contentTextView.setEmojis(status.emojis, identifier: status.id)
} }
displayNameLabel.updateForAccountDisplayName(account: status.account) displayNameLabel.updateForAccountDisplayName(account: status.account)
} }
@ -239,10 +230,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) ?? [])
pollView.isHidden = status.poll == nil contentContainer.pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController contentContainer.pollView.mastodonController = mastodonController
pollView.delegate = delegate contentContainer.pollView.delegate = delegate
pollView.updateUI(status: status, poll: status.poll) contentContainer.pollView.updateUI(status: status, poll: status.poll)
} }
func setShowThreadLinks(prev: Bool, next: Bool) { func setShowThreadLinks(prev: Bool, next: Bool) {
@ -331,7 +322,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(attachmentsView.getAttachmentView(for:)) let sourceViews = status.attachments.map(contentContainer.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,11 +8,45 @@
import UIKit import UIKit
class StatusContentContainer: UIView { protocol StatusContentPollView: UIView {
// TODO: this is a weird place for this func estimateHeight(effectiveWidth: CGFloat) -> CGFloat
static var cardViewHeight: CGFloat { 90 } }
class StatusContentContainer<ContentView: ContentTextView, PollView: StatusContentPollView>: UIView {
private var arrangedSubviews: [any StatusContentView] private var useTopSpacer = false
private let 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] = []
@ -27,12 +61,8 @@ class StatusContentContainer: UIView {
subviews.filter { !$0.isHidden }.map(\.bounds.height).reduce(0, +) subviews.filter { !$0.isHidden }.map(\.bounds.height).reduce(0, +)
} }
init(arrangedSubviews: [any StatusContentView], useTopSpacer: Bool) { init(useTopSpacer: Bool) {
var arrangedSubviews = arrangedSubviews self.useTopSpacer = useTopSpacer
if useTopSpacer {
arrangedSubviews.insert(TopSpacerView(), at: 0)
}
self.arrangedSubviews = arrangedSubviews
super.init(frame: .zero) super.init(frame: .zero)
@ -40,14 +70,10 @@ class StatusContentContainer: UIView {
subview.translatesAutoresizingMaskIntoConstraints = false subview.translatesAutoresizingMaskIntoConstraints = false
addSubview(subview) addSubview(subview)
if subview.statusContentFillsHorizontally { NSLayoutConstraint.activate([
NSLayoutConstraint.activate([ subview.leadingAnchor.constraint(equalTo: leadingAnchor),
subview.leadingAnchor.constraint(equalTo: leadingAnchor), subview.trailingAnchor.constraint(equalTo: trailingAnchor),
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
@ -56,21 +82,17 @@ class StatusContentContainer: UIView {
setNeedsUpdateConstraints() setNeedsUpdateConstraints()
updateObservations() isHiddenObservations = arrangedSubviews.map {
$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 {
@ -107,31 +129,6 @@ class StatusContentContainer: UIView {
super.updateConstraints() super.updateConstraints()
} }
func insertArrangedSubview(_ view: any StatusContentView, after: any StatusContentView) {
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
if view.statusContentFillsHorizontally {
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: leadingAnchor),
view.trailingAnchor.constraint(equalTo: trailingAnchor),
])
} else {
view.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
}
let index = arrangedSubviews.firstIndex(where: { $0 === after })!
arrangedSubviews.insert(view, at: index + 1)
setNeedsUpdateConstraints()
updateObservations()
}
func removeArrangedSubview(_ view: any StatusContentView) {
view.removeFromSuperview()
arrangedSubviews.removeAll(where: { $0 === view })
setNeedsUpdateConstraints()
updateObservations()
}
func setCollapsed(_ collapsed: Bool) { func setCollapsed(_ collapsed: Bool) {
guard collapsed != isCollapsed else { guard collapsed != isCollapsed else {
return return
@ -150,67 +147,18 @@ class StatusContentContainer: UIView {
// 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
for view in arrangedSubviews where !view.isHidden { height += contentTextView.sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height
height += view.estimateHeight(effectiveWidth: effectiveWidth) if !cardView.isHidden {
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,33 +186,24 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.setContentCompressionResistancePriority(.required, for: .vertical) $0.setContentCompressionResistancePriority(.required, for: .vertical)
} }
private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [ let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: false).configure {
contentTextView, $0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
cardView, $0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
attachmentsView, $0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
pollView,
] as! [any StatusContentView], useTopSpacer: false).configure {
$0.setContentHuggingPriority(.defaultLow, for: .vertical) $0.setContentHuggingPriority(.defaultLow, for: .vertical)
} }
private var contentTextView: StatusContentTextView {
let contentTextView = StatusContentTextView().configure { contentContainer.contentTextView
$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 {
let cardView = StatusCardView().configure { contentContainer.cardView
$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 {
@ -306,7 +297,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
var showReplyIndicator = true var showReplyIndicator = true
var showPinned: Bool = false var showPinned: Bool = false
var showFollowedHashtags: Bool = false var showFollowedHashtags: Bool = false
var showAttachmentsInline = true
// alas these need to be internal so they're accessible from the protocol extensions // alas these need to be internal so they're accessible from the protocol extensions
var statusID: String! var statusID: String!
@ -590,7 +580,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
if let reblogStatus { if let reblogStatus {
hideTimelineReason = false hideTimelineReason = false
updateRebloggerLabel(reblogger: reblogStatus.account) updateRebloggerLabel(reblogger: reblogStatus.account)
} else if showFollowedHashtags { }
if showFollowedHashtags {
let hashtags = mastodonController.followedHashtags.filter({ followed in status.hashtags.contains(where: { followed.name == $0.name }) }) let hashtags = mastodonController.followedHashtags.filter({ followed in status.hashtags.contains(where: { followed.name == $0.name }) })
if !hashtags.isEmpty { if !hashtags.isEmpty {
hideTimelineReason = false hideTimelineReason = false
@ -653,11 +645,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
baseUpdateStatusState(status: status) baseUpdateStatusState(status: status)
} }
func updateAttachmentsUI(status: StatusMO) {
attachmentsView.delegate = self
attachmentsView.updateUI(attachments: status.attachments, labelOnly: !showAttachmentsInline)
}
func estimateContentHeight() -> CGFloat { func estimateContentHeight() -> CGFloat {
let width = bounds.width /* leading spacing: */ - 16 /* avatar: */ - 50 /* spacing: 8 */ - 8 /* trailing spacing: */ - 16 let width = bounds.width /* leading spacing: */ - 16 /* avatar: */ - 50 /* spacing: 8 */ - 8 /* trailing spacing: */ - 16
return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width) return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width)

View File

@ -10,7 +10,7 @@
// https://help.apple.com/xcode/#/dev745c5c974 // https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2023.8 MARKETING_VERSION = 2023.8
CURRENT_PROJECT_VERSION = 109 CURRENT_PROJECT_VERSION = 107
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev