Compare commits

...

15 Commits

Author SHA1 Message Date
Shadowfacts e8576277e0 Bump build number and update changelog 2023-12-17 18:16:47 -05:00
Shadowfacts 7f0a9d8d5a Fix status that is reblogged and contains a followed hashtag not showing reblogger label 2023-12-17 18:09:25 -05:00
Shadowfacts 51f4a780e2 Show loading indicator while translating status 2023-12-16 16:14:18 -05:00
Shadowfacts 180a8eb18d Fix Status reblogs inverse relationship being to-one instead of to-many 2023-12-14 21:57:44 -05:00
Shadowfacts eb61043867 Fix timeline state restoration not returning to correct scroll position in certain circumstances
All of the work done by the restoreInitial callback needs to be async,
so that when the TimelineLikeController signals that the loading
indicator should be removed, the collection view is in the right place.

Closes #439
2023-12-14 18:28:22 -05:00
Shadowfacts e09935125f Fix copying/pasting images from Safari on macOS not working
Closes #453
2023-12-14 18:01:34 -05:00
Shadowfacts e8ef9345e9 Fix visibility/local-only buttons not appearing on Catalyst
You need to pass the configuration to the initializer to get it to show up in the Mac idiom

Fixes #452
2023-12-05 22:00:48 -05:00
Shadowfacts 28c1a9092b Add server-provided translation
Closes #331
2023-12-04 19:31:51 -05:00
Shadowfacts 5e609aa40d V2 instance API, add translation to InstanceFeatures 2023-12-04 17:55:03 -05:00
Shadowfacts 158940f8e6 Refactor StatusContentContainer to use an array of subviews 2023-12-04 17:06:10 -05:00
Shadowfacts 141e8b96a5 Show label when attachments are hidden in timelines 2023-12-04 16:38:04 -05:00
Shadowfacts 108a02826f Remove incorrect workaround for crash when LazilyDecoding used on nil MO 2023-12-04 16:20:22 -05:00
Shadowfacts be1ca70ebf Add preference for showing attachments in timeline
Closes #330
2023-12-04 16:18:54 -05:00
Shadowfacts 34edd8a13f Fix reblogged statuses being pruned while still referenced, add workaround for crash 2023-12-03 15:08:38 -05:00
Shadowfacts 23f383a7f9 Get rid of network request during share extension launch
Closes #438
2023-12-02 15:33:15 -05:00
37 changed files with 803 additions and 289 deletions

View File

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

@ -26,13 +26,26 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
} }
public func makeUIView(context: Context) -> UIButton { public func makeUIView(context: Context) -> UIButton {
let button = UIButton() let button = UIButton(configuration: makeConfiguration())
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
@ -43,16 +56,10 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
if buttonStyle.hasLabel { if buttonStyle.hasLabel {
config.title = selectedOption.title config.title = selectedOption.title
} }
button.configuration = config #if targetEnvironment(macCatalyst)
button.menu = UIMenu(children: options.map { opt in config.macIdiomStyle = .bordered
let action = UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in #endif
selection = opt.value return config
}
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,6 +62,7 @@ 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)
@ -127,6 +128,7 @@ 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)
@ -182,6 +184,7 @@ 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
@ -253,6 +256,7 @@ 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,6 +16,11 @@ 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 {
@ -47,21 +52,47 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
self.accessToken = accessToken self.accessToken = accessToken
} }
init?(userDefaultsDict dict: [String: String]) { init?(userDefaultsDict dict: [String: Any]) {
guard let id = dict["id"], guard let id = dict["id"] as? String,
let instanceURL = dict["instanceURL"], let instanceURL = dict["instanceURL"] as? String,
let url = URL(string: instanceURL), let url = URL(string: instanceURL),
let clientID = dict["clientID"], let clientID = dict["clientID"] as? String,
let secret = dict["clientSecret"], let secret = dict["clientSecret"] as? String,
let accessToken = dict["accessToken"] else { let accessToken = dict["accessToken"] as? String 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"] self.username = dict["username"] as? String
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,19 +46,7 @@ public class UserAccountsManager: ObservableObject {
} }
set { set {
objectWillChange.send() objectWillChange.send()
let array = newValue.map { (info) -> [String: String] in let array = newValue.map(\.userDefaultsDict)
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)
} }
} }
@ -147,6 +135,17 @@ public class UserAccountsManager: ObservableObject {
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
}
} }
public extension Notification.Name { public extension Notification.Name {

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,18 +53,14 @@ 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: visibility, visibility: Preferences.shared.defaultPostVisibility.resolved(withServerDefault: account.serverDefaultVisibility.flatMap(Visibility.init(rawValue:))),
language: serverPrefs?.postingDefaultLanguage, language: account.serverDefaultLanguage,
localOnly: !(serverPrefs?.postingDefaultFederation ?? true) localOnly: !(account.serverDefaultFederation ?? true)
) )
for attachment in await attachments { for attachment in await attachments {

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 {
@ -371,15 +385,18 @@ 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 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

@ -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="22225" systemVersion="23A344" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23B92" 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,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"/>
@ -123,7 +125,8 @@
<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"/> <relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogs" inverseEntity="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,7 +33,6 @@ 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 {
instance.performOnContext {
var wrapper = instance[keyPath: storageKeyPath] var wrapper = instance[keyPath: storageKeyPath]
if let value = wrapper.value { if let value = wrapper.value {
return value return value
@ -58,9 +57,7 @@ public struct LazilyDecoding<Enclosing: NSManagedObject, Value: Codable> {
} }
} }
} }
}
set { set {
instance.performOnContext {
var wrapper = instance[keyPath: storageKeyPath] var wrapper = instance[keyPath: storageKeyPath]
wrapper.value = newValue wrapper.value = newValue
wrapper.skipClearingOnNextUpdate = true wrapper.skipClearingOnNextUpdate = true
@ -69,7 +66,6 @@ public struct LazilyDecoding<Enclosing: NSManagedObject, Value: Codable> {
instance[keyPath: wrapper.keyPath] = newData instance[keyPath: wrapper.keyPath] = newData
} }
} }
}
mutating func removeCachedValue() { mutating func removeCachedValue() {
value = nil value = nil
@ -78,16 +74,6 @@ 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 < %@)", minDate as NSDate) statusReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (reblogs.@count = 0)", 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,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

@ -118,12 +118,15 @@ 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.hideActionsInTimeline) {
Text("Hide Actions on Timeline")
}
Toggle(isOn: $preferences.showLinkPreviews) { Toggle(isOn: $preferences.showLinkPreviews) {
Text("Show Link Previews") Text("Show Link Previews")
} }
Toggle(isOn: $preferences.showAttachmentsInTimeline) {
Text("Show Attachments on Timeline")
}
Toggle(isOn: $preferences.hideActionsInTimeline) {
Text("Hide Actions on Timeline")
}
Toggle(isOn: $preferences.underlineTextLinks) { Toggle(isOn: $preferences.underlineTextLinks) {
Text("Underline Links") Text("Underline Links")
} }

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

@ -164,6 +164,18 @@ 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)
@ -180,6 +192,7 @@ 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 {
@ -407,7 +420,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
let hasStatusesToRestore = await loadStatusesToRestore(position: position) let hasStatusesToRestore = await loadStatusesToRestore(position: position)
if hasStatusesToRestore { if hasStatusesToRestore {
applyItemsToRestore(position: position) await applyItemsToRestore(position: position)
loaded = true loaded = true
} }
case .mastodon: case .mastodon:
@ -430,7 +443,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 {
applyItemsToRestore(position: position) await applyItemsToRestore(position: position)
} }
mastodonController.persistentContainer.viewContext.delete(position) mastodonController.persistentContainer.viewContext.delete(position)
} }
@ -445,6 +458,16 @@ 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)
} }
@ -509,7 +532,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
@MainActor @MainActor
private func applyItemsToRestore(position: TimelinePosition) { private func applyItemsToRestore(position: TimelinePosition) async {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses]) snapshot.appendSections([.statuses])
let statusIDs = position.statusIDs let statusIDs = position.statusIDs
@ -522,7 +545,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
"statusIDs": position.statusIDs "statusIDs": position.statusIDs
] ]
SentrySDK.addBreadcrumb(crumb) SentrySDK.addBreadcrumb(crumb)
dataSource.apply(snapshot, animatingDifferences: false) { await 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])
@ -531,7 +554,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID") stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
} }
} }
}
@MainActor @MainActor
private func restoreStatusesFromMarkerPosition() async -> Bool { private func restoreStatusesFromMarkerPosition() async -> Bool {

View File

@ -30,8 +30,8 @@ class AttachmentsContainerView: UIView {
} }
} }
var blurView: UIVisualEffectView? private var blurView: UIVisualEffectView?
var hideButtonView: UIVisualEffectView? private var hideButtonView: UIVisualEffectView?
var contentHidden: Bool! { var contentHidden: Bool! {
didSet { didSet {
guard let blurView = blurView, guard let blurView = blurView,
@ -42,6 +42,8 @@ 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()
@ -67,21 +69,26 @@ class AttachmentsContainerView: UIView {
// MARK: - User Interaface // MARK: - User Interaface
func updateUI(attachments: [Attachment]) { func updateUI(attachments: [Attachment], labelOnly: Bool = false) {
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
attachmentViews.allObjects.forEach { $0.removeFromSuperview() } removeAttachmentViews()
attachmentViews.removeAllObjects() hideButtonView?.isHidden = false
attachmentStacks.allObjects.forEach { $0.removeFromSuperview() }
attachmentStacks.removeAllObjects()
moreView?.removeFromSuperview()
var accessibilityElements = [Any]() var accessibilityElements = [Any]()
@ -284,6 +291,14 @@ 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
@ -396,6 +411,35 @@ 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 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,18 @@ 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 {
@ -555,3 +625,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 }
@ -44,6 +48,7 @@ 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
@ -89,21 +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
contentContainer.attachmentsView.delegate = self self.updateAttachmentsUI(status: status)
contentContainer.attachmentsView.updateUI(attachments: status.attachments) 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)
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)
@ -167,6 +171,11 @@ 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)
@ -177,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
} }
} }
@ -206,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)
} }
@ -230,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) {
@ -322,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 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] = []
@ -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)
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
@ -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
@ -297,6 +306,7 @@ 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!
@ -580,9 +590,7 @@ 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
@ -645,6 +653,11 @@ 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 = 107 CURRENT_PROJECT_VERSION = 109
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