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 6cf2ae5b..697e6b35 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 8bda27f3..2f1cf090 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 0a544121..a2c61180 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 df237ecc..a3df7ba1 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 774f8726..e37f64e5 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 f27349f2..e8096cf4 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 5328bdb9..b5fcce2e 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 1f441b16..1e50daa5 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 a2c61180..75b93de9 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 7f1a9a91..f77100f1 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 c5a95407..88c9bf91 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 c87ad5bf..a6c1342d 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 0c2187af..9219f9aa 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 88c9bf91..f4bed14c 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 00000000..14e1e87d --- /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 00000000..c0a7eb8d --- /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 b5fcce2e..6a099dd1 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 022fdefd..f79f0613 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 a2c8174c..66f9c4a8 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 c8c0b388..98151be7 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 092b622f..64963f9f 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 49ec21a5..0c15152b 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 14e1e87d..c93701e8 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 f79f0613..8deb9778 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 66f9c4a8..e3bd73c8 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 98151be7..306927c0 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 64963f9f..efa6cd2a 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 491035ab..e8dfdde9 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 d9d5291d..efe3a76f 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 e3bd73c8..5cb73fa1 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 9f3a9789..45931ceb 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 b9cc59fb..d22a3942 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 efa6cd2a..ba52345a 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 81b09431..284516a7 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 76513c6c..b3327cd5 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 6c6fafdb..a15de80a 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 6ea5e358..00000000 --- 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 67036ec0..00000000 --- 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 e2c188c0..00000000 --- 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 9219f9aa..cfd35891 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 a15de80a..b3e129fc 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 00000000..27fa466a --- /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 5cb73fa1..1adf417e 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 45931ceb..9b0ac9c4 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 b3327cd5..40f03354 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 eab7d025..23ffd7d7 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 e8096cf4..2f7ec098 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 e73d2d84..60aed0c1 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 d2df9c76..406d1eec 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 0365171c..22e3effd 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 4f8618b0..562846a8 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 2f7ec098..d28587c6 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 a20eebdb..b230106f 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 53cd7e4c..69555906 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 4680d5e9..22d6d9f1 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 2097647e..6733c17d 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 7bd31cd2..00000000 --- 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 d28587c6..b822b49a 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 16f2443d..0def2175 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 42674b30..7d0c21b0 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 3013b898..eb6da265 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 22d6d9f1..100e376d 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 984bc8b7..3379ddc8 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 697e6b35..8239f09a 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 93682956..b31aaec5 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 b230106f..9efdee82 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 69555906..5c9d4c77 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