From 4af56e48bf9eb8163ea33e1749a7f90bf660bc84 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 24 Oct 2023 14:56:39 -0400 Subject: [PATCH 01/29] Clean up TimelineLikeCollectionViewController.apply(_:animatingDifferences:) --- .../Utilities/TimelineLikeCollectionViewController.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift b/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift index 6cf2ae5b9d..697e6b35a6 100644 --- a/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift +++ b/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift @@ -211,11 +211,9 @@ extension TimelineLikeCollectionViewController { extension TimelineLikeCollectionViewController { // apply(_:animatingDifferences:) is marked as nonisolated, so just awaiting it doesn't dispatch to the main thread, unlike other async @MainActor methods // but we always want to update the data source on the main thread for consistency, so this method does that + @MainActor func apply(_ snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool) async { - let task = Task { @MainActor in - self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences) - } - await task.value + await self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences) } @MainActor From f7a9075b777dd9ced4275ffa0aeed429832de2e9 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 24 Oct 2023 15:02:13 -0400 Subject: [PATCH 02/29] Fix timeline jump button having background when button shapes accessibility setting is on --- Tusker/Screens/Timeline/TimelineJumpButton.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tusker/Screens/Timeline/TimelineJumpButton.swift b/Tusker/Screens/Timeline/TimelineJumpButton.swift index 8bda27f33f..2f1cf09038 100644 --- a/Tusker/Screens/Timeline/TimelineJumpButton.swift +++ b/Tusker/Screens/Timeline/TimelineJumpButton.swift @@ -20,6 +20,8 @@ class TimelineJumpButton: UIView { var config = UIButton.Configuration.plain() config.image = UIImage(systemName: "arrow.up") config.contentInsets = .zero + // We don't want a background for this button, even when accessibility button shapes are enabled, because it's in the navbar. + config.background.backgroundColor = .clear return UIButton(configuration: config) }() From 74820e8922600eee84d7cd85a24935b8316e5831 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 24 Oct 2023 15:11:14 -0400 Subject: [PATCH 03/29] Underline links when button shapes accessibility setting is on --- Tusker/Views/ContentTextView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index 0a54412167..a2c61180f3 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -78,10 +78,21 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { linkTextAttributes = [ .foregroundColor: UIColor.tintColor ] + updateLinkUnderlineStyle() // the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:))) addGestureRecognizer(recognizer) + + NotificationCenter.default.addObserver(self, selector: #selector(updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil) + } + + @objc private func updateLinkUnderlineStyle() { + if UIAccessibility.buttonShapesEnabled { + linkTextAttributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + } else { + linkTextAttributes.removeValue(forKey: .underlineStyle) + } } // MARK: - Emojis From bde21fbc6cae4d921f96b5af7c79ec92ce8410ea Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 24 Oct 2023 15:28:44 -0400 Subject: [PATCH 04/29] Fix crash due to prematurely pruned statuses being fetched If the app hasn't launched in long enough, we may be displaying old statuses as a result of state restoration. If the user leaves the app, those statuses can't get pruned, because the user may return. We need to make sure the lastFetchedAt date is current, since awakeFromFetch won't be called until the object is faulted in (which wasn't happening immediately during state restoration). --- Tusker/CoreData/AccountMO.swift | 7 ++++++- Tusker/CoreData/StatusMO.swift | 7 ++++++- Tusker/Screens/Timeline/TimelineViewController.swift | 10 +++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Tusker/CoreData/AccountMO.swift b/Tusker/CoreData/AccountMO.swift index df237ecc58..a3df7ba137 100644 --- a/Tusker/CoreData/AccountMO.swift +++ b/Tusker/CoreData/AccountMO.swift @@ -59,9 +59,14 @@ public final class AccountMO: NSManagedObject, AccountProtocol { super.awakeFromFetch() managedObjectContext?.perform { - self.lastFetchedAt = Date() + self.touch() } } + + /// Update the `lastFetchedAt` date so this object isn't pruned early. + func touch() { + lastFetchedAt = Date() + } } diff --git a/Tusker/CoreData/StatusMO.swift b/Tusker/CoreData/StatusMO.swift index 774f87264d..e37f64e59b 100644 --- a/Tusker/CoreData/StatusMO.swift +++ b/Tusker/CoreData/StatusMO.swift @@ -89,10 +89,15 @@ public final class StatusMO: NSManagedObject, StatusProtocol { super.awakeFromFetch() managedObjectContext?.perform { - self.lastFetchedAt = Date() + self.touch() } } + /// Update the `lastFetchedAt` date so this object isn't pruned early. + func touch() { + lastFetchedAt = Date() + } + } extension StatusMO { diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index f27349f2f4..e8096cf475 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -440,7 +440,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro private func loadStatusesToRestore(position: TimelinePosition) async -> Bool { let originalPositionStatusIDs = position.statusIDs - let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil }) + var unloaded = [String]() + for id in position.statusIDs { + 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() + } else { + unloaded.append(id) + } + } guard !unloaded.isEmpty else { return true } From 157c8629a93069e3ccfddc0987fb3b8948565ac1 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 24 Oct 2023 16:02:03 -0400 Subject: [PATCH 05/29] Add underline links preference Closes #397 --- .../TuskerPreferences/Preferences.swift | 4 ++++ .../Preferences/AppearancePrefsView.swift | 3 +++ Tusker/Views/ContentTextView.swift | 18 +++++++++++++++--- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift index 5328bdb9f3..b5fcce2ef8 100644 --- a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift @@ -61,6 +61,7 @@ public final class Preferences: Codable, ObservableObject { self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions 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.defaultPostVisibility = try container.decode(Visibility.self, forKey: .defaultPostVisibility) self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost @@ -121,6 +122,7 @@ public final class Preferences: Codable, ObservableObject { try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions) try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions) try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode) + try container.encode(underlineTextLinks, forKey: .underlineTextLinks) try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility) try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility) @@ -175,6 +177,7 @@ public final class Preferences: Codable, ObservableObject { @Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share] private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen @Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode + @Published public var underlineTextLinks = false // MARK: Composing @Published public var defaultPostVisibility = Visibility.public @@ -245,6 +248,7 @@ public final class Preferences: Codable, ObservableObject { case leadingStatusSwipeActions case trailingStatusSwipeActions case widescreenNavigationMode + case underlineTextLinks case defaultPostVisibility case defaultReplyVisibility diff --git a/Tusker/Screens/Preferences/AppearancePrefsView.swift b/Tusker/Screens/Preferences/AppearancePrefsView.swift index 1f441b16c2..1e50daa517 100644 --- a/Tusker/Screens/Preferences/AppearancePrefsView.swift +++ b/Tusker/Screens/Preferences/AppearancePrefsView.swift @@ -124,6 +124,9 @@ struct AppearancePrefsView : View { Toggle(isOn: $preferences.showLinkPreviews) { Text("Show Link Previews") } + Toggle(isOn: $preferences.underlineTextLinks) { + Text("Underline Links") + } NavigationLink("Leading Swipe Actions") { SwipeActionsPrefsView(selection: $preferences.leadingStatusSwipeActions) .edgesIgnoringSafeArea(.all) diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index a2c61180f3..75b93de9bf 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -12,6 +12,7 @@ import Pachyderm import SafariServices import WebURL import WebURLFoundationExtras +import Combine private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) private let dataDetectorsScheme = "x-apple-data-detectors" @@ -52,6 +53,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { // The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing. private weak var currentTargetedPreview: UITargetedPreview? + private var underlineTextLinksCancellable: AnyCancellable? + override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) commonInit() @@ -84,11 +87,20 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:))) addGestureRecognizer(recognizer) - NotificationCenter.default.addObserver(self, selector: #selector(updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil) + underlineTextLinksCancellable = + Preferences.shared.$underlineTextLinks + .sink { [unowned self] in + self.updateLinkUnderlineStyle(preference: $0) + } } - @objc private func updateLinkUnderlineStyle() { - if UIAccessibility.buttonShapesEnabled { + @objc private func _updateLinkUnderlineStyle() { + updateLinkUnderlineStyle() + } + + private func updateLinkUnderlineStyle(preference: Bool = Preferences.shared.underlineTextLinks) { + if UIAccessibility.buttonShapesEnabled || preference { linkTextAttributes[.underlineStyle] = NSUnderlineStyle.single.rawValue } else { linkTextAttributes.removeValue(forKey: .underlineStyle) From dfc8234908e52236f1602d74fb12188c92a18994 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 26 Oct 2023 17:30:31 -0500 Subject: [PATCH 06/29] Attribute authenticated API requests to the user Closes #134 --- Packages/Pachyderm/Package.swift | 2 +- Packages/Pachyderm/Sources/Pachyderm/Client.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/Pachyderm/Package.swift b/Packages/Pachyderm/Package.swift index 7f1a9a9199..f77100f147 100644 --- a/Packages/Pachyderm/Package.swift +++ b/Packages/Pachyderm/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "Pachyderm", platforms: [ - .iOS(.v14), + .iOS(.v15), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index c5a954077d..88c9bf915a 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -122,6 +122,8 @@ public class Client { } if let accessToken = accessToken { urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + // We consider authenticated requests to be user-initiated. + urlRequest.attribution = .user } return urlRequest } From 6e5e0c3bb55c320ac0ba5e310b0365be2bfba91d Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 27 Oct 2023 14:58:15 -0500 Subject: [PATCH 07/29] Use server preferences for default visibility and language Closes #282 --- .../CoreData/DraftsPersistentContainer.swift | 2 + .../InstanceFeatures/InstanceFeatures.swift | 4 + .../Pachyderm/Sources/Pachyderm/Client.swift | 18 ++++ .../Sources/Pachyderm/Model/Preferences.swift | 34 +++++++ .../TuskerPreferences/PostVisibility.swift | 97 +++++++++++++++++++ .../TuskerPreferences/Preferences.swift | 44 ++------- ShareExtension/ShareViewController.swift | 16 ++- Tusker/API/MastodonController.swift | 21 +++- Tusker/CoreData/AccountPreferences.swift | 11 +++ .../Tusker.xcdatamodel/contents | 6 +- .../Preferences/ComposingPrefsView.swift | 8 +- 11 files changed, 212 insertions(+), 49 deletions(-) create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift index c87ad5bf61..a6c1342d2a 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift @@ -81,6 +81,7 @@ public class DraftsPersistentContainer: NSPersistentContainer { contentWarning: String, inReplyToID: String?, visibility: Visibility, + language: String?, localOnly: Bool ) -> Draft { let draft = Draft(context: viewContext) @@ -92,6 +93,7 @@ public class DraftsPersistentContainer: NSPersistentContainer { draft.contentWarningEnabled = !contentWarning.isEmpty draft.inReplyToID = inReplyToID draft.visibility = visibility + draft.language = language draft.localOnly = localOnly save() return draft diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index 0c2187afb8..9219f9aa11 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -171,6 +171,10 @@ public class InstanceFeatures: ObservableObject { hasMastodonVersion(4, 2, 0) } + public var hasServerPreferences: Bool { + hasMastodonVersion(2, 8, 0) + } + public init() { } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index 88c9bf915a..f4bed14c50 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -105,6 +105,20 @@ public class Client { return task } + @discardableResult + public func run(_ request: Request) async throws -> (Result, Pagination?) { + return try await withCheckedThrowingContinuation { continuation in + run(request) { response in + switch response { + case .failure(let error): + continuation.resume(throwing: error) + case .success(let result, let pagination): + continuation.resume(returning: (result, pagination)) + } + } + } + } + func createURLRequest(request: Request) -> URLRequest? { guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } components.path = request.endpoint.path @@ -225,6 +239,10 @@ public class Client { return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis") } + public static func getPreferences() -> Request { + return Request(method: .get, path: "/api/v1/preferences") + } + // MARK: - Accounts public static func getAccount(id: String) -> Request { return Request(method: .get, path: "/api/v1/accounts/\(id)") diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift new file mode 100644 index 0000000000..14e1e87d0a --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift @@ -0,0 +1,34 @@ +// +// Preferences.swift +// Pachyderm +// +// Created by Shadowfacts on 10/26/23. +// + +import Foundation + +public struct Preferences: Codable, Sendable { + public let postingDefaultVisibility: Visibility + public let postingDefaultSensitive: Bool + public let postingDefaultLanguage: String + public let readingExpandMedia: ExpandMedia + public let readingExpandSpoilers: Bool + public let readingAutoplayGifs: Bool + + enum CodingKeys: String, CodingKey { + case postingDefaultVisibility = "posting:default:visibility" + case postingDefaultSensitive = "posting:default:sensitive" + case postingDefaultLanguage = "posting:default:language" + case readingExpandMedia = "reading:expand:media" + case readingExpandSpoilers = "reading:expand:spoilers" + case readingAutoplayGifs = "reading:autoplay:gifs" + } +} + +extension Preferences { + public enum ExpandMedia: String, Codable, Sendable { + case `default` + case always = "show_all" + case never = "hide_all" + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift new file mode 100644 index 0000000000..c0a7eb8ddd --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift @@ -0,0 +1,97 @@ +// +// PostVisibility.swift +// TuskerPreferences +// +// Created by Shadowfacts on 10/26/23. +// + +import Foundation +import Pachyderm + +public enum PostVisibility: Codable, Hashable, CaseIterable { + case serverDefault + case visibility(Visibility) + + public static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) } + + public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility { + switch self { + case .serverDefault: + // If the server doesn't have a default visibility preference, we fallback to public. + // This isn't ideal, but I don't want to add a separate preference for "Default Post Visibility Fallback" :/ + serverDefault ?? .public + case .visibility(let vis): + vis + } + } + + public func resolved(withServerDefault serverDefault: () async -> Visibility?) async -> Visibility { + switch self { + case .serverDefault: + await serverDefault() ?? .public + case .visibility(let vis): + vis + } + } + + public var displayName: String { + switch self { + case .serverDefault: + return "Account Default" + case .visibility(let vis): + return vis.displayName + } + } + + public var imageName: String? { + switch self { + case .serverDefault: + return nil + case .visibility(let vis): + return vis.imageName + } + } +} + +public enum ReplyVisibility: Codable, Hashable, CaseIterable { + case sameAsPost + case visibility(Visibility) + + public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) } + + public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility { + switch self { + case .sameAsPost: + Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverDefault) + case .visibility(let vis): + vis + } + } + + public func resolved(withServerDefault serverDefault: () async -> Visibility?) async -> Visibility { + switch self { + case .sameAsPost: + await Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverDefault) + case .visibility(let vis): + vis + } + } + + public var displayName: String { + switch self { + case .sameAsPost: + return "Same as Default" + case .visibility(let vis): + return vis.displayName + } + } + + public var imageName: String? { + switch self { + case .sameAsPost: + return nil + case .visibility(let vis): + return vis.imageName + } + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift index b5fcce2ef8..6a099dd1b9 100644 --- a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift @@ -63,7 +63,11 @@ public final class Preferences: Codable, ObservableObject { self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false - self.defaultPostVisibility = try container.decode(Visibility.self, forKey: .defaultPostVisibility) + if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) { + self.defaultPostVisibility = .visibility(existing) + } else { + self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility) + } self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions) self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode) @@ -180,7 +184,7 @@ public final class Preferences: Codable, ObservableObject { @Published public var underlineTextLinks = false // MARK: Composing - @Published public var defaultPostVisibility = Visibility.public + @Published public var defaultPostVisibility = PostVisibility.serverDefault @Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost @Published public var requireAttachmentDescriptions = false @Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs @@ -292,42 +296,6 @@ public final class Preferences: Codable, ObservableObject { } -extension Preferences { - public enum ReplyVisibility: Codable, Hashable, CaseIterable { - case sameAsPost - case visibility(Visibility) - - public static var allCases: [Preferences.ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) } - - public var resolved: Visibility { - switch self { - case .sameAsPost: - return Preferences.shared.defaultPostVisibility - case .visibility(let vis): - return vis - } - } - - public var displayName: String { - switch self { - case .sameAsPost: - return "Same as Default" - case .visibility(let vis): - return vis.displayName - } - } - - public var imageName: String? { - switch self { - case .sameAsPost: - return nil - case .visibility(let vis): - return vis.imageName - } - } - } -} - extension Preferences { public enum AttachmentBlurMode: Codable, Hashable, CaseIterable { case useStatusSetting diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index 022fdefde8..f79f06135e 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -12,6 +12,7 @@ import ComposeUI import UniformTypeIdentifiers import TuskerPreferences import Combine +import Pachyderm class ShareViewController: UIViewController { @@ -50,21 +51,26 @@ class ShareViewController: UIViewController { } private func createDraft(account: UserAccountInfo) async -> Draft { - let (text, attachments) = await 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( accountID: account.id, - text: text, + text: await text, contentWarning: "", inReplyToID: nil, - visibility: Preferences.shared.defaultPostVisibility, + visibility: visibility, + language: serverPrefs?.postingDefaultLanguage, localOnly: false ) - for attachment in attachments { + for attachment in await attachments { DraftsPersistentContainer.shared.viewContext.insert(attachment) } - draft.draftAttachments = attachments + draft.draftAttachments = await attachments return draft } diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index a2c8174c3a..66f9c4a8a2 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -193,6 +193,8 @@ class MastodonController: ObservableObject { @MainActor func initialize() { + precondition(!transient, "Cannot initialize transient MastodonController") + // we want this to happen immediately, and synchronously so that the filters (which don't change that often) // are available when Filterers are constructed loadCachedFilters() @@ -217,6 +219,7 @@ class MastodonController: ObservableObject { loadLists() _ = await loadFilters() + await loadServerPreferences() } catch { Logging.general.error("MastodonController initialization failed: \(String(describing: error))") } @@ -358,6 +361,17 @@ class MastodonController: ObservableObject { } } + // MainActor because the accountPreferences instance is bound to the view context + @MainActor + private func loadServerPreferences() async { + guard instanceFeatures.hasServerPreferences, + let (prefs, _) = try? await run(Client.getPreferences()) else { + return + } + accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage + accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility + } + private func updateActiveInstance(from instance: Instance) { persistentContainer.performBackgroundTask { context in if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first { @@ -519,7 +533,11 @@ class MastodonController: ObservableObject { func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft { var acctsToMention = [String]() - var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility + var visibility = if inReplyToID != nil { + Preferences.shared.defaultReplyVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility) + } else { + Preferences.shared.defaultPostVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility) + } var localOnly = false var contentWarning = "" @@ -559,6 +577,7 @@ class MastodonController: ObservableObject { contentWarning: contentWarning, inReplyToID: inReplyToID, visibility: visibility, + language: accountPreferences!.serverDefaultLanguage, localOnly: localOnly ) } diff --git a/Tusker/CoreData/AccountPreferences.swift b/Tusker/CoreData/AccountPreferences.swift index c8c0b3885c..98151be754 100644 --- a/Tusker/CoreData/AccountPreferences.swift +++ b/Tusker/CoreData/AccountPreferences.swift @@ -24,10 +24,21 @@ public final class AccountPreferences: NSManagedObject { @NSManaged public var accountID: String @NSManaged var createdAt: Date @NSManaged var pinnedTimelinesData: Data? + @NSManaged var serverDefaultLanguage: String? + @NSManaged private var serverDefaultVisibilityString: String? @LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines) var pinnedTimelines: [PinnedTimeline] + var serverDefaultVisibility: Visibility? { + get { + serverDefaultVisibilityString.flatMap(Visibility.init(rawValue:)) + } + set { + serverDefaultVisibilityString = newValue?.rawValue + } + } + static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences { let prefs = AccountPreferences(context: context) prefs.accountID = account.id diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index 092b622f27..64963f9fed 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -33,6 +33,8 @@ + + @@ -155,4 +157,4 @@ - \ No newline at end of file + diff --git a/Tusker/Screens/Preferences/ComposingPrefsView.swift b/Tusker/Screens/Preferences/ComposingPrefsView.swift index 49ec21a5ac..0c15152b4e 100644 --- a/Tusker/Screens/Preferences/ComposingPrefsView.swift +++ b/Tusker/Screens/Preferences/ComposingPrefsView.swift @@ -28,9 +28,11 @@ struct ComposingPrefsView: View { var visibilitySection: some View { Section { Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) { - ForEach(Visibility.allCases, id: \.self) { visibility in + ForEach(PostVisibility.allCases, id: \.self) { visibility in HStack { - Image(systemName: visibility.imageName) + if let imageName = visibility.imageName { + Image(systemName: imageName) + } Text(visibility.displayName) } .tag(visibility) @@ -38,7 +40,7 @@ struct ComposingPrefsView: View { // navbar title on the ForEach is currently incorrectly applied when the picker is not expanded, see FB6838291 } Picker(selection: $preferences.defaultReplyVisibility, label: Text("Reply Visibility")) { - ForEach(Preferences.ReplyVisibility.allCases, id: \.self) { visibility in + ForEach(ReplyVisibility.allCases, id: \.self) { visibility in HStack { if let imageName = visibility.imageName { Image(systemName: imageName) From eb496243c78379412072f95a2fea89ec792f1f52 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 27 Oct 2023 15:12:48 -0500 Subject: [PATCH 08/29] Use server preference for local-only on Hometown Closes #281 --- Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift | 3 +++ ShareExtension/ShareViewController.swift | 2 +- Tusker/API/MastodonController.swift | 3 ++- Tusker/CoreData/AccountPreferences.swift | 1 + .../CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents | 3 ++- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift index 14e1e87d0a..c93701e8fa 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift @@ -11,6 +11,8 @@ public struct Preferences: Codable, Sendable { public let postingDefaultVisibility: Visibility public let postingDefaultSensitive: Bool public let postingDefaultLanguage: String + // Whether posts federate or not (local-only) on Hometown + public let postingDefaultFederation: Bool? public let readingExpandMedia: ExpandMedia public let readingExpandSpoilers: Bool public let readingAutoplayGifs: Bool @@ -19,6 +21,7 @@ public struct Preferences: Codable, Sendable { case postingDefaultVisibility = "posting:default:visibility" case postingDefaultSensitive = "posting:default:sensitive" case postingDefaultLanguage = "posting:default:language" + case postingDefaultFederation = "posting:default:federation" case readingExpandMedia = "reading:expand:media" case readingExpandSpoilers = "reading:expand:spoilers" case readingAutoplayGifs = "reading:autoplay:gifs" diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index f79f06135e..8deb97788b 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -64,7 +64,7 @@ class ShareViewController: UIViewController { inReplyToID: nil, visibility: visibility, language: serverPrefs?.postingDefaultLanguage, - localOnly: false + localOnly: !(serverPrefs?.postingDefaultFederation ?? true) ) for attachment in await attachments { diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 66f9c4a8a2..e3bd73c8b2 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -370,6 +370,7 @@ class MastodonController: ObservableObject { } accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility + accountPreferences!.serverDefaultFederation = prefs.postingDefaultFederation ?? true } private func updateActiveInstance(from instance: Instance) { @@ -538,7 +539,7 @@ class MastodonController: ObservableObject { } else { Preferences.shared.defaultPostVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility) } - var localOnly = false + var localOnly = instanceFeatures.localOnlyPosts && !accountPreferences!.serverDefaultFederation var contentWarning = "" if let inReplyToID = inReplyToID, diff --git a/Tusker/CoreData/AccountPreferences.swift b/Tusker/CoreData/AccountPreferences.swift index 98151be754..306927c04e 100644 --- a/Tusker/CoreData/AccountPreferences.swift +++ b/Tusker/CoreData/AccountPreferences.swift @@ -24,6 +24,7 @@ public final class AccountPreferences: NSManagedObject { @NSManaged public var accountID: String @NSManaged var createdAt: Date @NSManaged var pinnedTimelinesData: Data? + @NSManaged var serverDefaultFederation: Bool @NSManaged var serverDefaultLanguage: String? @NSManaged private var serverDefaultVisibilityString: String? diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index 64963f9fed..efa6cd2adf 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -33,6 +33,7 @@ + @@ -157,4 +158,4 @@ - + \ No newline at end of file From a46eaafbcf537c74cc2c87be084bc9d395adf3f9 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 27 Oct 2023 17:00:53 -0500 Subject: [PATCH 09/29] Add reply policy and exclusive fields to lists --- .../Sources/Pachyderm/Model/List.swift | 25 ++++++++++++++--- .../Model/Protocols/ListProtocol.swift | 2 ++ Tusker/API/MastodonController.swift | 8 ++---- Tusker/API/RenameListService.swift | 2 +- Tusker/CoreData/ListMO.swift | 27 +++++++++++++++++++ .../Tusker.xcdatamodel/contents | 2 ++ Tusker/Scenes/AuxiliarySceneDelegate.swift | 2 +- 7 files changed, 57 insertions(+), 11 deletions(-) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/List.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/List.swift index 491035ab41..e8dfdde95e 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/List.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/List.swift @@ -11,14 +11,18 @@ import Foundation public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable { public let id: String public let title: String + public let replyPolicy: ReplyPolicy? + public let exclusive: Bool? public var timeline: Timeline { return .list(id: id) } - public init(id: String, title: String) { + public init(id: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) { self.id = id self.title = title + self.replyPolicy = replyPolicy + self.exclusive = exclusive } public static func ==(lhs: List, rhs: List) -> Bool { @@ -36,8 +40,15 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable { return request } - public static func update(_ listID: String, title: String) -> Request { - return Request(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(["title" => title])) + public static func update(_ listID: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) -> Request { + var params = ["title" => title] + if let replyPolicy { + params.append("replies_policy" => replyPolicy.rawValue) + } + if let exclusive { + params.append("exclusive" => exclusive) + } + return Request(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(params)) } public static func delete(_ listID: String) -> Request { @@ -59,5 +70,13 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable { private enum CodingKeys: String, CodingKey { case id case title + case replyPolicy = "replies_policy" + case exclusive + } +} + +extension List { + public enum ReplyPolicy: String, Codable, Hashable, CaseIterable, Sendable { + case followed, list, none } } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/ListProtocol.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/ListProtocol.swift index d9d5291d41..efe3a76f01 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/ListProtocol.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/ListProtocol.swift @@ -10,4 +10,6 @@ import Foundation public protocol ListProtocol { var id: String { get } var title: String { get } + var replyPolicy: List.ReplyPolicy? { get } + var exclusive: Bool? { get } } diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index e3bd73c8b2..5cb73fa120 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -449,16 +449,12 @@ class MastodonController: ObservableObject { guard let lists = try? persistentContainer.viewContext.fetch(req) else { return [] } - return lists.map { - List(id: $0.id, title: $0.title) - }.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) + return lists.map(\.apiList).sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) } func getCachedList(id: String) -> List? { let req = ListMO.fetchRequest(id: id) - return (try? persistentContainer.viewContext.fetch(req).first).flatMap { - List(id: $0.id, title: $0.title) - } + return (try? persistentContainer.viewContext.fetch(req).first).map(\.apiList) } @MainActor diff --git a/Tusker/API/RenameListService.swift b/Tusker/API/RenameListService.swift index 9f3a978959..45931cebfd 100644 --- a/Tusker/API/RenameListService.swift +++ b/Tusker/API/RenameListService.swift @@ -47,7 +47,7 @@ class RenameListService { private func updateList(with title: String) async { do { - let req = List.update(list.id, title: title) + let req = List.update(list.id, title: title, replyPolicy: nil, exclusive: nil) let (list, _) = try await mastodonController.run(req) mastodonController.renamedList(list) } catch { diff --git a/Tusker/CoreData/ListMO.swift b/Tusker/CoreData/ListMO.swift index b9cc59fb4a..d22a394245 100644 --- a/Tusker/CoreData/ListMO.swift +++ b/Tusker/CoreData/ListMO.swift @@ -25,6 +25,22 @@ public final class ListMO: NSManagedObject, ListProtocol { @NSManaged public var id: String @NSManaged public var title: String + @NSManaged private var replyPolicyString: String? + @NSManaged private var exclusiveInternal: Bool + + public var replyPolicy: List.ReplyPolicy? { + get { + replyPolicyString.flatMap(List.ReplyPolicy.init(rawValue:)) + } + set { + replyPolicyString = newValue?.rawValue + } + } + + public var exclusive: Bool? { + get { exclusiveInternal } + set { exclusiveInternal = newValue ?? false } + } } @@ -37,5 +53,16 @@ extension ListMO { func updateFrom(apiList list: List) { self.id = list.id self.title = list.title + self.replyPolicy = list.replyPolicy + self.exclusive = list.exclusive + } + + var apiList: List { + List( + id: id, + title: title, + replyPolicy: replyPolicy, + exclusive: exclusive + ) } } diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index efa6cd2adf..ba52345a5a 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -62,7 +62,9 @@ + + diff --git a/Tusker/Scenes/AuxiliarySceneDelegate.swift b/Tusker/Scenes/AuxiliarySceneDelegate.swift index 81b0943114..284516a7a6 100644 --- a/Tusker/Scenes/AuxiliarySceneDelegate.swift +++ b/Tusker/Scenes/AuxiliarySceneDelegate.swift @@ -112,7 +112,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel case .list(id: let id): let req = ListMO.fetchRequest(id: id) if let list = try? mastodonController.persistentContainer.viewContext.fetch(req).first { - return ListTimelineViewController(for: List(id: id, title: list.title), mastodonController: mastodonController) + return ListTimelineViewController(for: list.apiList, mastodonController: mastodonController) } else { return TimelineViewController(for: timeline, mastodonController: mastodonController) } From 44021d3ad2727911d31453dfee674f04d6c142af Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 27 Oct 2023 17:13:49 -0500 Subject: [PATCH 10/29] Convert edit list screen to collection view --- .../EditListAccountsViewController.swift | 210 +++++++++++++----- 1 file changed, 152 insertions(+), 58 deletions(-) diff --git a/Tusker/Screens/Lists/EditListAccountsViewController.swift b/Tusker/Screens/Lists/EditListAccountsViewController.swift index 76513c6ca1..b3327cd5fe 100644 --- a/Tusker/Screens/Lists/EditListAccountsViewController.swift +++ b/Tusker/Screens/Lists/EditListAccountsViewController.swift @@ -10,18 +10,21 @@ import UIKit import Pachyderm import Combine -class EditListAccountsViewController: EnhancedTableViewController { +class EditListAccountsViewController: UIViewController, CollectionViewController { private var list: List - let mastodonController: MastodonController + private let mastodonController: MastodonController - var changedAccounts = false + private var state = State.unloaded - var dataSource: DataSource! - var nextRange: RequestRange? + private(set) var changedAccounts = false - var searchResultsController: SearchResultsViewController! - var searchController: UISearchController! + private var dataSource: UICollectionViewDiffableDataSource! + var collectionView: UICollectionView! { view as? UICollectionView } + private var nextRange: RequestRange? + + private var searchResultsController: SearchResultsViewController! + private var searchController: UISearchController! private var listRenamedCancellable: AnyCancellable? @@ -29,7 +32,7 @@ class EditListAccountsViewController: EnhancedTableViewController { self.list = list self.mastodonController = mastodonController - super.init(style: .plain) + super.init(nibName: nil, bundle: nil) listChanged() @@ -46,29 +49,45 @@ class EditListAccountsViewController: EnhancedTableViewController { fatalError("init(coder:) has not been implemeneted") } + override func loadView() { + var config = UICollectionLayoutListConfiguration(appearance: .grouped) + config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in + var config = sectionConfig + switch dataSource.itemIdentifier(for: indexPath)! { + case .loadingIndicator: + config.topSeparatorVisibility = .hidden + config.bottomSeparatorVisibility = .hidden + case .account(id: _): + config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + } + return config + } + config.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in + switch dataSource.itemIdentifier(for: indexPath) { + case .account(id: let id): + let remove = UIContextualAction(style: .destructive, title: "Remove") { [unowned self] _, _, completion in + Task { + await self.removeAccount(id: id) + completion(true) + } + } + return UISwipeActionsConfiguration(actions: [remove]) + default: + return nil + } + } + let layout = UICollectionViewCompositionalLayout.list(using: config) + view = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.delegate = self + collectionView.allowsSelection = false + collectionView.backgroundColor = .appGroupedBackground + dataSource = createDataSource() + } + override func viewDidLoad() { super.viewDidLoad() - tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: "accountCell") - - tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 66 - tableView.allowsSelection = false - tableView.backgroundColor = .appGroupedBackground - - dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in - guard case let .account(id) = item else { fatalError() } - - let cell = tableView.dequeueReusableCell(withIdentifier: "accountCell", for: indexPath) as! AccountTableViewCell - cell.delegate = self - cell.updateUI(accountID: id) - cell.configurationUpdateHandler = { cell, state in - cell.backgroundConfiguration = .appListGroupedCell(for: state) - } - return cell - }) - dataSource.editListAccountsController = self - searchResultsController = SearchResultsViewController(mastodonController: mastodonController, scope: .people) searchResultsController.following = true searchResultsController.delegate = self @@ -87,7 +106,34 @@ class EditListAccountsViewController: EnhancedTableViewController { } navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed)) + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in + cell.indicator.startAnimating() + } + let accountCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in + cell.delegate = self + cell.updateUI(accountID: itemIdentifier) + cell.configurationUpdateHandler = { cell, state in + cell.backgroundConfiguration = .appListGroupedCell(for: state) + } + } + return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in + switch itemIdentifier { + case .loadingIndicator: + return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) + case .account(id: let id): + return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id) + } + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + clearSelectionOnAppear(animated: animated) + Task { await loadAccounts() } @@ -97,10 +143,21 @@ class EditListAccountsViewController: EnhancedTableViewController { title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title) } - func loadAccounts() async { + @MainActor + private func loadAccounts() async { + guard state == .unloaded else { return } + + state = .loading + + async let results = try await mastodonController.run(List.getAccounts(list.id)) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.accounts]) + snapshot.appendItems([.loadingIndicator]) + await dataSource.apply(snapshot) + do { - let request = List.getAccounts(list.id) - let (accounts, pagination) = try await mastodonController.run(request) + let (accounts, pagination) = try await results self.nextRange = pagination?.older await withCheckedContinuation { continuation in @@ -109,20 +166,61 @@ class EditListAccountsViewController: EnhancedTableViewController { } } - var snapshot = self.dataSource.snapshot() - if snapshot.indexOfSection(.accounts) == nil { - snapshot.appendSections([.accounts]) - } else { - snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts)) - } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.accounts]) snapshot.appendItems(accounts.map { .account(id: $0.id) }) await dataSource.apply(snapshot) + + state = .loaded } catch { let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in toast.dismissToast(animated: true) await self.loadAccounts() } self.showToast(configuration: config, animated: true) + + state = .unloaded + await dataSource.apply(.init()) + } + } + + private func loadNextPage() async { + guard state == .loaded, + let nextRange else { return } + + state = .loading + + async let results = try await mastodonController.run(List.getAccounts(list.id, range: nextRange)) + + let origSnapshot = dataSource.snapshot() + var snapshot = origSnapshot + snapshot.appendItems([.loadingIndicator]) + await dataSource.apply(snapshot) + + do { + let (accounts, pagination) = try await results + self.nextRange = pagination?.older + + await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(accounts: accounts) { + continuation.resume() + } + } + + var snapshot = origSnapshot + snapshot.appendItems(accounts.map { .account(id: $0.id) }) + await dataSource.apply(snapshot) + + state = .loaded + } catch { + let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in + toast.dismissToast(animated: true) + await self.loadNextPage() + } + self.showToast(configuration: config, animated: true) + + state = .loaded + await dataSource.apply(origSnapshot) } } @@ -157,12 +255,6 @@ class EditListAccountsViewController: EnhancedTableViewController { } } - // MARK: - Table view delegate - - override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - return .delete - } - // MARK: - Interaction @objc func renameButtonPressed() { @@ -171,29 +263,31 @@ class EditListAccountsViewController: EnhancedTableViewController { } +extension EditListAccountsViewController { + enum State { + case unloaded + case loading + case loaded + case loadingOlder + } +} + extension EditListAccountsViewController { enum Section: Hashable { case accounts } enum Item: Hashable { + case loadingIndicator case account(id: String) } - - class DataSource: UITableViewDiffableDataSource { - weak var editListAccountsController: EditListAccountsViewController? - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return true - } - - override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - guard editingStyle == .delete, - case let .account(id) = itemIdentifier(for: indexPath) else { - return - } - +} + +extension EditListAccountsViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + if state == .loaded, + indexPath.item == collectionView.numberOfItems(inSection: indexPath.section) - 1 { Task { - await self.editListAccountsController?.removeAccount(id: id) + await loadNextPage() } } } From 2464e2530f8fe917855189a066fb1d57a32bc7f1 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 27 Oct 2023 17:14:55 -0500 Subject: [PATCH 11/29] Remove dead code --- Tusker.xcodeproj/project.pbxproj | 12 -- .../EnhancedTableViewController.swift | 92 ------------- .../Account Cell/AccountTableViewCell.swift | 130 ------------------ .../Account Cell/AccountTableViewCell.xib | 76 ---------- 4 files changed, 310 deletions(-) delete mode 100644 Tusker/Screens/Utilities/EnhancedTableViewController.swift delete mode 100644 Tusker/Views/Account Cell/AccountTableViewCell.swift delete mode 100644 Tusker/Views/Account Cell/AccountTableViewCell.xib diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 6c6fafdb24..a15de80a9c 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -214,8 +214,6 @@ D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; }; D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; }; D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; }; - D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; }; - D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */; }; D6A4531629EF64BA00032932 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4531529EF64BA00032932 /* ShareViewController.swift */; }; D6A4531929EF64BA00032932 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6A4531729EF64BA00032932 /* MainInterface.storyboard */; }; D6A4531D29EF64BA00032932 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6A4531329EF64BA00032932 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -258,7 +256,6 @@ D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366C2828444F00237D0E /* SavedInstance.swift */; }; D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366E2828452F00237D0E /* SavedHashtag.swift */; }; D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */; }; - D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */; }; D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; }; D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; }; D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; @@ -618,8 +615,6 @@ D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = ""; }; D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = ""; }; D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = ""; }; - D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTableViewCell.swift; sourceTree = ""; }; - D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountTableViewCell.xib; sourceTree = ""; }; D6A4531329EF64BA00032932 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D6A4531529EF64BA00032932 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; D6A4531829EF64BA00032932 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; @@ -661,7 +656,6 @@ D6B9366C2828444F00237D0E /* SavedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedInstance.swift; sourceTree = ""; }; D6B9366E2828452F00237D0E /* SavedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedHashtag.swift; sourceTree = ""; }; D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Helpers.swift"; sourceTree = ""; }; - D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = ""; }; D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = ""; }; D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = ""; }; D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = ""; }; @@ -1296,8 +1290,6 @@ D6A3BC872321F78000FD64D5 /* Account Cell */ = { isa = PBXGroup; children = ( - D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */, - D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */, D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */, ); path = "Account Cell"; @@ -1427,7 +1419,6 @@ D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */, D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */, D62D67C42A97D8CD00167EE2 /* MultiColumnNavigationController.swift */, - D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */, D693DE5823FE24300061E07D /* InteractivePushTransition.swift */, D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */, D6E0DC8D216EDF1E00369478 /* Previewing.swift */, @@ -1861,7 +1852,6 @@ D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */, D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */, D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */, - D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */, D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */, D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */, D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */, @@ -1987,7 +1977,6 @@ D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */, D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */, D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */, - D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */, D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */, D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */, D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */, @@ -2227,7 +2216,6 @@ D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */, D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, - D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */, D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */, D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, diff --git a/Tusker/Screens/Utilities/EnhancedTableViewController.swift b/Tusker/Screens/Utilities/EnhancedTableViewController.swift deleted file mode 100644 index 6ea5e358d0..0000000000 --- a/Tusker/Screens/Utilities/EnhancedTableViewController.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// EnhancedTableViewController.swift -// Tusker -// -// Created by Shadowfacts on 11/10/18. -// Copyright © 2018 Shadowfacts. All rights reserved. -// - -import UIKit -import SafariServices - -class EnhancedTableViewController: UITableViewController { - - var dragEnabled = false - - override func viewDidLoad() { - super.viewDidLoad() - - if dragEnabled { - tableView.dragDelegate = self - } - } - - // MARK: Table View Delegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if let cell = tableView.cellForRow(at: indexPath) as? SelectableTableViewCell { - cell.didSelectCell() - } - } - -} - -extension EnhancedTableViewController { - - override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - if let cell = tableView.cellForRow(at: indexPath) as? UITableViewCell & MenuPreviewProvider { - let cellLocation = cell.convert(point, from: tableView) - guard let (previewProvider, actionsProvider) = cell.getPreviewProviders(for: cellLocation, sourceViewController: self) else { - return nil - } - let actionProvider: UIContextMenuActionProvider = { (_) in - let suggested = self.getSuggestedContextMenuActions(tableView: tableView, indexPath: indexPath, point: point) - return UIMenu(title: "", image: nil, identifier: nil, options: [], children: suggested + actionsProvider()) - } - return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider, actionProvider: actionProvider) - } else { - return nil - } - } - - // todo: replace this with the UIKit suggested actions, if possible - @objc open func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] { - return [] - } - - override func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { - if let viewController = animator.previewViewController { - animator.preferredCommitStyle = .pop - animator.addCompletion { - if let customPresenting = viewController as? CustomPreviewPresenting { - customPresenting.presentFromPreview(presenter: self) - } else { - self.show(viewController, sender: nil) - } - } - } - } - -} - -extension EnhancedTableViewController: UITableViewDragDelegate { - func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { - guard let cell = tableView.cellForRow(at: indexPath) as? DraggableTableViewCell else { - return [] - } - return cell.dragItemsForBeginning(session: session) - } -} - -extension EnhancedTableViewController: TabBarScrollableViewController { - func tabBarScrollToTop() { - tableView.scrollToTop() - } -} - -extension EnhancedTableViewController: StatusBarTappableViewController { - func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { - tableView.scrollToTop() - return .stop - } -} diff --git a/Tusker/Views/Account Cell/AccountTableViewCell.swift b/Tusker/Views/Account Cell/AccountTableViewCell.swift deleted file mode 100644 index 67036ec00b..0000000000 --- a/Tusker/Views/Account Cell/AccountTableViewCell.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// AccountTableViewCell.swift -// Tusker -// -// Created by Shadowfacts on 9/5/19. -// Copyright © 2019 Shadowfacts. All rights reserved. -// - -import UIKit -import SwiftSoup - -class AccountTableViewCell: UITableViewCell { - - weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)? - var mastodonController: MastodonController! { delegate?.apiController } - - @IBOutlet weak var avatarImageView: UIImageView! - @IBOutlet weak var displayNameLabel: AccountDisplayNameLabel! - @IBOutlet weak var usernameLabel: UILabel! - @IBOutlet weak var noteLabel: EmojiLabel! - - var accountID: String! - - private var avatarRequest: ImageCache.Request? - private var isGrayscale = false - - override func awakeFromNib() { - super.awakeFromNib() - - avatarImageView.layer.masksToBounds = true - avatarImageView.layer.cornerCurve = .continuous - - usernameLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .light)) - usernameLabel.adjustsFontForContentSizeCategory = true - - noteLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) - noteLabel.adjustsFontForContentSizeCategory = true - - NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPrefrences), name: .preferencesChanged, object: nil) - } - - @objc func updateUIForPrefrences() { - avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) - - guard let account = mastodonController.persistentContainer.account(for: accountID) else { - // this table view cell could be cached in a table view (e.g., SearchResultsViewController) for an account that's since been purged - return - } - displayNameLabel.updateForAccountDisplayName(account: account) - - if isGrayscale != Preferences.shared.grayscaleImages { - updateGrayscaleableUI(account: account) - } - } - - func updateUI(accountID: String) { - self.accountID = accountID - guard let account = mastodonController.persistentContainer.account(for: accountID) else { - fatalError("Missing cached account \(accountID)") - } - - usernameLabel.text = "@\(account.acct)" - - updateGrayscaleableUI(account: account) - updateUIForPrefrences() - } - - private func updateGrayscaleableUI(account: AccountMO) { - isGrayscale = Preferences.shared.grayscaleImages - - let accountID = self.accountID - - avatarImageView.image = nil - if let avatarURL = account.avatar { - avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in - guard let self = self else { return } - self.avatarRequest = nil - - guard let image = image, - self.accountID == accountID, - let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else { return } - - DispatchQueue.main.async { - self.avatarImageView.image = transformedImage - } - } - } - - let doc = try! SwiftSoup.parse(account.note) - noteLabel.text = try! doc.text() - noteLabel.setEmojis(account.emojis, identifier: account.id) - } - - override func prepareForReuse() { - super.prepareForReuse() - - avatarRequest?.cancel() - } - -} - -extension AccountTableViewCell: SelectableTableViewCell { - func didSelectCell() { - delegate?.selected(account: accountID) - } -} - -extension AccountTableViewCell: MenuPreviewProvider { - func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { - guard let mastodonController = mastodonController else { return nil } - return ( - content: { ProfileViewController(accountID: self.accountID, mastodonController: mastodonController) }, - actions: { self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(self.avatarImageView)) ?? [] } - ) - } -} - -extension AccountTableViewCell: DraggableTableViewCell { - func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] { - guard let account = mastodonController.persistentContainer.account(for: accountID), - let currentAccountID = mastodonController.accountInfo?.id else { - return [] - } - let provider = NSItemProvider(object: account.url as NSURL) - let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID) - activity.displaysAuxiliaryScene = true - provider.registerObject(activity, visibility: .all) - return [UIDragItem(itemProvider: provider)] - } -} diff --git a/Tusker/Views/Account Cell/AccountTableViewCell.xib b/Tusker/Views/Account Cell/AccountTableViewCell.xib deleted file mode 100644 index e2c188c0a0..0000000000 --- a/Tusker/Views/Account Cell/AccountTableViewCell.xib +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From bd21e88e8bd1d453f9e9a13da475d59ba09f2bb9 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 28 Oct 2023 12:16:14 -0500 Subject: [PATCH 12/29] Add UI for changing list reply policy and exclusivity Closes #428 --- .../InstanceFeatures/InstanceFeatures.swift | 8 ++ Tusker.xcodeproj/project.pbxproj | 4 + Tusker/API/EditListSettingsService.swift | 46 ++++++++++ Tusker/API/MastodonController.swift | 2 +- Tusker/API/RenameListService.swift | 2 +- .../EditListAccountsViewController.swift | 91 +++++++++++++++++-- .../Lists/ListTimelineViewController.swift | 2 +- 7 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 Tusker/API/EditListSettingsService.swift diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index 9219f9aa11..cfd35891e5 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -175,6 +175,14 @@ public class InstanceFeatures: ObservableObject { hasMastodonVersion(2, 8, 0) } + public var listRepliesPolicy: Bool { + hasMastodonVersion(3, 3, 0) + } + + public var exclusiveLists: Bool { + hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil)) + } + public init() { } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index a15de80a9c..b3e129fce3 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -264,6 +264,7 @@ D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */; }; D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; }; D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; }; + D6C041C42AED77730094D32D /* EditListSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C041C32AED77730094D32D /* EditListSettingsService.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; }; D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; }; @@ -665,6 +666,7 @@ D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = ""; }; D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = ""; }; D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = ""; }; + D6C041C32AED77730094D32D /* EditListSettingsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSettingsService.swift; sourceTree = ""; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = ""; }; D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = ""; }; D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = ""; }; @@ -1636,6 +1638,7 @@ D621733228F1D5ED004C7DB1 /* ReblogService.swift */, D6F6A54F291F058600F496A8 /* CreateListService.swift */, D6F6A551291F098700F496A8 /* RenameListService.swift */, + D6C041C32AED77730094D32D /* EditListSettingsService.swift */, D6F6A553291F0D9600F496A8 /* DeleteListService.swift */, D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */, D61F75B0293BD85300C0B37F /* CreateFilterService.swift */, @@ -2140,6 +2143,7 @@ D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */, + D6C041C42AED77730094D32D /* EditListSettingsService.swift in Sources */, D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */, D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */, D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */, diff --git a/Tusker/API/EditListSettingsService.swift b/Tusker/API/EditListSettingsService.swift new file mode 100644 index 0000000000..27fa466a74 --- /dev/null +++ b/Tusker/API/EditListSettingsService.swift @@ -0,0 +1,46 @@ +// +// EditListSettingsService.swift +// Tusker +// +// Created by Shadowfacts on 10/28/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +@MainActor +class EditListSettingsService { + private let list: ListProtocol + private let mastodonController: MastodonController + private let present: (UIViewController) -> Void + + init(list: ListProtocol, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) { + self.list = list + self.mastodonController = mastodonController + self.present = present + } + + func run(title: String? = nil, replyPolicy: List.ReplyPolicy? = nil, exclusive: Bool? = nil) async { + do { + let req = List.update( + list.id, + title: title ?? list.title, + replyPolicy: replyPolicy ?? list.replyPolicy, + exclusive: exclusive ?? list.exclusive + ) + let (list, _) = try await mastodonController.run(req) + mastodonController.updatedList(list) + } catch { + let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in + Task { + await self.run(title: title, replyPolicy: replyPolicy, exclusive: exclusive) + } + })) + present(alert) + } + } + +} diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 5cb73fa120..1adf417e18 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -471,7 +471,7 @@ class MastodonController: ObservableObject { } @MainActor - func renamedList(_ list: List) { + func updatedList(_ list: List) { var new = self.lists if let index = new.firstIndex(where: { $0.id == list.id }) { new[index] = list diff --git a/Tusker/API/RenameListService.swift b/Tusker/API/RenameListService.swift index 45931cebfd..9b0ac9c454 100644 --- a/Tusker/API/RenameListService.swift +++ b/Tusker/API/RenameListService.swift @@ -49,7 +49,7 @@ class RenameListService { do { let req = List.update(list.id, title: title, replyPolicy: nil, exclusive: nil) let (list, _) = try await mastodonController.run(req) - mastodonController.renamedList(list) + mastodonController.updatedList(list) } catch { let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) diff --git a/Tusker/Screens/Lists/EditListAccountsViewController.swift b/Tusker/Screens/Lists/EditListAccountsViewController.swift index b3327cd5fe..40f0335457 100644 --- a/Tusker/Screens/Lists/EditListAccountsViewController.swift +++ b/Tusker/Screens/Lists/EditListAccountsViewController.swift @@ -38,7 +38,6 @@ class EditListAccountsViewController: UIViewController, CollectionViewController listRenamedCancellable = mastodonController.$lists .compactMap { $0.first { $0.id == list.id } } - .removeDuplicates(by: { $0.title == $1.title }) .sink { [unowned self] in self.list = $0 self.listChanged() @@ -103,9 +102,27 @@ class EditListAccountsViewController: UIViewController, CollectionViewController navigationItem.hidesSearchBarWhenScrolling = false if #available(iOS 16.0, *) { navigationItem.preferredSearchBarPlacement = .stacked + + navigationItem.renameDelegate = self + navigationItem.titleMenuProvider = { [unowned self] suggested in + var children = suggested + children.append(contentsOf: self.listSettingsMenuElements()) + return UIMenu(children: children) + } + } else { + navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Edit", menu: UIMenu(children: [ + // uncached so that menu always reflects the current state of the list + UIDeferredMenuElement.uncached({ [unowned self] elementHandler in + var elements = self.listSettingsMenuElements() + elements.insert(UIAction(title: "Rename…", image: UIImage(systemName: "pencil"), handler: { [unowned self] _ in + RenameListService(list: self.list, mastodonController: self.mastodonController, present: { + self.present($0, animated: true) + }).run() + }), at: 0) + elementHandler(elements) + }) + ])) } - - navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed)) } private func createDataSource() -> UICollectionViewDiffableDataSource { @@ -140,7 +157,31 @@ class EditListAccountsViewController: UIViewController, CollectionViewController } private func listChanged() { - title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title) + title = list.title + } + + private func listSettingsMenuElements() -> [UIMenuElement] { + var elements = [UIMenuElement]() + if mastodonController.instanceFeatures.listRepliesPolicy { + let actions = List.ReplyPolicy.allCases.map { policy in + UIAction(title: policy.actionTitle, state: list.replyPolicy == policy ? .on : .off) { [unowned self] _ in + self.setReplyPolicy(policy) + } + } + elements.append(UIMenu(title: "Show replies…", image: UIImage(systemName: "arrowshape.turn.up.left"), children: actions)) + } + if mastodonController.instanceFeatures.exclusiveLists { + let actions = [ + UIAction(title: "Hidden from Home", state: list.exclusive == true ? .on : .off) { [unowned self] _ in + self.setExclusive(true) + }, + UIAction(title: "Shown on Home", state: list.exclusive == false ? .on : .off) { [unowned self] _ in + self.setExclusive(false) + }, + ] + elements.append(UIMenu(title: "Posts from this list are…", children: actions)) + } + return elements } @MainActor @@ -254,13 +295,21 @@ class EditListAccountsViewController: UIViewController, CollectionViewController self.showToast(configuration: config, animated: true) } } - - // MARK: - Interaction - @objc func renameButtonPressed() { - RenameListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }).run() + private func setReplyPolicy(_ replyPolicy: List.ReplyPolicy) { + Task { + let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) + await service.run(replyPolicy: replyPolicy) + } } + private func setExclusive(_ exclusive: Bool) { + Task { + let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) + await service.run(exclusive: exclusive) + } + } + } extension EditListAccountsViewController { @@ -310,3 +359,29 @@ extension EditListAccountsViewController: SearchResultsViewControllerDelegate { } } } + +extension EditListAccountsViewController: UINavigationItemRenameDelegate { + func navigationItem(_: UINavigationItem, shouldEndRenamingWith title: String) -> Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + func navigationItem(_: UINavigationItem, didEndRenamingWith title: String) { + Task { + let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) + await service.run(title: title) + } + } +} + +private extension List.ReplyPolicy { + var actionTitle: String { + switch self { + case .followed: + "To accounts you follow" + case .list: + "To other list members" + case .none: + "Never" + } + } +} diff --git a/Tusker/Screens/Lists/ListTimelineViewController.swift b/Tusker/Screens/Lists/ListTimelineViewController.swift index eab7d02512..23ffd7d7c4 100644 --- a/Tusker/Screens/Lists/ListTimelineViewController.swift +++ b/Tusker/Screens/Lists/ListTimelineViewController.swift @@ -59,7 +59,7 @@ class ListTimelineViewController: TimelineViewController { func presentEdit(animated: Bool) { let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController) - editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed)) + editListAccountsController.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed)) let navController = UINavigationController(rootViewController: editListAccountsController) present(navController, animated: animated) } From 0de9a9fd371169433e980e0d09f00008429e1130 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 28 Oct 2023 13:36:11 -0500 Subject: [PATCH 13/29] Fix list timeline refresh failing if initial load returned no statuses --- Tusker/Screens/Timeline/TimelineViewController.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index e8096cf475..2f7ec098c9 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -726,10 +726,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro @objc func refresh() { Task { @MainActor in - if case .notLoadedInitial = controller.state { - await controller.loadInitial() - } else { + if case .idle = controller.state, + !dataSource.snapshot().itemIdentifiers(inSection: .statuses).isEmpty { await controller.loadNewer() + } else { + await controller.loadInitial() } #if !targetEnvironment(macCatalyst) collectionView.refreshControl?.endRefreshing() From 2ccec2f4df8fabfbd68febc9a23dbf132d8847ad Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 28 Oct 2023 13:47:44 -0500 Subject: [PATCH 14/29] Fix crash if URLComponents.url is nil in instance selector --- .../Onboarding/InstanceSelectorTableViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift index e73d2d84cf..60aed0c1ca 100644 --- a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift +++ b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift @@ -150,7 +150,8 @@ class InstanceSelectorTableViewController: UITableViewController { private func updateSpecificInstance(domain: String) { activityIndicator.startAnimating() - guard let components = parseURLComponents(input: domain) else { + guard let components = parseURLComponents(input: domain), + let url = components.url else { var snapshot = dataSource.snapshot() if snapshot.indexOfSection(.selected) != nil { snapshot.deleteSections([.selected]) @@ -159,7 +160,6 @@ class InstanceSelectorTableViewController: UITableViewController { activityIndicator.stopAnimating() return } - let url = components.url! let client = Client(baseURL: url, session: .appDefault) let request = Client.getInstance() From a07b398cbe5bc583462721f06a34944396ddc9a7 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 28 Oct 2023 13:52:54 -0500 Subject: [PATCH 15/29] Maybe fix crash due to VC hierarchy consistency check failing on split collapse/expand --- Tusker/Screens/Main/MainSplitViewController.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index d2df9c7672..406d1eec50 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -214,6 +214,10 @@ extension MainSplitViewController: UISplitViewControllerDelegate { if item == sidebar.selectedItem { itemNavStack = secondaryNavController.viewControllers secondaryNavController.viewControllers = [] + // Sometimes removing a VC from the viewControllers array doesn't immediately remove it's view from the hierarchy + for vc in itemNavStack { + vc.viewIfLoaded?.removeFromSuperview() + } } else { itemNavStack = navigationStacks[item] ?? [] navigationStacks.removeValue(forKey: item) @@ -339,6 +343,11 @@ extension MainSplitViewController: UISplitViewControllerDelegate { let viewControllersToMove = navController.viewControllers.dropFirst(skipFirst) navController.viewControllers.removeLast(navController.viewControllers.count - skipFirst) + // Sometimes removing a VC from the viewControllers array doesn't immediately remove it's view from the hierarchy + for vc in viewControllersToMove { + vc.viewIfLoaded?.removeFromSuperview() + } + if let prepend = prepend { navigationStacks[item] = [prepend] + viewControllersToMove } else { From 3aa45cb3654b5fc2d0d12e4d91bf685eed8a6cc4 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 28 Oct 2023 13:56:25 -0500 Subject: [PATCH 16/29] Maybe fix crash due to reading ScaledMetric on background thread ScaledMetric.wrappedValue calls into Font.scaleFactor(textStyle:in:) which uses a dictionary setter --- Tusker/Views/AccountDisplayNameView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tusker/Views/AccountDisplayNameView.swift b/Tusker/Views/AccountDisplayNameView.swift index 0365171c4d..22e3effda9 100644 --- a/Tusker/Views/AccountDisplayNameView.swift +++ b/Tusker/Views/AccountDisplayNameView.swift @@ -38,6 +38,7 @@ struct AccountDisplayNameView: View { let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange) guard !matches.isEmpty else { return } + let emojiSize = self.emojiSize let emojiImages = MultiThreadDictionary() let group = DispatchGroup() From af5a0b7bbdd70a420df8e902cfb9699f27d2c375 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 28 Oct 2023 13:58:39 -0500 Subject: [PATCH 17/29] Fix crash with large image dismiss gesture --- .../Transitions/LargeImageInteractionController.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Tusker/Screens/Large Image/Transitions/LargeImageInteractionController.swift b/Tusker/Screens/Large Image/Transitions/LargeImageInteractionController.swift index 4f8618b0bb..562846a81f 100644 --- a/Tusker/Screens/Large Image/Transitions/LargeImageInteractionController.swift +++ b/Tusker/Screens/Large Image/Transitions/LargeImageInteractionController.swift @@ -25,7 +25,14 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition { } @objc func handleGesture(_ recognizer: UIPanGestureRecognizer) { - let translation = recognizer.translation(in: recognizer.view!.superview!) + guard let recognizerSuperview = recognizer.view?.superview else { + // Assume the gesture has ended b/c we don't have a view/superview anymore. + inProgress = false + direction = nil + cancel() + return + } + let translation = recognizer.translation(in: recognizerSuperview) var progress = translation.y / 200 if let direction = direction { progress *= direction @@ -63,7 +70,7 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition { override func cancel() { super.cancel() - viewController.isInteractivelyAnimatingDismissal = false + viewController?.isInteractivelyAnimatingDismissal = false } } From 37311e5f17ba136bb56425ffed30ad8ccab62bc9 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 28 Oct 2023 14:03:08 -0500 Subject: [PATCH 18/29] Fix potential crash due to race condition in timeline gap filling --- Tusker/Screens/Timeline/TimelineViewController.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 2f7ec098c9..d28587c681 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -1166,7 +1166,10 @@ extension TimelineViewController { let addedItems: Bool let statusItems = snapshot.itemIdentifiers(inSection: .statuses) - let gapIndex = statusItems.firstIndex(of: .gap)! + guard let gapIndex = statusItems.firstIndex(of: .gap) else { + // Not sure how this is reachable (maybe the gap cell was tapped twice and the requests raced?) but w/e + return + } switch direction { case .above: @@ -1293,6 +1296,9 @@ extension TimelineViewController: UICollectionViewDelegate { selected(status: status.reblog?.id ?? id, state: collapseState.copy()) } case .gap: + guard controller.state == .idle else { + return + } let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell cell.showsIndicator = true Task { From e4eff2d362b7e7921e82cc35aaaea1bf6244549e Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 28 Oct 2023 14:14:02 -0500 Subject: [PATCH 19/29] Bump version and update changelog --- CHANGELOG.md | 15 +++++++++++++++ Version.xcconfig | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a20eebdbd9..b230106f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 2023.8 (105) +Features/Improvements: +- Use server-set preference for default post visibility, language, and (on Hometown) local-only +- Add preference to underline links +- Allow changing list reply policy and exclusivity from menu on Edit List screen +- Attribute network requests to user, rather than developer, when appropriate + +Bugfixes: +- Fix older notifications not loading if all initially-loaded are grouped together +- Fix list timelines failing to refresh if there were no statuses initially +- Fix timeline jump button having a background when Button Shapes accessibility setting is on +- Fix crash when relaunching app after not being launched in more than a week +- Fix potential crash on instance selector screen +- Fix crash when showing display names with custom emojis in certain places + ## 2023.8 (104) Features/Improvements: - Show search operators on Mastodon 4.2 diff --git a/Version.xcconfig b/Version.xcconfig index 53cd7e4ca0..6955590689 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -10,7 +10,7 @@ // https://help.apple.com/xcode/#/dev745c5c974 MARKETING_VERSION = 2023.8 -CURRENT_PROJECT_VERSION = 104 +CURRENT_PROJECT_VERSION = 105 CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev From 2b5d4681e3f80857972ed5a88713cb3f2fcf9a57 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 2 Nov 2023 10:44:52 -0400 Subject: [PATCH 20/29] Prevent mul/und from being used as language Closes #440 --- .../ComposeUI/Views/LanguagePicker.swift | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift index 4680d5e9b0..22d6d9f128 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift @@ -30,7 +30,13 @@ struct LanguagePicker: View { if maybeIso639Code.last == "-" { maybeIso639Code = maybeIso639Code[.. Date: Thu, 2 Nov 2023 17:53:26 -0400 Subject: [PATCH 21/29] Remove dead code --- .../Sources/ComposeUI/API/PostService.swift | 4 +- .../ComposeUI/Model/AttachmentData.swift | 278 ------------------ 2 files changed, 2 insertions(+), 280 deletions(-) delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift index 2097647e45..6733c17df2 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift @@ -111,7 +111,7 @@ class PostService: ObservableObject { do { (data, utType) = try await getData(for: attachment) currentStep += 1 - } catch let error as AttachmentData.Error { + } catch let error as DraftAttachment.ExportError { throw Error.attachmentData(index: index, cause: error) } do { @@ -169,7 +169,7 @@ class PostService: ObservableObject { } enum Error: Swift.Error, LocalizedError { - case attachmentData(index: Int, cause: AttachmentData.Error) + case attachmentData(index: Int, cause: DraftAttachment.ExportError) case attachmentUpload(index: Int, cause: Client.Error) case posting(Client.Error) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift deleted file mode 100644 index 7bd31cd2ff..0000000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift +++ /dev/null @@ -1,278 +0,0 @@ -// -// AttachmentData.swift -// ComposeUI -// -// Created by Shadowfacts on 1/1/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import UIKit -import Photos -import UniformTypeIdentifiers -import PencilKit -import InstanceFeatures - -enum AttachmentData { - case asset(PHAsset) - case image(Data, originalType: UTType) - case video(URL) - case drawing(PKDrawing) - case gif(Data) - - var type: AttachmentType { - switch self { - case let .asset(asset): - return asset.attachmentType! - case .image(_, originalType: _): - return .image - case .video(_): - return .video - case .drawing(_): - return .image - case .gif(_): - return .image - } - } - - var isAsset: Bool { - switch self { - case .asset(_): - return true - default: - return false - } - } - - var canSaveToDraft: Bool { - switch self { - case .video(_): - return false - default: - return true - } - } - - func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) { - switch self { - case let .image(originalData, originalType): - let data: Data - let type: UTType - switch originalType { - case .png, .jpeg: - data = originalData - type = originalType - default: - let image = UIImage(data: originalData)! - // The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future. - data = image.jpegData(compressionQuality: 0.8)! - type = .jpeg - } - let processed = processImageData(data, type: type, features: features, skipAllConversion: skipAllConversion) - completion(.success(processed)) - case let .asset(asset): - if asset.mediaType == .image { - let options = PHImageRequestOptions() - options.version = .current - options.deliveryMode = .highQualityFormat - options.resizeMode = .none - options.isNetworkAccessAllowed = true - PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in - guard let data = data, let dataUTI = dataUTI else { - completion(.failure(.missingData)) - return - } - let processed = processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion) - completion(.success(processed)) - } - } else if asset.mediaType == .video { - let options = PHVideoRequestOptions() - options.deliveryMode = .automatic - options.isNetworkAccessAllowed = true - options.version = .current - PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in - if let exportSession = exportSession { - AttachmentData.exportVideoData(session: exportSession, completion: completion) - } else if let error = info?[PHImageErrorKey] as? Error { - completion(.failure(.videoExport(error))) - } else { - completion(.failure(.noVideoExportSession)) - } - } - } else { - fatalError("assetType must be either image or video") - } - case let .video(url): - let asset = AVURLAsset(url: url) - guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { - completion(.failure(.noVideoExportSession)) - return - } - AttachmentData.exportVideoData(session: session, completion: completion) - - case let .drawing(drawing): - let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1) - completion(.success((image.pngData()!, .png))) - case let .gif(data): - completion(.success((data, .gif))) - } - } - - private func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) { - guard !skipAllConversion else { - return (data, type) - } - - var data = data - var type = type - let image = CIImage(data: data)! - let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB - - // neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG - // they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB) - // if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion - if needsColorSpaceConversion || type == .heic { - let context = CIContext() - let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace! - if type == .png { - data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)! - } else { - data = context.jpegRepresentation(of: image, colorSpace: colorSpace)! - type = .jpeg - } - } - - return (data, type) - } - - private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), Error>) -> Void) { - session.outputFileType = .mp4 - session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") - session.exportAsynchronously { - guard session.status == .completed else { - completion(.failure(.videoExport(session.error!))) - return - } - do { - let data = try Data(contentsOf: session.outputURL!) - completion(.success((data, .mpeg4Movie))) - } catch { - completion(.failure(.videoExport(error))) - } - } - } - - enum AttachmentType { - case image, video - } - - enum Error: Swift.Error, LocalizedError { - case missingData - case videoExport(Swift.Error) - case noVideoExportSession - - var localizedDescription: String { - switch self { - case .missingData: - return "Missing Data" - case .videoExport(let error): - return "Exporting video: \(error)" - case .noVideoExportSession: - return "Couldn't create video export session" - } - } - } -} - -extension PHAsset { - var attachmentType: AttachmentData.AttachmentType? { - switch self.mediaType { - case .image: - return .image - case .video: - return .video - default: - return nil - } - } -} - -extension AttachmentData: Codable { - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - switch self { - case let .asset(asset): - try container.encode("asset", forKey: .type) - try container.encode(asset.localIdentifier, forKey: .assetIdentifier) - case let .image(originalData, originalType): - try container.encode("image", forKey: .type) - try container.encode(originalType, forKey: .imageType) - try container.encode(originalData, forKey: .imageData) - case .video(_): - throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "video CompositionAttachments cannot be encoded")) - case let .drawing(drawing): - try container.encode("drawing", forKey: .type) - let drawingData = drawing.dataRepresentation() - try container.encode(drawingData, forKey: .drawing) - case .gif(_): - throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "gif CompositionAttachments cannot be encoded")) - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - switch try container.decode(String.self, forKey: .type) { - case "asset": - let identifier = try container.decode(String.self, forKey: .assetIdentifier) - guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject else { - throw DecodingError.dataCorruptedError(forKey: .assetIdentifier, in: container, debugDescription: "Could not fetch asset with local identifier") - } - self = .asset(asset) - case "image": - let data = try container.decode(Data.self, forKey: .imageData) - if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) { - self = .image(data, originalType: type) - } else { - guard let image = UIImage(data: data) else { - throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage") - } - let jpegData = image.jpegData(compressionQuality: 1)! - self = .image(jpegData, originalType: .jpeg) - } - case "drawing": - let drawingData = try container.decode(Data.self, forKey: .drawing) - let drawing = try PKDrawing(data: drawingData) - self = .drawing(drawing) - default: - throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing") - } - } - - enum CodingKeys: CodingKey { - case type - case imageData - case imageType - /// The local identifier of the PHAsset for this attachment - case assetIdentifier - /// The PKDrawing object for this attachment. - case drawing - } -} - -extension AttachmentData: Equatable { - static func ==(lhs: AttachmentData, rhs: AttachmentData) -> Bool { - switch (lhs, rhs) { - case let (.asset(a), .asset(b)): - return a.localIdentifier == b.localIdentifier - case let (.image(a, originalType: aType), .image(b, originalType: bType)): - return a == b && aType == bType - case let (.video(a), .video(b)): - return a == b - case let (.drawing(a), .drawing(b)): - return a == b - default: - return false - } - } -} From 6c2c2e6ae7dc67239d8a74e41a3006730bc537dc Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 2 Nov 2023 18:18:08 -0400 Subject: [PATCH 22/29] More logging to try and pin down LazilyDecoding EXC_BAD_ACCESS --- Tusker/Screens/Timeline/TimelineViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index d28587c681..b822b49aab 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -574,6 +574,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro var count = 0 while count < 5 { count += 1 + + let crumb = Breadcrumb(level: .info, category: "TimelineViewController") + crumb.message = "scrollToItem, attempt=\(count)" + SentrySDK.addBreadcrumb(crumb) + let origOffset = self.collectionView.contentOffset self.collectionView.layoutIfNeeded() self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) From 34e57c297b5bd8b06188cc0455bedcaf331ab3f8 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 3 Nov 2023 11:07:43 -0400 Subject: [PATCH 23/29] Tweak HEIF/HEIC handling --- .../ComposeUI/CoreData/DraftAttachment.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift index 16f2443dbd..0def2175b0 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift @@ -137,6 +137,8 @@ extension DraftAttachment { //private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment" private let imageType = UTType.image.identifier +private let heifType = UTType.heif.identifier +private let heicType = UTType.heic.identifier private let jpegType = UTType.jpeg.identifier private let pngType = UTType.png.identifier private let mp4Type = UTType.mpeg4Movie.identifier @@ -148,7 +150,7 @@ extension DraftAttachment: NSItemProviderReading { // todo: is there a better way of handling movies than manually adding all possible UTI types? // just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension // without the file extension, getting the thumbnail and exporting the video for attachment upload fails - [/*typeIdentifier, */ gifType, jpegType, pngType, imageType, mp4Type, quickTimeType] + [/*typeIdentifier, */ gifType, heifType, heicType, jpegType, pngType, imageType, mp4Type, quickTimeType] } public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment { @@ -273,20 +275,13 @@ extension DraftAttachment { var data = data var type = type - if type != .png && type != .jpeg, - let image = UIImage(data: data) { - // The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future. - data = image.jpegData(compressionQuality: 0.8)! - type = .jpeg - } - let image = CIImage(data: data)! let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB // neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG // they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB) // if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion - if needsColorSpaceConversion || type == .heic { + if needsColorSpaceConversion || type == .heic || type == .heif { let context = CIContext() let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace! if type == .png { From dcdfe853e1e6251798b3e84843917597884fc768 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 5 Nov 2023 11:14:58 -0500 Subject: [PATCH 24/29] Fix Cmd+W closing sometimes closing non-foreground window on macOS Closes #444 --- Tusker/AppDelegate.swift | 7 ------- Tusker/MenuController.swift | 17 ++++++++++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index 42674b3037..7d0c21b0ef 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -162,13 +162,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - @objc func closeWindow() { - guard let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) else { - return - } - UIApplication.shared.requestSceneSessionDestruction(scene.session, options: nil) - } - private func swizzleStatusBar() { let selector = Selector(("handleTapAction:")) var originalIMP: IMP? diff --git a/Tusker/MenuController.swift b/Tusker/MenuController.swift index 3013b89823..eb6da265df 100644 --- a/Tusker/MenuController.swift +++ b/Tusker/MenuController.swift @@ -41,22 +41,25 @@ struct MenuController { static let prevSubTabCommand = UIKeyCommand(title: "Previous Sub Tab", action: #selector(TabbedPageViewController.selectPrevPage), input: "[", modifierFlags: [.command, .shift]) static func buildMainMenu(builder: UIMenuBuilder) { - builder.replace(menu: .file, with: buildFileMenu()) + builder.replace(menu: .file, with: buildFileMenu(builder: builder)) builder.insertChild(buildSubTabMenu(), atStartOfMenu: .view) builder.insertChild(buildSidebarShortcuts(), atStartOfMenu: .view) } - private static func buildFileMenu() -> UIMenu { + private static func buildFileMenu(builder: UIMenuBuilder) -> UIMenu { + var children: [UIMenuElement] = [ + composeCommand, + refreshCommand(discoverabilityTitle: nil), + ] + if let close = builder.menu(for: .close) { + children.append(close) + } return UIMenu( title: "File", image: nil, identifier: nil, options: [], - children: [ - composeCommand, - refreshCommand(discoverabilityTitle: nil), - UIKeyCommand(title: "Close", action: #selector(AppDelegate.closeWindow), input: "w", modifierFlags: .command), - ] + children: children ) } From e57ef210fd1df4073fb0ec3d3fcbb128dcb4dd78 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 5 Nov 2023 11:32:49 -0500 Subject: [PATCH 25/29] Fix language picker button not having a pointer effect --- Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift index 22d6d9f128..100e376d82 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift @@ -69,6 +69,8 @@ struct LanguagePicker: View { Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased()) } .accessibilityLabel("Post Language") + .padding(5) + .hoverEffect() .sheet(isPresented: $isShowingSheet) { NavigationStack { LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet) From 4c5da1b5a93892a220529b379fc1a8dc2d310d00 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 5 Nov 2023 15:21:41 -0500 Subject: [PATCH 26/29] Add URL handler for opening Compose window --- Tusker/Scenes/MainSceneDelegate.swift | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 984bc8b7da..3379ddc8fc 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -35,7 +35,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate window = UIWindow(windowScene: windowScene) showAppOrOnboardingUI(session: session) - if connectionOptions.urlContexts.count > 0 { + if !connectionOptions.urlContexts.isEmpty { self.scene(scene, openURLContexts: connectionOptions.urlContexts) } @@ -50,14 +50,21 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate } func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - if URLContexts.count > 1 { - fatalError("Cannot open more than 1 URL") + guard let url = URLContexts.first?.url, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let rootViewController else { + return } - let url = URLContexts.first!.url - - if var components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let rootViewController = rootViewController { + if components.host == "compose" { + if let mastodonController = window!.windowScene!.session.mastodonController { + let draft = mastodonController.createDraft() + let text = components.queryItems?.first(where: { $0.name == "text" })?.value + draft.text = text ?? "" + rootViewController.compose(editing: draft, animated: true, isDucked: false) + } + } else { + // Assume anything else is a search query components.scheme = "https" let query = components.string! rootViewController.performSearch(query: query) From e6f1968609c6003b0b29d3ddacd303e4061ac464 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 5 Nov 2023 18:22:20 -0500 Subject: [PATCH 27/29] Fix TimelineLikeCollectionViewController.apply not actually applying snapshots on the main thread --- .../Utilities/TimelineLikeCollectionViewController.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift b/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift index 697e6b35a6..8239f09a80 100644 --- a/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift +++ b/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift @@ -211,9 +211,10 @@ extension TimelineLikeCollectionViewController { extension TimelineLikeCollectionViewController { // apply(_:animatingDifferences:) is marked as nonisolated, so just awaiting it doesn't dispatch to the main thread, unlike other async @MainActor methods // but we always want to update the data source on the main thread for consistency, so this method does that - @MainActor func apply(_ snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool) async { - await self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + await MainActor.run { + dataSource?.apply(snapshot, animatingDifferences: animatingDifferences) + } } @MainActor From 86862825f67affd865aa90d994aa998a9537cfb5 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 5 Nov 2023 18:32:05 -0500 Subject: [PATCH 28/29] Assert that the compose draft belongs to the view context --- .../Sources/ComposeUI/Controllers/ComposeController.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index 93682956bf..b31aaec556 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -20,7 +20,11 @@ public final class ComposeController: ViewController { public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView public typealias EmojiImageView = (Emoji) -> AnyView - @Published public private(set) var draft: Draft + @Published public private(set) var draft: Draft { + didSet { + assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext) + } + } @Published public var config: ComposeUIConfig @Published public var mastodonController: ComposeMastodonContext let fetchAvatar: AvatarImageView.FetchAvatar @@ -106,6 +110,7 @@ public final class ComposeController: ViewController { emojiImageView: @escaping EmojiImageView ) { self.draft = draft + assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext) self.config = config self.mastodonController = mastodonController self.fetchAvatar = fetchAvatar From cb474436496b1d7fb48db3bc32f4a523d8b9ac2e Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 7 Nov 2023 22:16:48 -0500 Subject: [PATCH 29/29] Bump version and update changelog --- CHANGELOG.md | 6 ++++++ Version.xcconfig | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b230106f0a..9efdee8223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2023.8 (106) +Bugfixes: +- Fix being able to set post language to multiple/undefined +- iPadOS: Fix language picker button not having a pointer effect +- macOS: Fix Cmd+W sometimes closing the non-foreground window + ## 2023.8 (105) Features/Improvements: - Use server-set preference for default post visibility, language, and (on Hometown) local-only diff --git a/Version.xcconfig b/Version.xcconfig index 6955590689..5c9d4c77ff 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -10,7 +10,7 @@ // https://help.apple.com/xcode/#/dev745c5c974 MARKETING_VERSION = 2023.8 -CURRENT_PROJECT_VERSION = 105 +CURRENT_PROJECT_VERSION = 106 CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev