Compare commits
15 Commits
99caaa0f28
...
e8576277e0
Author | SHA1 | Date |
---|---|---|
Shadowfacts | e8576277e0 | |
Shadowfacts | 7f0a9d8d5a | |
Shadowfacts | 51f4a780e2 | |
Shadowfacts | 180a8eb18d | |
Shadowfacts | eb61043867 | |
Shadowfacts | e09935125f | |
Shadowfacts | e8ef9345e9 | |
Shadowfacts | 28c1a9092b | |
Shadowfacts | 5e609aa40d | |
Shadowfacts | 158940f8e6 | |
Shadowfacts | 141e8b96a5 | |
Shadowfacts | 108a02826f | |
Shadowfacts | be1ca70ebf | |
Shadowfacts | 34edd8a13f | |
Shadowfacts | 23f383a7f9 |
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -1,5 +1,18 @@
|
|||
# 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)
|
||||
Features/Improvements:
|
||||
- Style blockquotes in statuses
|
||||
|
|
|
@ -54,14 +54,20 @@ class ToolbarController: ViewController {
|
|||
cwButton
|
||||
|
||||
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
|
||||
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
||||
.padding(.horizontal, -8)
|
||||
#endif
|
||||
.disabled(draft.editedStatusID != nil)
|
||||
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
|
||||
|
||||
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
||||
localOnlyPicker
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.padding(.leading, 4)
|
||||
#else
|
||||
.padding(.horizontal, -8)
|
||||
#endif
|
||||
.disabled(draft.editedStatusID != nil)
|
||||
}
|
||||
|
||||
|
|
|
@ -157,10 +157,11 @@ extension DraftAttachment: NSItemProviderReading {
|
|||
var data = data
|
||||
var type = UTType(typeIdentifier)!
|
||||
|
||||
// this seems to only occur when the item is a UIImage, rather than just image data,
|
||||
// which seems to only occur when sharing a screenshot directly from the markup screen
|
||||
// the type is .image in certain circumstances:
|
||||
// - macOS: image copied from macOS Safari -> only UIImage(data: data) works
|
||||
// - iOS: sharing screenshot from markup -> only NSKeyedUnarchiver works
|
||||
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() {
|
||||
data = pngData
|
||||
type = .png
|
||||
|
|
|
@ -21,7 +21,8 @@ public class InstanceFeatures: ObservableObject {
|
|||
@Published public private(set) var charsReservedPerURL = 23
|
||||
@Published public private(set) var maxPollOptionChars: Int?
|
||||
@Published public private(set) var maxPollOptionsCount: Int?
|
||||
@Published public private(set) var mediaAttachmentsConfiguration: Instance.MediaAttachmentsConfiguration?
|
||||
@Published public private(set) var mediaAttachmentsConfiguration: InstanceV1.MediaAttachmentsConfiguration?
|
||||
@Published public private(set) var translation: Bool = false
|
||||
|
||||
public var localOnlyPosts: Bool {
|
||||
switch instanceType {
|
||||
|
@ -240,6 +241,7 @@ public class InstanceFeatures: ObservableObject {
|
|||
maxPollOptionsCount = pollsConfig.maxOptions
|
||||
}
|
||||
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
|
||||
translation = instance.translation
|
||||
|
||||
_featuresUpdated.send()
|
||||
}
|
||||
|
|
|
@ -9,26 +9,39 @@ import Foundation
|
|||
import Pachyderm
|
||||
|
||||
public struct InstanceInfo {
|
||||
public let version: String
|
||||
public let maxStatusCharacters: Int?
|
||||
public let configuration: Instance.Configuration?
|
||||
public let pollsConfiguration: Instance.PollsConfiguration?
|
||||
public var version: String
|
||||
public var maxStatusCharacters: Int?
|
||||
public var configuration: InstanceV1.Configuration?
|
||||
public var pollsConfiguration: InstanceV1.PollsConfiguration?
|
||||
public var translation: Bool
|
||||
|
||||
public init(version: String, maxStatusCharacters: Int?, configuration: Instance.Configuration?, pollsConfiguration: Instance.PollsConfiguration?) {
|
||||
public init(
|
||||
version: String,
|
||||
maxStatusCharacters: Int?,
|
||||
configuration: InstanceV1.Configuration?,
|
||||
pollsConfiguration: InstanceV1.PollsConfiguration?,
|
||||
translation: Bool
|
||||
) {
|
||||
self.version = version
|
||||
self.maxStatusCharacters = maxStatusCharacters
|
||||
self.configuration = configuration
|
||||
self.pollsConfiguration = pollsConfiguration
|
||||
self.translation = translation
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceInfo {
|
||||
public init(instance: Instance) {
|
||||
public init(v1 instance: InstanceV1) {
|
||||
self.init(
|
||||
version: instance.version,
|
||||
maxStatusCharacters: instance.maxStatusCharacters,
|
||||
configuration: instance.configuration,
|
||||
pollsConfiguration: instance.pollsConfiguration
|
||||
pollsConfiguration: instance.pollsConfiguration,
|
||||
translation: false
|
||||
)
|
||||
}
|
||||
|
||||
public mutating func update(v2: InstanceV2) {
|
||||
translation = v2.configuration.translation.enabled
|
||||
}
|
||||
}
|
||||
|
|
|
@ -231,8 +231,12 @@ public class Client {
|
|||
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
|
||||
}
|
||||
|
||||
public static func getInstance() -> Request<Instance> {
|
||||
return Request<Instance>(method: .get, path: "/api/v1/instance")
|
||||
public static func getInstanceV1() -> Request<InstanceV1> {
|
||||
return Request<InstanceV1>(method: .get, path: "/api/v1/instance")
|
||||
}
|
||||
|
||||
public static func getInstanceV2() -> Request<InstanceV2> {
|
||||
return Request<InstanceV2>(method: .get, path: "/api/v2/instance")
|
||||
}
|
||||
|
||||
public static func getCustomEmoji() -> Request<[Emoji]> {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// Instance.swift
|
||||
// InstanceV1.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Instance: Decodable, Sendable {
|
||||
public struct InstanceV1: Decodable, Sendable {
|
||||
public let uri: String
|
||||
public let title: String
|
||||
public let description: String
|
||||
|
@ -92,7 +92,7 @@ public struct Instance: Decodable, Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
extension Instance {
|
||||
extension InstanceV1 {
|
||||
public struct Stats: Decodable, Sendable {
|
||||
public let domainCount: Int?
|
||||
public let statusCount: Int?
|
||||
|
@ -106,7 +106,7 @@ extension Instance {
|
|||
}
|
||||
}
|
||||
|
||||
extension Instance {
|
||||
extension InstanceV1 {
|
||||
public struct Configuration: Codable, Sendable {
|
||||
public let statuses: StatusesConfiguration
|
||||
public let mediaAttachments: MediaAttachmentsConfiguration
|
||||
|
@ -121,7 +121,8 @@ extension Instance {
|
|||
}
|
||||
}
|
||||
|
||||
extension Instance {
|
||||
extension InstanceV1 {
|
||||
// note: also used by InstanceV2
|
||||
public struct StatusesConfiguration: Codable, Sendable {
|
||||
public let maxCharacters: Int
|
||||
public let maxMediaAttachments: Int
|
||||
|
@ -135,7 +136,8 @@ extension Instance {
|
|||
}
|
||||
}
|
||||
|
||||
extension Instance {
|
||||
extension InstanceV1 {
|
||||
// note: also used by InstanceV2
|
||||
public struct MediaAttachmentsConfiguration: Codable, Sendable {
|
||||
public let supportedMIMETypes: [String]
|
||||
public let imageSizeLimit: Int
|
||||
|
@ -155,7 +157,8 @@ extension Instance {
|
|||
}
|
||||
}
|
||||
|
||||
extension Instance {
|
||||
extension InstanceV1 {
|
||||
// note: also used by InstanceV2
|
||||
public struct PollsConfiguration: Codable, Sendable {
|
||||
public let maxOptions: Int
|
||||
public let maxCharactersPerOption: Int
|
||||
|
@ -171,7 +174,8 @@ extension Instance {
|
|||
}
|
||||
}
|
||||
|
||||
extension Instance {
|
||||
extension InstanceV1 {
|
||||
// note: also used by InstanceV2
|
||||
public struct Rule: Decodable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let text: String
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -177,6 +177,10 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
|||
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
|
||||
}
|
||||
|
||||
public static func translate(_ statusID: String) -> Request<Translation> {
|
||||
return Request(method: .post, path: "/api/v1/statuses/\(statusID)/translate")
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case uri
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -26,13 +26,26 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
|
|||
}
|
||||
|
||||
public func makeUIView(context: Context) -> UIButton {
|
||||
let button = UIButton()
|
||||
let button = UIButton(configuration: makeConfiguration())
|
||||
button.showsMenuAsPrimaryAction = true
|
||||
button.setContentHuggingPriority(.required, for: .horizontal)
|
||||
return button
|
||||
}
|
||||
|
||||
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()
|
||||
if #available(iOS 16.0, *) {
|
||||
config.indicator = .popup
|
||||
|
@ -43,16 +56,10 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
|
|||
if buttonStyle.hasLabel {
|
||||
config.title = selectedOption.title
|
||||
}
|
||||
button.configuration = config
|
||||
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
|
||||
#if targetEnvironment(macCatalyst)
|
||||
config.macIdiomStyle = .bordered
|
||||
#endif
|
||||
return config
|
||||
}
|
||||
|
||||
public struct Option {
|
||||
|
|
|
@ -62,6 +62,7 @@ public final class Preferences: Codable, ObservableObject {
|
|||
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
||||
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
|
||||
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) {
|
||||
self.defaultPostVisibility = .visibility(existing)
|
||||
|
@ -127,6 +128,7 @@ public final class Preferences: Codable, ObservableObject {
|
|||
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
|
||||
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
|
||||
try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
|
||||
try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline)
|
||||
|
||||
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
||||
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
||||
|
@ -182,6 +184,7 @@ public final class Preferences: Codable, ObservableObject {
|
|||
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
|
||||
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
|
||||
@Published public var underlineTextLinks = false
|
||||
@Published public var showAttachmentsInTimeline = true
|
||||
|
||||
// MARK: Composing
|
||||
@Published public var defaultPostVisibility = PostVisibility.serverDefault
|
||||
|
@ -253,6 +256,7 @@ public final class Preferences: Codable, ObservableObject {
|
|||
case trailingStatusSwipeActions
|
||||
case widescreenNavigationMode
|
||||
case underlineTextLinks
|
||||
case showAttachmentsInTimeline
|
||||
|
||||
case defaultPostVisibility
|
||||
case defaultReplyVisibility
|
||||
|
|
|
@ -16,6 +16,11 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
|
|||
public private(set) var username: 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"
|
||||
|
||||
static func id(instanceURL: URL, username: String?) -> String {
|
||||
|
@ -47,21 +52,47 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
|
|||
self.accessToken = accessToken
|
||||
}
|
||||
|
||||
init?(userDefaultsDict dict: [String: String]) {
|
||||
guard let id = dict["id"],
|
||||
let instanceURL = dict["instanceURL"],
|
||||
init?(userDefaultsDict dict: [String: Any]) {
|
||||
guard let id = dict["id"] as? String,
|
||||
let instanceURL = dict["instanceURL"] as? String,
|
||||
let url = URL(string: instanceURL),
|
||||
let clientID = dict["clientID"],
|
||||
let secret = dict["clientSecret"],
|
||||
let accessToken = dict["accessToken"] else {
|
||||
let clientID = dict["clientID"] as? String,
|
||||
let secret = dict["clientSecret"] as? String,
|
||||
let accessToken = dict["accessToken"] as? String else {
|
||||
return nil
|
||||
}
|
||||
self.id = id
|
||||
self.instanceURL = url
|
||||
self.clientID = clientID
|
||||
self.clientSecret = secret
|
||||
self.username = dict["username"]
|
||||
self.username = dict["username"] as? String
|
||||
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
|
||||
|
|
|
@ -46,19 +46,7 @@ public class UserAccountsManager: ObservableObject {
|
|||
}
|
||||
set {
|
||||
objectWillChange.send()
|
||||
let array = newValue.map { (info) -> [String: String] in
|
||||
var res = [
|
||||
"id": info.id,
|
||||
"instanceURL": info.instanceURL.absoluteString,
|
||||
"clientID": info.clientID,
|
||||
"clientSecret": info.clientSecret,
|
||||
"accessToken": info.accessToken
|
||||
]
|
||||
if let username = info.username {
|
||||
res["username"] = username
|
||||
}
|
||||
return res
|
||||
}
|
||||
let array = newValue.map(\.userDefaultsDict)
|
||||
defaults.set(array, forKey: accountsKey)
|
||||
}
|
||||
}
|
||||
|
@ -146,6 +134,17 @@ public class UserAccountsManager: ObservableObject {
|
|||
public func setMostRecentAccount(_ account: UserAccountInfo?) {
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
|
|||
self.instanceFeatures = InstanceFeatures()
|
||||
|
||||
Task { @MainActor in
|
||||
async let instance = try? await run(Client.getInstance()).0
|
||||
async let instance = try? await run(Client.getInstanceV1()).0
|
||||
async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in
|
||||
self.client.nodeInfo { response in
|
||||
switch response {
|
||||
|
@ -40,7 +40,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
|
|||
}
|
||||
})
|
||||
guard let instance = await instance else { return }
|
||||
self.instanceFeatures.update(instance: InstanceInfo(instance: instance), nodeInfo: await nodeInfo)
|
||||
self.instanceFeatures.update(instance: InstanceInfo(v1: instance), nodeInfo: await nodeInfo)
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
|
|
|
@ -53,18 +53,14 @@ class ShareViewController: UIViewController {
|
|||
private func createDraft(account: UserAccountInfo) async -> Draft {
|
||||
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(
|
||||
accountID: account.id,
|
||||
text: await text,
|
||||
contentWarning: "",
|
||||
inReplyToID: nil,
|
||||
visibility: visibility,
|
||||
language: serverPrefs?.postingDefaultLanguage,
|
||||
localOnly: !(serverPrefs?.postingDefaultFederation ?? true)
|
||||
visibility: Preferences.shared.defaultPostVisibility.resolved(withServerDefault: account.serverDefaultVisibility.flatMap(Visibility.init(rawValue:))),
|
||||
language: account.serverDefaultLanguage,
|
||||
localOnly: !(account.serverDefaultFederation ?? true)
|
||||
)
|
||||
|
||||
for attachment in await attachments {
|
||||
|
|
|
@ -53,7 +53,8 @@ class MastodonController: ObservableObject {
|
|||
let instanceFeatures = InstanceFeatures()
|
||||
|
||||
@Published private(set) var account: AccountMO?
|
||||
@Published private(set) var instance: Instance?
|
||||
@Published private(set) var instance: InstanceV1?
|
||||
@Published private var instanceV2: InstanceV2?
|
||||
@Published private(set) var instanceInfo: InstanceInfo!
|
||||
@Published private(set) var nodeInfo: NodeInfo!
|
||||
@Published private(set) var lists: [List] = []
|
||||
|
@ -63,7 +64,7 @@ class MastodonController: ObservableObject {
|
|||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
|
||||
private var pendingOwnInstanceRequestCallbacks = [(Result<InstanceV1, Client.Error>) -> Void]()
|
||||
private var ownInstanceRequest: URLSessionTask?
|
||||
|
||||
var loggedIn: Bool {
|
||||
|
@ -107,9 +108,14 @@ class MastodonController: ObservableObject {
|
|||
|
||||
$instance
|
||||
.compactMap { $0 }
|
||||
.sink { [unowned self] in
|
||||
self.updateActiveInstance(from: $0)
|
||||
self.instanceInfo = InstanceInfo(instance: $0)
|
||||
.combineLatest($instanceV2)
|
||||
.sink {[unowned self] (instance, v2) in
|
||||
var info = InstanceInfo(v1: instance)
|
||||
if let v2 {
|
||||
info.update(v2: v2)
|
||||
}
|
||||
self.instanceInfo = info
|
||||
self.updateActiveInstance(from: info)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
|
@ -217,6 +223,10 @@ class MastodonController: ObservableObject {
|
|||
|
||||
_ = try await (ownAccount, ownInstance)
|
||||
|
||||
if instanceFeatures.hasMastodonVersion(4, 0, 0) {
|
||||
async let _ = try? getOwnInstanceV2()
|
||||
}
|
||||
|
||||
loadLists()
|
||||
_ = await loadFilters()
|
||||
await loadServerPreferences()
|
||||
|
@ -277,7 +287,7 @@ class MastodonController: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
|
||||
func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) {
|
||||
getOwnInstanceInternal(retryAttempt: 0) {
|
||||
if case let .success(instance) = $0 {
|
||||
completion?(instance)
|
||||
|
@ -286,7 +296,7 @@ class MastodonController: ObservableObject {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
func getOwnInstance() async throws -> Instance {
|
||||
func getOwnInstance() async throws -> InstanceV1 {
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
getOwnInstanceInternal(retryAttempt: 0) { result in
|
||||
continuation.resume(with: result)
|
||||
|
@ -294,7 +304,7 @@ class MastodonController: ObservableObject {
|
|||
})
|
||||
}
|
||||
|
||||
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<Instance, Client.Error>) -> Void)?) {
|
||||
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<InstanceV1, Client.Error>) -> Void)?) {
|
||||
// this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
|
@ -306,7 +316,7 @@ class MastodonController: ObservableObject {
|
|||
}
|
||||
|
||||
if ownInstanceRequest == nil {
|
||||
let request = Client.getInstance()
|
||||
let request = Client.getInstanceV1()
|
||||
ownInstanceRequest = run(request) { (response) in
|
||||
switch response {
|
||||
case .failure(let error):
|
||||
|
@ -361,6 +371,10 @@ class MastodonController: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func getOwnInstanceV2() async throws {
|
||||
self.instanceV2 = try await client.run(Client.getInstanceV2()).0
|
||||
}
|
||||
|
||||
// MainActor because the accountPreferences instance is bound to the view context
|
||||
@MainActor
|
||||
private func loadServerPreferences() async {
|
||||
|
@ -371,15 +385,18 @@ class MastodonController: ObservableObject {
|
|||
accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage
|
||||
accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility
|
||||
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
|
||||
if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first {
|
||||
existing.update(from: instance)
|
||||
existing.update(from: info)
|
||||
} else {
|
||||
let new = ActiveInstance(context: context)
|
||||
new.update(from: instance)
|
||||
new.update(from: info)
|
||||
}
|
||||
if context.hasChanges {
|
||||
try? context.save()
|
||||
|
|
|
@ -22,18 +22,20 @@ public final class ActiveInstance: NSManagedObject {
|
|||
@NSManaged public var maxStatusCharacters: Int
|
||||
@NSManaged private var configurationData: Data?
|
||||
@NSManaged private var pollsConfigurationData: Data?
|
||||
@NSManaged public var translation: Bool
|
||||
|
||||
@LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil)
|
||||
public var configuration: Instance.Configuration?
|
||||
public var configuration: InstanceV1.Configuration?
|
||||
|
||||
@LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil)
|
||||
public var pollsConfiguration: Instance.PollsConfiguration?
|
||||
public var pollsConfiguration: InstanceV1.PollsConfiguration?
|
||||
|
||||
func update(from instance: Instance) {
|
||||
self.version = instance.version
|
||||
self.maxStatusCharacters = instance.maxStatusCharacters ?? 500
|
||||
self.configuration = instance.configuration
|
||||
self.pollsConfiguration = instance.pollsConfiguration
|
||||
func update(from info: InstanceInfo) {
|
||||
self.version = info.version
|
||||
self.maxStatusCharacters = info.maxStatusCharacters ?? 500
|
||||
self.configuration = info.configuration
|
||||
self.pollsConfiguration = info.pollsConfiguration
|
||||
self.translation = info.translation
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -43,7 +45,8 @@ extension InstanceInfo {
|
|||
version: activeInstance.version,
|
||||
maxStatusCharacters: activeInstance.maxStatusCharacters,
|
||||
configuration: activeInstance.configuration,
|
||||
pollsConfiguration: activeInstance.pollsConfiguration
|
||||
pollsConfiguration: activeInstance.pollsConfiguration,
|
||||
translation: activeInstance.translation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,6 +54,7 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
|
|||
@NSManaged public var reblog: StatusMO?
|
||||
@NSManaged public var localOnly: Bool
|
||||
@NSManaged public var lastFetchedAt: Date?
|
||||
@NSManaged public var language: String?
|
||||
|
||||
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
|
||||
public var attachments: [Attachment]
|
||||
|
@ -139,6 +140,7 @@ extension StatusMO {
|
|||
self.visibility = status.visibility
|
||||
self.poll = status.poll
|
||||
self.localOnly = status.localOnly ?? false
|
||||
self.language = status.language
|
||||
|
||||
if let existing = container.account(for: status.account.id, in: context) {
|
||||
existing.updateFrom(apiAccount: status.account, container: container)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<?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">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
|
@ -41,6 +41,7 @@
|
|||
<attribute name="configurationData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="translation" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="version" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
|
||||
|
@ -109,6 +110,7 @@
|
|||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
|
||||
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
||||
<attribute name="language" optional="YES" attributeType="String"/>
|
||||
<attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="mentionsData" attributeType="Binary"/>
|
||||
|
@ -123,7 +125,8 @@
|
|||
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||
<attribute name="visibilityString" attributeType="String"/>
|
||||
<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"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
|
|
|
@ -33,41 +33,37 @@ public struct LazilyDecoding<Enclosing: NSManagedObject, Value: Codable> {
|
|||
|
||||
public static subscript(_enclosingInstance instance: Enclosing, wrapped wrappedKeyPath: ReferenceWritableKeyPath<Enclosing, Value>, storage storageKeyPath: ReferenceWritableKeyPath<Enclosing, Self>) -> Value {
|
||||
get {
|
||||
instance.performOnContext {
|
||||
var wrapper = instance[keyPath: storageKeyPath]
|
||||
if let value = wrapper.value {
|
||||
return value
|
||||
} else {
|
||||
guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback }
|
||||
do {
|
||||
let value = try decoder.decode(Box.self, from: data)
|
||||
wrapper.value = value.value
|
||||
wrapper.observation = instance.observe(wrapper.keyPath, changeHandler: { instance, _ in
|
||||
var wrapper = instance[keyPath: storageKeyPath]
|
||||
if wrapper.skipClearingOnNextUpdate {
|
||||
wrapper.skipClearingOnNextUpdate = false
|
||||
} else {
|
||||
wrapper.removeCachedValue()
|
||||
}
|
||||
instance[keyPath: storageKeyPath] = wrapper
|
||||
})
|
||||
var wrapper = instance[keyPath: storageKeyPath]
|
||||
if let value = wrapper.value {
|
||||
return value
|
||||
} else {
|
||||
guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback }
|
||||
do {
|
||||
let value = try decoder.decode(Box.self, from: data)
|
||||
wrapper.value = value.value
|
||||
wrapper.observation = instance.observe(wrapper.keyPath, changeHandler: { instance, _ in
|
||||
var wrapper = instance[keyPath: storageKeyPath]
|
||||
if wrapper.skipClearingOnNextUpdate {
|
||||
wrapper.skipClearingOnNextUpdate = false
|
||||
} else {
|
||||
wrapper.removeCachedValue()
|
||||
}
|
||||
instance[keyPath: storageKeyPath] = wrapper
|
||||
return value.value
|
||||
} catch {
|
||||
return wrapper.fallback
|
||||
}
|
||||
})
|
||||
instance[keyPath: storageKeyPath] = wrapper
|
||||
return value.value
|
||||
} catch {
|
||||
return wrapper.fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
set {
|
||||
instance.performOnContext {
|
||||
var wrapper = instance[keyPath: storageKeyPath]
|
||||
wrapper.value = newValue
|
||||
wrapper.skipClearingOnNextUpdate = true
|
||||
instance[keyPath: storageKeyPath] = wrapper
|
||||
let newData = try! encoder.encode(Box(value: newValue))
|
||||
instance[keyPath: wrapper.keyPath] = newData
|
||||
}
|
||||
var wrapper = instance[keyPath: storageKeyPath]
|
||||
wrapper.value = newValue
|
||||
wrapper.skipClearingOnNextUpdate = true
|
||||
instance[keyPath: storageKeyPath] = wrapper
|
||||
let newData = try! encoder.encode(Box(value: newValue))
|
||||
instance[keyPath: wrapper.keyPath] = newData
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
init<T>(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) where Value == [T] {
|
||||
self.init(from: keyPath, fallback: [])
|
||||
|
|
|
@ -148,7 +148,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
minDate.addTimeInterval(-7 * 24 * 60 * 60)
|
||||
|
||||
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)
|
||||
deleteStatusReq.resultType = .resultTypeCount
|
||||
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {
|
||||
|
|
|
@ -15,6 +15,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
private let mastodonController: MastodonController
|
||||
private let mainStatusID: String
|
||||
private let mainStatusState: CollapseState
|
||||
private var mainStatusTranslation: Translation?
|
||||
var statusIDToScrollToOnLoad: String
|
||||
var showStatusesAutomatically = false
|
||||
|
||||
|
@ -88,11 +89,14 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
|
||||
cell.setShowThreadLinks(prev: item.2, next: item.3)
|
||||
}
|
||||
let mainStatusCell = UICollectionView.CellRegistration<ConversationMainStatusCollectionViewCell, (String, CollapseState, Bool)> { [unowned self] cell, indexPath, item in
|
||||
let mainStatusCell = UICollectionView.CellRegistration<ConversationMainStatusCollectionViewCell, (String, CollapseState, Translation?, Bool)> { [unowned self] cell, indexPath, item in
|
||||
cell.delegate = self
|
||||
cell.translateStatus = { [unowned self] in
|
||||
self.translateMainStatus()
|
||||
}
|
||||
cell.showStatusAutomatically = self.showStatusesAutomatically
|
||||
cell.updateUI(statusID: item.0, state: item.1)
|
||||
cell.setShowThreadLinks(prev: item.2, next: false)
|
||||
cell.updateUI(statusID: item.0, state: item.1, translation: item.2)
|
||||
cell.setShowThreadLinks(prev: item.3, next: false)
|
||||
}
|
||||
let expandThreadCell = UICollectionView.CellRegistration<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in
|
||||
cell.updateUI(childThreads: item.0, inline: item.1)
|
||||
|
@ -104,7 +108,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
switch itemIdentifier {
|
||||
case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
|
||||
if id == self.mainStatusID {
|
||||
return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink))
|
||||
return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, self.mainStatusTranslation, prevLink))
|
||||
} else {
|
||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, prevLink, nextLink))
|
||||
}
|
||||
|
@ -260,6 +264,30 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
}
|
||||
}
|
||||
|
||||
private func translateMainStatus() {
|
||||
Task { @MainActor in
|
||||
let translation: Translation
|
||||
do {
|
||||
translation = try await mastodonController.run(Status.translate(mainStatusID)).0
|
||||
} catch {
|
||||
let config = ToastConfiguration(from: error, with: "Error Translating", in: self) { toast in
|
||||
toast.dismissToast(animated: true)
|
||||
self.translateMainStatus()
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
mainStatusTranslation = translation
|
||||
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.reconfigureItems(snapshot.itemIdentifiers(inSection: .mainStatus))
|
||||
await MainActor.run {
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ConversationCollectionViewController {
|
||||
|
|
|
@ -162,7 +162,7 @@ class InstanceSelectorTableViewController: UITableViewController {
|
|||
}
|
||||
|
||||
let client = Client(baseURL: url, session: .appDefault)
|
||||
let request = Client.getInstance()
|
||||
let request = Client.getInstanceV1()
|
||||
client.run(request) { (response) in
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
if snapshot.indexOfSection(.selected) != nil {
|
||||
|
@ -309,7 +309,7 @@ extension InstanceSelectorTableViewController {
|
|||
case recommendedInstances
|
||||
}
|
||||
enum Item: Equatable, Hashable {
|
||||
case selected(URL, Instance)
|
||||
case selected(URL, InstanceV1)
|
||||
case recommended(InstanceSelector.Instance)
|
||||
|
||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||
|
|
|
@ -118,12 +118,15 @@ struct AppearancePrefsView : View {
|
|||
Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) {
|
||||
Text("Always Show Status Visibility Icons")
|
||||
}
|
||||
Toggle(isOn: $preferences.hideActionsInTimeline) {
|
||||
Text("Hide Actions on Timeline")
|
||||
}
|
||||
Toggle(isOn: $preferences.showLinkPreviews) {
|
||||
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) {
|
||||
Text("Underline Links")
|
||||
}
|
||||
|
|
|
@ -61,13 +61,34 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
|
|||
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private let contentContainer = StatusContentContainer<StatusEditContentTextView, StatusEditPollView>(useTopSpacer: false).configure {
|
||||
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
|
||||
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
||||
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
||||
private lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
|
||||
contentTextView,
|
||||
cardView,
|
||||
attachmentsView,
|
||||
pollView,
|
||||
] as! [any StatusContentView], useTopSpacer: false).configure {
|
||||
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
}
|
||||
|
||||
private let contentTextView = StatusEditContentTextView().configure {
|
||||
$0.adjustsFontForContentSizeCategory = true
|
||||
$0.isScrollEnabled = false
|
||||
$0.backgroundColor = nil
|
||||
$0.isEditable = false
|
||||
$0.isSelectable = false
|
||||
$0.defaultFont = TimelineStatusCollectionViewCell.contentFont
|
||||
$0.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
||||
$0.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
||||
}
|
||||
|
||||
private let cardView = StatusCardView().configure {
|
||||
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
|
||||
}
|
||||
|
||||
private let attachmentsView = AttachmentsContainerView()
|
||||
|
||||
private let pollView = StatusEditPollView()
|
||||
|
||||
weak var delegate: StatusEditCollectionViewCellDelegate?
|
||||
private var mastodonController: MastodonController! { delegate?.apiController }
|
||||
|
||||
|
@ -108,7 +129,7 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
|
|||
}
|
||||
str += "collapsed"
|
||||
} else {
|
||||
str += AttributedString(contentContainer.contentTextView.attributedText)
|
||||
str += AttributedString(contentTextView.attributedText)
|
||||
|
||||
if edit.attachments.count > 0 {
|
||||
let includeDescriptions: Bool
|
||||
|
@ -170,13 +191,13 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
|
|||
|
||||
timestampLabel.text = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: edit.createdAt)
|
||||
|
||||
contentContainer.contentTextView.setTextFrom(edit: edit, index: index)
|
||||
contentContainer.contentTextView.navigationDelegate = delegate
|
||||
contentContainer.attachmentsView.delegate = self
|
||||
contentContainer.attachmentsView.updateUI(attachments: edit.attachments)
|
||||
contentContainer.pollView.isHidden = edit.poll == nil
|
||||
contentContainer.pollView.updateUI(poll: edit.poll, emojis: edit.emojis)
|
||||
contentContainer.cardView.isHidden = true
|
||||
contentTextView.setTextFrom(edit: edit, index: index)
|
||||
contentTextView.navigationDelegate = delegate
|
||||
attachmentsView.delegate = self
|
||||
attachmentsView.updateUI(attachments: edit.attachments)
|
||||
pollView.isHidden = edit.poll == nil
|
||||
pollView.updateUI(poll: edit.poll, emojis: edit.emojis)
|
||||
cardView.isHidden = true
|
||||
|
||||
contentWarningLabel.text = edit.spoilerText
|
||||
contentWarningLabel.isHidden = edit.spoilerText.isEmpty
|
||||
|
@ -219,9 +240,9 @@ extension StatusEditCollectionViewCell: AttachmentViewDelegate {
|
|||
guard let delegate else {
|
||||
return nil
|
||||
}
|
||||
let attachments = contentContainer.attachmentsView.attachments!
|
||||
let attachments = attachmentsView.attachments!
|
||||
let sourceViews = attachments.map {
|
||||
contentContainer.attachmentsView.getAttachmentView(for: $0)
|
||||
attachmentsView.getAttachmentView(for: $0)
|
||||
}
|
||||
let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index)
|
||||
return gallery
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class StatusEditPollView: UIStackView, StatusContentPollView {
|
||||
class StatusEditPollView: UIStackView, StatusContentView {
|
||||
|
||||
private var titleLabels: [EmojiLabel] = []
|
||||
|
||||
|
|
|
@ -164,6 +164,18 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
.store(in: &cancellables)
|
||||
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 {
|
||||
userActivityNeedsUpdate
|
||||
.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
|
||||
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
|
||||
cell.delegate = self
|
||||
cell.showAttachmentsInline = Preferences.shared.showAttachmentsInTimeline
|
||||
if case .home = timeline {
|
||||
cell.showFollowedHashtags = true
|
||||
} else {
|
||||
|
@ -407,7 +420,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
}
|
||||
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
|
||||
if hasStatusesToRestore {
|
||||
applyItemsToRestore(position: position)
|
||||
await applyItemsToRestore(position: position)
|
||||
loaded = true
|
||||
}
|
||||
case .mastodon:
|
||||
|
@ -430,7 +443,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
position.centerStatusID = centerStatusID
|
||||
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
|
||||
if hasStatusesToRestore {
|
||||
applyItemsToRestore(position: position)
|
||||
await applyItemsToRestore(position: position)
|
||||
}
|
||||
mastodonController.persistentContainer.viewContext.delete(position)
|
||||
}
|
||||
|
@ -445,6 +458,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
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
|
||||
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 {
|
||||
unloaded.append(id)
|
||||
}
|
||||
|
@ -509,7 +532,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
}
|
||||
|
||||
@MainActor
|
||||
private func applyItemsToRestore(position: TimelinePosition) {
|
||||
private func applyItemsToRestore(position: TimelinePosition) async {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.statuses])
|
||||
let statusIDs = position.statusIDs
|
||||
|
@ -522,14 +545,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
"statusIDs": position.statusIDs
|
||||
]
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||
if let centerStatusID,
|
||||
let index = statusIDs.firstIndex(of: centerStatusID) {
|
||||
self.scrollToItem(item: items[index])
|
||||
stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)")
|
||||
} else {
|
||||
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
|
||||
}
|
||||
await apply(snapshot, animatingDifferences: false)
|
||||
if let centerStatusID,
|
||||
let index = statusIDs.firstIndex(of: centerStatusID) {
|
||||
self.scrollToItem(item: items[index])
|
||||
stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)")
|
||||
} else {
|
||||
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -30,8 +30,8 @@ class AttachmentsContainerView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
var blurView: UIVisualEffectView?
|
||||
var hideButtonView: UIVisualEffectView?
|
||||
private var blurView: UIVisualEffectView?
|
||||
private var hideButtonView: UIVisualEffectView?
|
||||
var contentHidden: Bool! {
|
||||
didSet {
|
||||
guard let blurView = blurView,
|
||||
|
@ -42,6 +42,8 @@ class AttachmentsContainerView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
private var label: UILabel?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
commonInit()
|
||||
|
@ -67,21 +69,26 @@ class AttachmentsContainerView: UIView {
|
|||
|
||||
// MARK: - User Interaface
|
||||
|
||||
func updateUI(attachments: [Attachment]) {
|
||||
func updateUI(attachments: [Attachment], labelOnly: Bool = false) {
|
||||
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 {
|
||||
self.isHidden = attachments.isEmpty
|
||||
return
|
||||
}
|
||||
|
||||
self.attachments = attachments
|
||||
self.attachmentTokens = newTokens
|
||||
|
||||
attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
|
||||
attachmentViews.removeAllObjects()
|
||||
attachmentStacks.allObjects.forEach { $0.removeFromSuperview() }
|
||||
attachmentStacks.removeAllObjects()
|
||||
moreView?.removeFromSuperview()
|
||||
removeAttachmentViews()
|
||||
hideButtonView?.isHidden = false
|
||||
|
||||
var accessibilityElements = [Any]()
|
||||
|
||||
|
@ -284,6 +291,14 @@ class AttachmentsContainerView: UIView {
|
|||
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 {
|
||||
let attachmentView = AttachmentView(attachment: attachments[index], index: index)
|
||||
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
|
||||
|
||||
@objc func blurViewTapped() {
|
||||
|
|
|
@ -23,7 +23,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
weak var overrideMastodonController: MastodonController?
|
||||
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
|
||||
|
||||
private var htmlConverter = HTMLConverter()
|
||||
private(set) var htmlConverter = HTMLConverter()
|
||||
var defaultFont: UIFont {
|
||||
_read { yield htmlConverter.font }
|
||||
_modify { yield &htmlConverter.font }
|
||||
|
|
|
@ -16,7 +16,7 @@ class InstanceTableViewCell: UITableViewCell {
|
|||
@IBOutlet weak var adultLabel: UILabel!
|
||||
@IBOutlet weak var descriptionTextView: ContentTextView!
|
||||
|
||||
var instance: Instance?
|
||||
var instance: InstanceV1?
|
||||
var selectorInstance: InstanceSelector.Instance?
|
||||
|
||||
var thumbnailURL: URL?
|
||||
|
@ -53,7 +53,7 @@ class InstanceTableViewCell: UITableViewCell {
|
|||
updateThumbnail(url: instance.proxiedThumbnailURL)
|
||||
}
|
||||
|
||||
func updateUI(instance: Instance) {
|
||||
func updateUI(instance: InstanceV1) {
|
||||
self.instance = instance
|
||||
self.selectorInstance = nil
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class StatusPollView: UIView, StatusContentPollView {
|
||||
class StatusPollView: UIView, StatusContentView {
|
||||
|
||||
private static let formatter: DateComponentsFormatter = {
|
||||
let f = DateComponentsFormatter()
|
||||
|
|
|
@ -117,18 +117,40 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
|
||||
}
|
||||
|
||||
let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: true).configure {
|
||||
$0.contentTextView.defaultFont = ConversationMainStatusCollectionViewCell.contentFont
|
||||
$0.contentTextView.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont
|
||||
$0.contentTextView.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle
|
||||
$0.contentTextView.isSelectable = true
|
||||
$0.contentTextView.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber]
|
||||
if #available(iOS 16.0, *) {
|
||||
$0.contentTextView.dataDetectorTypes.formUnion([.money, .physicalValue])
|
||||
}
|
||||
private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
|
||||
contentTextView,
|
||||
cardView,
|
||||
attachmentsView,
|
||||
pollView,
|
||||
] as! [any StatusContentView], useTopSpacer: true).configure {
|
||||
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
}
|
||||
|
||||
let contentTextView = StatusContentTextView().configure {
|
||||
$0.adjustsFontForContentSizeCategory = true
|
||||
$0.isScrollEnabled = false
|
||||
$0.backgroundColor = nil
|
||||
$0.isEditable = false
|
||||
$0.isSelectable = true
|
||||
$0.defaultFont = ConversationMainStatusCollectionViewCell.contentFont
|
||||
$0.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont
|
||||
$0.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle
|
||||
$0.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber]
|
||||
if #available(iOS 16.0, *) {
|
||||
$0.dataDetectorTypes.formUnion([.money, .physicalValue])
|
||||
}
|
||||
}
|
||||
|
||||
private var translateButton: TranslateButton?
|
||||
|
||||
let cardView = StatusCardView().configure {
|
||||
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
|
||||
}
|
||||
|
||||
let attachmentsView = AttachmentsContainerView()
|
||||
|
||||
let pollView = StatusPollView()
|
||||
|
||||
private lazy var favoritesCountButton = UIButton().configure {
|
||||
$0.titleLabel!.adjustsFontForContentSizeCategory = true
|
||||
$0.addTarget(self, action: #selector(favoritesCountPressed), for: .touchUpInside)
|
||||
|
@ -289,6 +311,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
var mastodonController: MastodonController! { delegate?.apiController }
|
||||
weak var delegate: StatusCollectionViewCellDelegate?
|
||||
var showStatusAutomatically = false
|
||||
var translateStatus: (() -> Void)?
|
||||
|
||||
var statusID: String!
|
||||
var statusState: CollapseState!
|
||||
|
@ -318,9 +341,9 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
accountDetailAccessibilityElement,
|
||||
contentWarningLabel,
|
||||
collapseButton,
|
||||
contentContainer.contentTextView,
|
||||
contentContainer.attachmentsView,
|
||||
contentContainer.pollView,
|
||||
contentTextView,
|
||||
attachmentsView,
|
||||
pollView,
|
||||
favoritesCountButton,
|
||||
reblogsCountButton,
|
||||
timestampAndClientLabel,
|
||||
|
@ -348,7 +371,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
|
||||
// MARK: Configure UI
|
||||
|
||||
func updateUI(statusID: String, state: CollapseState) {
|
||||
func updateUI(statusID: String, state: CollapseState, translation: Translation?) {
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
fatalError()
|
||||
}
|
||||
|
@ -358,7 +381,17 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
self.statusID = statusID
|
||||
self.statusState = state
|
||||
|
||||
doUpdateUI(status: status)
|
||||
let attributedTranslatedContent: NSAttributedString? = translation.map {
|
||||
contentTextView.htmlConverter.convert($0.content)
|
||||
}
|
||||
doUpdateUI(status: status, precomputedContent: attributedTranslatedContent)
|
||||
|
||||
if !status.spoilerText.isEmpty,
|
||||
let translated = translation?.spoilerText {
|
||||
contentWarningLabel.text = translated
|
||||
contentWarningLabel.setEmojis(status.emojis, identifier: "\(statusID)_translated")
|
||||
}
|
||||
|
||||
accountDetailToContentWarningSpacer.isHidden = collapseButton.isHidden
|
||||
contentWarningToCollapseButtonSpacer.isHidden = collapseButton.isHidden
|
||||
|
||||
|
@ -383,6 +416,31 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
} else {
|
||||
editTimestampButton.isHidden = true
|
||||
}
|
||||
|
||||
if mastodonController.instanceFeatures.translation,
|
||||
let preferredLanguage = mastodonController.accountPreferences.serverDefaultLanguage,
|
||||
preferredLanguage != status.language {
|
||||
var config = UIButton.Configuration.tinted()
|
||||
config.image = UIImage(systemName: "globe")!
|
||||
if let translation {
|
||||
let lang = Locale.current.localizedString(forLanguageCode: translation.detectedSourceLanguage) ?? translation.detectedSourceLanguage
|
||||
config.title = "Translated from \(lang)"
|
||||
} else {
|
||||
config.title = "Translate"
|
||||
}
|
||||
|
||||
if let translateButton {
|
||||
translateButton.configuration = config
|
||||
} else {
|
||||
let button = TranslateButton(configuration: config)
|
||||
button.addTarget(self, action: #selector(translatePressed), for: .touchUpInside)
|
||||
translateButton = button
|
||||
contentContainer.insertArrangedSubview(button, after: contentTextView)
|
||||
}
|
||||
translateButton!.isEnabled = translation == nil
|
||||
} else if let translateButton {
|
||||
contentContainer.removeArrangedSubview(translateButton)
|
||||
}
|
||||
}
|
||||
|
||||
private func createObservers() {
|
||||
|
@ -487,6 +545,18 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
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 {
|
||||
|
@ -555,3 +625,13 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private class TranslateButton: UIButton, StatusContentView {
|
||||
var statusContentFillsHorizontally: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,11 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
|
|||
var usernameLabel: UILabel { get }
|
||||
var contentWarningLabel: EmojiLabel { get }
|
||||
var collapseButton: StatusCollapseButton { get }
|
||||
var contentContainer: StatusContentContainer<StatusContentTextView, StatusPollView> { get }
|
||||
var contentContainer: StatusContentContainer { get }
|
||||
var contentTextView: StatusContentTextView { get }
|
||||
var pollView: StatusPollView { get }
|
||||
var cardView: StatusCardView { get }
|
||||
var attachmentsView: AttachmentsContainerView { get }
|
||||
var replyButton: UIButton { get }
|
||||
var favoriteButton: ToggleableButton { get }
|
||||
var reblogButton: ToggleableButton { get }
|
||||
|
@ -44,6 +48,7 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
|
|||
var isGrayscale: Bool { get set }
|
||||
var cancellables: Set<AnyCancellable> { get set }
|
||||
|
||||
func updateAttachmentsUI(status: StatusMO)
|
||||
func updateUIForPreferences(status: StatusMO)
|
||||
func updateStatusState(status: StatusMO)
|
||||
func estimateContentHeight() -> CGFloat
|
||||
|
@ -89,21 +94,20 @@ extension StatusCollectionViewCell {
|
|||
|
||||
updateAccountUI(account: status.account)
|
||||
|
||||
contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent)
|
||||
contentContainer.contentTextView.navigationDelegate = delegate
|
||||
contentContainer.attachmentsView.delegate = self
|
||||
contentContainer.attachmentsView.updateUI(attachments: status.attachments)
|
||||
contentContainer.pollView.isHidden = status.poll == nil
|
||||
contentContainer.pollView.mastodonController = mastodonController
|
||||
contentContainer.pollView.delegate = delegate
|
||||
contentContainer.pollView.updateUI(status: status, poll: status.poll)
|
||||
contentTextView.setTextFrom(status: status, precomputed: precomputedContent)
|
||||
contentTextView.navigationDelegate = delegate
|
||||
self.updateAttachmentsUI(status: status)
|
||||
pollView.isHidden = status.poll == nil
|
||||
pollView.mastodonController = mastodonController
|
||||
pollView.delegate = delegate
|
||||
pollView.updateUI(status: status, poll: status.poll)
|
||||
if Preferences.shared.showLinkPreviews {
|
||||
contentContainer.cardView.updateUI(status: status)
|
||||
contentContainer.cardView.isHidden = status.card == nil
|
||||
contentContainer.cardView.navigationDelegate = delegate
|
||||
contentContainer.cardView.actionProvider = delegate
|
||||
cardView.updateUI(status: status)
|
||||
cardView.isHidden = status.card == nil
|
||||
cardView.navigationDelegate = delegate
|
||||
cardView.actionProvider = delegate
|
||||
} else {
|
||||
contentContainer.cardView.isHidden = true
|
||||
cardView.isHidden = true
|
||||
}
|
||||
|
||||
updateUIForPreferences(status: status)
|
||||
|
@ -167,6 +171,11 @@ extension StatusCollectionViewCell {
|
|||
return true
|
||||
}
|
||||
|
||||
func updateAttachmentsUI(status: StatusMO) {
|
||||
attachmentsView.delegate = self
|
||||
attachmentsView.updateUI(attachments: status.attachments)
|
||||
}
|
||||
|
||||
func updateAccountUI(account: AccountMO) {
|
||||
avatarImageView.update(for: account.avatar)
|
||||
displayNameLabel.updateForAccountDisplayName(account: account)
|
||||
|
@ -177,20 +186,20 @@ extension StatusCollectionViewCell {
|
|||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
|
||||
|
||||
let newCardHidden = !Preferences.shared.showLinkPreviews || status.card == nil
|
||||
if contentContainer.cardView.isHidden != newCardHidden {
|
||||
if cardView.isHidden != newCardHidden {
|
||||
delegate?.statusCellNeedsReconfigure(self, animated: false, completion: nil)
|
||||
}
|
||||
|
||||
switch Preferences.shared.attachmentBlurMode {
|
||||
case .never:
|
||||
contentContainer.attachmentsView.contentHidden = false
|
||||
attachmentsView.contentHidden = false
|
||||
case .always:
|
||||
contentContainer.attachmentsView.contentHidden = true
|
||||
attachmentsView.contentHidden = true
|
||||
default:
|
||||
if status.sensitive {
|
||||
contentContainer.attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
|
||||
attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
|
||||
} else {
|
||||
contentContainer.attachmentsView.contentHidden = false
|
||||
attachmentsView.contentHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -206,8 +215,8 @@ extension StatusCollectionViewCell {
|
|||
// only called when isGrayscale does not match the pref
|
||||
func updateGrayscaleableUI(status: StatusMO) {
|
||||
isGrayscale = Preferences.shared.grayscaleImages
|
||||
if contentContainer.contentTextView.hasEmojis {
|
||||
contentContainer.contentTextView.setEmojis(status.emojis, identifier: status.id)
|
||||
if contentTextView.hasEmojis {
|
||||
contentTextView.setEmojis(status.emojis, identifier: status.id)
|
||||
}
|
||||
displayNameLabel.updateForAccountDisplayName(account: status.account)
|
||||
}
|
||||
|
@ -230,10 +239,10 @@ extension StatusCollectionViewCell {
|
|||
// do not include reply action here, because the cell already contains a button for it
|
||||
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
|
||||
|
||||
contentContainer.pollView.isHidden = status.poll == nil
|
||||
contentContainer.pollView.mastodonController = mastodonController
|
||||
contentContainer.pollView.delegate = delegate
|
||||
contentContainer.pollView.updateUI(status: status, poll: status.poll)
|
||||
pollView.isHidden = status.poll == nil
|
||||
pollView.mastodonController = mastodonController
|
||||
pollView.delegate = delegate
|
||||
pollView.updateUI(status: status, poll: status.poll)
|
||||
}
|
||||
|
||||
func setShowThreadLinks(prev: Bool, next: Bool) {
|
||||
|
@ -322,7 +331,7 @@ extension StatusCollectionViewCell {
|
|||
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
|
||||
guard let delegate = delegate,
|
||||
let status = mastodonController.persistentContainer.status(for: statusID) else { return nil }
|
||||
let sourceViews = status.attachments.map(contentContainer.attachmentsView.getAttachmentView(for:))
|
||||
let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
|
||||
let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
|
||||
// TODO: PiP
|
||||
// gallery.avPlayerViewControllerDelegate = self
|
||||
|
|
|
@ -8,45 +8,11 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
protocol StatusContentPollView: UIView {
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat
|
||||
}
|
||||
|
||||
class StatusContentContainer<ContentView: ContentTextView, PollView: StatusContentPollView>: UIView {
|
||||
class StatusContentContainer: UIView {
|
||||
// TODO: this is a weird place for this
|
||||
static var cardViewHeight: CGFloat { 90 }
|
||||
|
||||
private 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 arrangedSubviews: [any StatusContentView]
|
||||
|
||||
private var isHiddenObservations: [NSKeyValueObservation] = []
|
||||
|
||||
|
@ -61,8 +27,12 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
|
|||
subviews.filter { !$0.isHidden }.map(\.bounds.height).reduce(0, +)
|
||||
}
|
||||
|
||||
init(useTopSpacer: Bool) {
|
||||
self.useTopSpacer = useTopSpacer
|
||||
init(arrangedSubviews: [any StatusContentView], useTopSpacer: Bool) {
|
||||
var arrangedSubviews = arrangedSubviews
|
||||
if useTopSpacer {
|
||||
arrangedSubviews.insert(TopSpacerView(), at: 0)
|
||||
}
|
||||
self.arrangedSubviews = arrangedSubviews
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
|
@ -70,10 +40,14 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
|
|||
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(subview)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
if subview.statusContentFillsHorizontally {
|
||||
NSLayoutConstraint.activate([
|
||||
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
} else {
|
||||
subview.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
|
||||
}
|
||||
}
|
||||
|
||||
// this constraint needs to have low priority so that during the collapse/expand animation, the content container is the view that shrinks/expands
|
||||
|
@ -82,17 +56,21 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
|
|||
|
||||
setNeedsUpdateConstraints()
|
||||
|
||||
isHiddenObservations = arrangedSubviews.map {
|
||||
$0.observe(\.isHidden) { [unowned self] _, _ in
|
||||
self.setNeedsUpdateConstraints()
|
||||
}
|
||||
}
|
||||
updateObservations()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func updateObservations() {
|
||||
isHiddenObservations = arrangedSubviews.map {
|
||||
$0.observeIsHidden { [unowned self] in
|
||||
self.setNeedsUpdateConstraints()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func updateConstraints() {
|
||||
let visibleSubviews = IndexSet(arrangedSubviews.indices.filter { !arrangedSubviews[$0].isHidden })
|
||||
if self.visibleSubviews != visibleSubviews {
|
||||
|
@ -129,6 +107,31 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
|
|||
super.updateConstraints()
|
||||
}
|
||||
|
||||
func insertArrangedSubview(_ view: any StatusContentView, after: any StatusContentView) {
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(view)
|
||||
if view.statusContentFillsHorizontally {
|
||||
NSLayoutConstraint.activate([
|
||||
view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
} else {
|
||||
view.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
|
||||
}
|
||||
|
||||
let index = arrangedSubviews.firstIndex(where: { $0 === after })!
|
||||
arrangedSubviews.insert(view, at: index + 1)
|
||||
setNeedsUpdateConstraints()
|
||||
updateObservations()
|
||||
}
|
||||
|
||||
func removeArrangedSubview(_ view: any StatusContentView) {
|
||||
view.removeFromSuperview()
|
||||
arrangedSubviews.removeAll(where: { $0 === view })
|
||||
setNeedsUpdateConstraints()
|
||||
updateObservations()
|
||||
}
|
||||
|
||||
func setCollapsed(_ collapsed: Bool) {
|
||||
guard collapsed != isCollapsed else {
|
||||
return
|
||||
|
@ -147,18 +150,67 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
|
|||
// just roughly inline with the content height
|
||||
func estimateVisibleSubviewHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||
var height: CGFloat = 0
|
||||
height += contentTextView.sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height
|
||||
if !cardView.isHidden {
|
||||
height += StatusContentContainer.cardViewHeight
|
||||
}
|
||||
if !attachmentsView.isHidden {
|
||||
height += effectiveWidth / attachmentsView.aspectRatio
|
||||
}
|
||||
if !pollView.isHidden {
|
||||
let pollHeight = pollView.estimateHeight(effectiveWidth: effectiveWidth)
|
||||
height += pollHeight
|
||||
for view in arrangedSubviews where !view.isHidden {
|
||||
height += view.estimateHeight(effectiveWidth: effectiveWidth)
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension StatusContentContainer {
|
||||
private class TopSpacerView: UIView, StatusContentView {
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
backgroundColor = .clear
|
||||
// other 4pt is provided by this view's own spacing
|
||||
heightAnchor.constraint(equalToConstant: 4).isActive = true
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||
4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIView {
|
||||
func observeIsHidden(_ f: @escaping () -> Void) -> NSKeyValueObservation {
|
||||
self.observe(\.isHidden) { _, _ in
|
||||
f()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol StatusContentView: UIView {
|
||||
var statusContentFillsHorizontally: Bool { get }
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat
|
||||
}
|
||||
|
||||
extension StatusContentView {
|
||||
var statusContentFillsHorizontally: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
extension ContentTextView: StatusContentView {
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||
sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusCardView: StatusContentView {
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||
StatusContentContainer.cardViewHeight
|
||||
}
|
||||
}
|
||||
|
||||
extension AttachmentsContainerView: StatusContentView {
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||
effectiveWidth / aspectRatio
|
||||
}
|
||||
}
|
||||
|
|
|
@ -186,25 +186,34 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
$0.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
}
|
||||
|
||||
let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: false).configure {
|
||||
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
|
||||
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
||||
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
||||
private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
|
||||
contentTextView,
|
||||
cardView,
|
||||
attachmentsView,
|
||||
pollView,
|
||||
] as! [any StatusContentView], useTopSpacer: false).configure {
|
||||
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
}
|
||||
private var contentTextView: StatusContentTextView {
|
||||
contentContainer.contentTextView
|
||||
|
||||
let contentTextView = StatusContentTextView().configure {
|
||||
$0.adjustsFontForContentSizeCategory = true
|
||||
$0.isScrollEnabled = false
|
||||
$0.backgroundColor = nil
|
||||
$0.isEditable = false
|
||||
$0.isSelectable = false
|
||||
$0.defaultFont = TimelineStatusCollectionViewCell.contentFont
|
||||
$0.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
||||
$0.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
||||
}
|
||||
private var cardView: StatusCardView {
|
||||
contentContainer.cardView
|
||||
}
|
||||
private var attachmentsView: AttachmentsContainerView {
|
||||
contentContainer.attachmentsView
|
||||
}
|
||||
private var pollView: StatusPollView {
|
||||
contentContainer.pollView
|
||||
|
||||
let cardView = StatusCardView().configure {
|
||||
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
|
||||
}
|
||||
|
||||
let attachmentsView = AttachmentsContainerView()
|
||||
|
||||
let pollView = StatusPollView()
|
||||
|
||||
private var placeholderReplyButtonLeadingConstraint: NSLayoutConstraint!
|
||||
private lazy var actionsContainer = UIView().configure {
|
||||
replyButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -297,6 +306,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
var showReplyIndicator = true
|
||||
var showPinned: Bool = false
|
||||
var showFollowedHashtags: Bool = false
|
||||
var showAttachmentsInline = true
|
||||
|
||||
// alas these need to be internal so they're accessible from the protocol extensions
|
||||
var statusID: String!
|
||||
|
@ -580,9 +590,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
if let reblogStatus {
|
||||
hideTimelineReason = false
|
||||
updateRebloggerLabel(reblogger: reblogStatus.account)
|
||||
}
|
||||
|
||||
if showFollowedHashtags {
|
||||
} else if showFollowedHashtags {
|
||||
let hashtags = mastodonController.followedHashtags.filter({ followed in status.hashtags.contains(where: { followed.name == $0.name }) })
|
||||
if !hashtags.isEmpty {
|
||||
hideTimelineReason = false
|
||||
|
@ -645,6 +653,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
baseUpdateStatusState(status: status)
|
||||
}
|
||||
|
||||
func updateAttachmentsUI(status: StatusMO) {
|
||||
attachmentsView.delegate = self
|
||||
attachmentsView.updateUI(attachments: status.attachments, labelOnly: !showAttachmentsInline)
|
||||
}
|
||||
|
||||
func estimateContentHeight() -> CGFloat {
|
||||
let width = bounds.width /* leading spacing: */ - 16 /* avatar: */ - 50 /* spacing: 8 */ - 8 /* trailing spacing: */ - 16
|
||||
return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width)
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
// https://help.apple.com/xcode/#/dev745c5c974
|
||||
|
||||
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_BUILD_SUFFIX_Debug=-dev
|
||||
|
|
Loading…
Reference in New Issue