Compare commits

..

No commits in common. "3ea1ad5622f6e9ae5b9fe7c77ddd21a6a2df696e" and "5ab22e742befe2373de8bc6136aa9813334a1094" have entirely different histories.

20 changed files with 58 additions and 251 deletions

View File

@ -1,32 +1,5 @@
# Changelog # Changelog
## 2022.1 (59)
This build is a hotfix for a crash when migrating saved instances to iCloud.
## 2022.1 (58)
Features/Improvements:
- Sync timeline positions via iCloud
- Add pinned timelines customization (synced via iCloud)
- Sync saved hashtags & instances via iCloud
- Add filters for hiding reblogs and replies from home timeline
- Show uncropped attachments in the timeline for posts that have only one attachment
- Add more prominent Follow button to profile pages
- Add About and Acknowledgements pages to Preferences
- Automatically report certain kinds of errors
- iPadOS/macOS: Add window titles to indicate which account is in use
- iPadOS: Limit content to readable width
- VoiceOver: Improve label for toggle collapse button in conversation view
Bugfixes:
- Fix attachments in timeline somtimes being untappable
- Fix previewing links in the main status in a conversation activating the link
- Don't show reblog swipe action when reblogging is forbidden
- Fix unknown notifications appearing in the Mentions view
- Fix crash when fetching present items in certain circumstances
- Fix relationship (following/blocked/etc.) change breaking profile header layout
- Fix crash when restored timeline state includes unloaded statuses
- macOS: Fix add attachment buttons not matching system accent color
## 2022.1 (53) ## 2022.1 (53)
Features/Improvements: Features/Improvements:
- Apply filters to Trending Posts - Apply filters to Trending Posts

View File

@ -288,7 +288,6 @@
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; }; D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; }; D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; }; D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; };
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; }; D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; };
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; }; D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; };
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */; }; D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */; };
@ -673,7 +672,6 @@
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; }; D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; }; D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; }; D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = "<group>"; };
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; }; D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = "<group>"; }; D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListViewController.swift; sourceTree = "<group>"; }; D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListViewController.swift; sourceTree = "<group>"; };
@ -1487,7 +1485,6 @@
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */, D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
D6114E1627F8BB210080E273 /* VersionTests.swift */, D6114E1627F8BB210080E273 /* VersionTests.swift */,
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */, D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */,
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */,
D6D4DDE6212518A200E1C4BB /* Info.plist */, D6D4DDE6212518A200E1C4BB /* Info.plist */,
); );
path = TuskerTests; path = TuskerTests;
@ -2139,7 +2136,6 @@
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */, D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */, D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */, D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */,
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */,
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */, D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -2282,7 +2278,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 59; CURRENT_PROJECT_VERSION = 53;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -2349,7 +2345,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 59; CURRENT_PROJECT_VERSION = 53;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2500,7 +2496,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 59; CURRENT_PROJECT_VERSION = 53;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -2528,7 +2524,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 59; CURRENT_PROJECT_VERSION = 53;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -2637,7 +2633,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 59; CURRENT_PROJECT_VERSION = 53;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2663,7 +2659,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 59; CURRENT_PROJECT_VERSION = 53;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;

View File

@ -155,14 +155,12 @@ class MastodonController: ObservableObject {
// are available when Filterers are constructed // are available when Filterers are constructed
loadCachedFilters() loadCachedFilters()
loadAccountPreferences() if let existing = try? persistentContainer.viewContext.fetch(AccountPreferences.fetchRequest(account: accountInfo!)).first {
accountPreferences = existing
NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator) } else {
.receive(on: DispatchQueue.main) accountPreferences = AccountPreferences.default(account: accountInfo!, context: persistentContainer.viewContext)
.sink { [unowned self] _ in persistentContainer.save(context: persistentContainer.viewContext)
self.loadAccountPreferences() }
}
.store(in: &cancellables)
Task { Task {
do { do {
@ -179,16 +177,6 @@ class MastodonController: ObservableObject {
} }
} }
@MainActor
private func loadAccountPreferences() {
if let existing = try? persistentContainer.viewContext.fetch(AccountPreferences.fetchRequest(account: accountInfo!)).first {
accountPreferences = existing
} else {
accountPreferences = AccountPreferences.default(account: accountInfo!, context: persistentContainer.viewContext)
persistentContainer.save(context: persistentContainer.viewContext)
}
}
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) { func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
if account != nil { if account != nil {
completion?(.success(account)) completion?(.success(account))

View File

@ -16,12 +16,10 @@ public final class AccountPreferences: NSManagedObject {
@nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest<AccountPreferences> { @nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest<AccountPreferences> {
let req = NSFetchRequest<AccountPreferences>(entityName: "AccountPreferences") let req = NSFetchRequest<AccountPreferences>(entityName: "AccountPreferences")
req.predicate = NSPredicate(format: "accountID = %@", account.id) req.predicate = NSPredicate(format: "accountID = %@", account.id)
req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
return req return req
} }
@NSManaged public var accountID: String @NSManaged public var accountID: String
@NSManaged var createdAt: Date
@NSManaged var pinnedTimelinesData: Data? @NSManaged var pinnedTimelinesData: Data?
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: []) @LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: [])
@ -30,7 +28,6 @@ public final class AccountPreferences: NSManagedObject {
static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences { static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
let prefs = AccountPreferences(context: context) let prefs = AccountPreferences(context: context)
prefs.accountID = account.id prefs.accountID = account.id
prefs.createdAt = Date()
prefs.pinnedTimelines = [.home, .public(local: true), .public(local: false)] prefs.pinnedTimelines = [.home, .public(local: true), .public(local: false)]
return prefs return prefs
} }

View File

@ -153,7 +153,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
loadPersistentStores { (description, error) in loadPersistentStores { (description, error) in
if let error = error { if let error = error {
logger.error("Unable to load persistent store: \(String(describing: error), privacy: .public)") logger.error("Unable to load persistent store: \(String(describing: error), privacy: .public)")
fatalError("Unable to load persistent store: \(String(describing: error))") fatalError("Unable to load persistent store")
} }
if description.configuration == "Cloud" { if description.configuration == "Cloud" {
@ -256,19 +256,18 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
return statusMO return statusMO
} }
func addAll(statuses: [Status], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) { func addAll(statuses: [Status], completion: (() -> Void)? = nil) {
let context = context ?? backgroundContext backgroundContext.perform {
context.perform { statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
statuses.forEach { self.upsert(status: $0, context: context) } self.save(context: self.backgroundContext)
self.save(context: context)
statuses.forEach { self.statusSubject.send($0.id) } statuses.forEach { self.statusSubject.send($0.id) }
completion?() completion?()
} }
} }
func addAll(statuses: [Status], in context: NSManagedObjectContext? = nil) async { func addAll(statuses: [Status]) async {
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
addAll(statuses: statuses, in: context) { addAll(statuses: statuses) {
continuation.resume() continuation.resume()
} }
} }
@ -507,11 +506,10 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken) let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken)
self.backgroundContext.performAndWait { self.backgroundContext.performAndWait {
if let result = try? self.backgroundContext.execute(req) as? NSPersistentHistoryResult, if let result = try? self.backgroundContext.execute(req) as? NSPersistentHistoryResult,
let transactions = result.result as? [NSPersistentHistoryTransaction], let transactions = result.result as? [NSPersistentHistoryTransaction] {
!transactions.isEmpty {
var changedHashtags = false var changedHashtags = false
var changedInstances = false var changedInstances = false
var changedTimelinePositions = Set<NSManagedObjectID>() var changedTimelinePositions: [NSManagedObjectID] = []
var changedAccountPrefs = false var changedAccountPrefs = false
outer: for transaction in transactions { outer: for transaction in transactions {
for change in transaction.changes ?? [] { for change in transaction.changes ?? [] {
@ -520,7 +518,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
} else if change.changedObjectID.entity.name == "SavedInstance" { } else if change.changedObjectID.entity.name == "SavedInstance" {
changedInstances = true changedInstances = true
} else if change.changedObjectID.entity.name == "TimelinePosition" { } else if change.changedObjectID.entity.name == "TimelinePosition" {
changedTimelinePositions.insert(change.changedObjectID) changedTimelinePositions.append(change.changedObjectID)
} else if change.changedObjectID.entity.name == "AccountPreferences" { } else if change.changedObjectID.entity.name == "AccountPreferences" {
changedAccountPrefs = true changedAccountPrefs = true
} }

View File

@ -16,12 +16,10 @@ public final class TimelinePosition: NSManagedObject {
@nonobjc class func fetchRequest(timeline: Timeline, account: LocalData.UserAccountInfo) -> NSFetchRequest<TimelinePosition> { @nonobjc class func fetchRequest(timeline: Timeline, account: LocalData.UserAccountInfo) -> NSFetchRequest<TimelinePosition> {
let req = NSFetchRequest<TimelinePosition>(entityName: "TimelinePosition") let req = NSFetchRequest<TimelinePosition>(entityName: "TimelinePosition")
req.predicate = NSPredicate(format: "accountID = %@ AND timelineKind = %@", account.id, toTimelineKind(timeline)) req.predicate = NSPredicate(format: "accountID = %@ AND timelineKind = %@", account.id, toTimelineKind(timeline))
req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
return req return req
} }
@NSManaged public var accountID: String @NSManaged public var accountID: String
@NSManaged public var createdAt: Date
@NSManaged private var timelineKind: String @NSManaged private var timelineKind: String
@NSManaged public var centerStatusID: String? @NSManaged public var centerStatusID: String?
@NSManaged private var statusIDsData: Data? @NSManaged private var statusIDsData: Data?
@ -38,7 +36,6 @@ public final class TimelinePosition: NSManagedObject {
self.init(context: context) self.init(context: context)
self.timeline = timeline self.timeline = timeline
self.accountID = account.id self.accountID = account.id
self.createdAt = Date()
} }
} }

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22C65" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22A380" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES"> <entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/> <attribute name="acct" attributeType="String"/>
<attribute name="avatar" optional="YES" attributeType="URI"/> <attribute name="avatar" optional="YES" attributeType="URI"/>
@ -30,7 +30,6 @@
</entity> </entity>
<entity name="AccountPreferences" representedClassName="AccountPreferences" syncable="YES"> <entity name="AccountPreferences" representedClassName="AccountPreferences" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/> <attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/> <attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/>
</entity> </entity>
<entity name="Filter" representedClassName="FilterMO" syncable="YES"> <entity name="Filter" representedClassName="FilterMO" syncable="YES">
@ -121,7 +120,6 @@
<entity name="TimelinePosition" representedClassName="TimelinePosition" syncable="YES"> <entity name="TimelinePosition" representedClassName="TimelinePosition" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/> <attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="centerStatusID" optional="YES" attributeType="String"/> <attribute name="centerStatusID" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="statusIDsData" optional="YES" attributeType="Binary" valueTransformerName="TimelinePositionStatusIDsTransformer"/> <attribute name="statusIDsData" optional="YES" attributeType="Binary" valueTransformerName="TimelinePositionStatusIDsTransformer"/>
<attribute name="timelineKind" optional="YES" attributeType="String"/> <attribute name="timelineKind" optional="YES" attributeType="String"/>
</entity> </entity>

View File

@ -8,31 +8,16 @@
import Foundation import Foundation
extension Array {
func uniques<ID: Hashable>(by identify: (Element) -> ID) -> [Element] {
var uniques = Set<Hashed<Element, ID>>()
for elem in self {
uniques.insert(Hashed(element: elem, id: identify(elem)))
}
return uniques.map(\.element)
}
}
extension Array where Element: Hashable { extension Array where Element: Hashable {
func uniques() -> [Element] { func uniques() -> [Element] {
return uniques(by: { $0 }) var buffer = [Element]()
} var added = Set<Element>()
} for elem in self {
if !added.contains(elem) {
fileprivate struct Hashed<Element, ID: Hashable>: Hashable { buffer.append(elem)
let element: Element added.insert(elem)
let id: ID }
}
static func ==(lhs: Self, rhs: Self) -> Bool { return buffer
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
} }
} }

View File

@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
@ -58,7 +56,7 @@
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Post videos from the camera.</string> <string>Post videos from the camera.</string>
<key>NSPhotoLibraryAddUsageDescription</key> <key>NSPhotoLibraryAddUsageDescription</key>
<string>Save photos directly from other people&apos;s posts.</string> <string>Save photos directly from other people's posts.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Post photos from the photo library.</string> <string>Post photos from the photo library.</string>
<key>NSUserActivityTypes</key> <key>NSUserActivityTypes</key>

View File

@ -206,7 +206,6 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
for followed in followed where !saved.contains(where: { $0.name == followed.name }) { for followed in followed where !saved.contains(where: { $0.name == followed.name }) {
items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url))) items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url)))
} }
items = items.uniques()
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.label)) items.sort(using: SemiCaseSensitiveComparator.keyPath(\.label))
return items return items
} }
@ -216,7 +215,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!) let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!)
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)] req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
do { do {
return try mastodonController.persistentContainer.viewContext.fetch(req).uniques(by: \.url) return try mastodonController.persistentContainer.viewContext.fetch(req)
} catch { } catch {
return [] return []
} }

View File

@ -240,7 +240,6 @@ class MainSidebarViewController: UIViewController {
for followed in followed where !saved.contains(where: { $0.name == followed.name }) { for followed in followed where !saved.contains(where: { $0.name == followed.name }) {
items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url))) items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url)))
} }
items = items.uniques()
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.title)) items.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
return items return items
} }
@ -250,7 +249,7 @@ class MainSidebarViewController: UIViewController {
let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!) let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!)
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)] req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
do { do {
return try mastodonController.persistentContainer.viewContext.fetch(req).uniques(by: \.url) return try mastodonController.persistentContainer.viewContext.fetch(req)
} catch { } catch {
return [] return []
} }

View File

@ -8,18 +8,15 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import CoreData import CoreData
import CloudKit
struct AdvancedPrefsView : View { struct AdvancedPrefsView : View {
@ObservedObject var preferences = Preferences.shared @ObservedObject var preferences = Preferences.shared
@State private var imageCacheSize: Int64 = 0 @State private var imageCacheSize: Int64 = 0
@State private var mastodonCacheSize: Int64 = 0 @State private var mastodonCacheSize: Int64 = 0
@State private var cloudKitStatus: CKAccountStatus?
var body: some View { var body: some View {
List { List {
formattingSection formattingSection
cloudKitSection
errorReportingSection errorReportingSection
cachingSection cachingSection
} }
@ -52,39 +49,6 @@ struct AdvancedPrefsView : View {
} }
} }
var cloudKitSection: some View {
Section {
HStack {
Text("iCloud Status")
Spacer()
switch cloudKitStatus {
case nil:
EmptyView()
case .available:
Text("Available")
case .couldNotDetermine:
Text("Could not determine")
case .noAccount:
Text("No account")
case .restricted:
Text("Restricted")
case .temporarilyUnavailable:
Text("Temporarily Unavailable")
@unknown default:
Text(String(describing: cloudKitStatus!))
}
}
}.task {
CKContainer.default().accountStatus { status, error in
if let error {
Logging.general.error("Unable to get CloudKit status: \(String(describing: error))")
} else {
self.cloudKitStatus = status
}
}
}
}
var errorReportingSection: some View { var errorReportingSection: some View {
Section { Section {
Toggle("Report Errors Automatically", isOn: $preferences.reportErrorsAutomatically) Toggle("Report Errors Automatically", isOn: $preferences.reportErrorsAutomatically)

View File

@ -103,7 +103,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
.sink { [unowned self] id in .sink { [unowned self] id in
switch state { switch state {
case .unloaded: case .unloaded:
Task { [self] in Task {
await self.load() await self.load()
} }
case .loaded, .setupInitialSnapshot: case .loaded, .setupInitialSnapshot:

View File

@ -213,10 +213,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
clearSelectionOnAppear(animated: animated) clearSelectionOnAppear(animated: animated)
if case .notLoadedInitial = controller.state { if case .notLoadedInitial = controller.state {
Task { if restoreState() {
if await restoreState() { Task {
await checkPresent(jumpImmediately: false) await checkPresent(jumpImmediately: false)
} else { }
} else {
Task {
await controller.loadInitial() await controller.loadInitial()
} }
} }
@ -327,16 +329,18 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return activity return activity
} }
func restoreState() async -> Bool { func restoreState() -> Bool {
guard persistsState, guard persistsState,
Preferences.shared.timelineStateRestoration, Preferences.shared.timelineStateRestoration,
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else { let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
return false return false
} }
loadViewIfNeeded() loadViewIfNeeded()
await controller.restoreInitial { Task {
await loadStatusesToRestore(position: position) await controller.restoreInitial {
applyItemsToRestore(position: position) await loadStatusesToRestore(position: position)
applyItemsToRestore(position: position)
}
} }
return true return true
} }
@ -363,19 +367,17 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
} }
await mastodonController.persistentContainer.addAll(statuses: statuses, in: mastodonController.persistentContainer.viewContext) await mastodonController.persistentContainer.addAll(statuses: statuses)
} }
private func applyItemsToRestore(position: TimelinePosition) { private func applyItemsToRestore(position: TimelinePosition) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses]) snapshot.appendSections([.statuses])
let statusIDs = position.statusIDs
let centerStatusID = position.centerStatusID
let items = position.statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) } let items = position.statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
snapshot.appendItems(items, toSection: .statuses) snapshot.appendItems(items, toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false) { dataSource.apply(snapshot, animatingDifferences: false) {
if let centerStatusID, if let centerID = position.centerStatusID,
let index = statusIDs.firstIndex(of: centerStatusID), let index = position.statusIDs.firstIndex(of: centerID),
let indexPath = self.dataSource.indexPath(for: items[index]) { let indexPath = self.dataSource.indexPath(for: items[index]) {
// it sometimes takes multiple attempts to convert on the right scroll position // it sometimes takes multiple attempts to convert on the right scroll position
// since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop // since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop
@ -390,7 +392,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
break break
} }
} }
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerStatusID)") stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerID)")
} else { } else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID") stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
} }
@ -488,29 +490,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return false return false
} }
if !alwaysPrompt { if !alwaysPrompt {
Task { _ = self.restoreState()
_ = await restoreState()
}
} else { } else {
var config = ToastConfiguration(title: "Sync Position") var config = ToastConfiguration(title: "Sync Position")
config.edge = .top config.edge = .top
config.dismissAutomaticallyAfter = 5 config.dismissAutomaticallyAfter = 5
config.systemImageName = "arrow.triangle.2.circlepath" config.systemImageName = "arrow.triangle.2.circlepath"
config.action = { [unowned self] toast in config.action = { [unowned self] toast in
toast.isUserInteractionEnabled = false toast.dismissToast(animated: true)
UIView.animateKeyframes(withDuration: 1, delay: 0, options: .repeat) { _ = self.restoreState()
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
toast.imageView!.transform = CGAffineTransform(rotationAngle: 0.5 * .pi)
}
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
// the translation is because the symbol isn't perfectly centered
toast.imageView!.transform = CGAffineTransform(translationX: -0.5, y: 0).rotated(by: .pi)
}
}
Task {
_ = await self.restoreState()
toast.dismissToast(animated: true)
}
} }
showToast(configuration: config, animated: true) showToast(configuration: config, animated: true)
UIAccessibility.post(notification: .announcement, argument: "Synced Position Updated") UIAccessibility.post(notification: .announcement, argument: "Synced Position Updated")

View File

@ -59,9 +59,7 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
guard let vc = currentViewController as? TimelineViewController else { guard let vc = currentViewController as? TimelineViewController else {
return false return false
} }
Task { _ = vc.restoreState()
_ = await vc.restoreState()
}
return true return true
}), }),
] ]
@ -87,7 +85,7 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
} }
func stateRestorationActivity() -> NSUserActivity? { func stateRestorationActivity() -> NSUserActivity? {
return (currentViewController as? TimelineViewController)?.stateRestorationActivity() return (currentViewController as! TimelineViewController).stateRestorationActivity()
} }
func restoreActivity(_ activity: NSUserActivity) { func restoreActivity(_ activity: NSUserActivity) {

View File

@ -23,8 +23,8 @@ class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIPage
var currentIndex: Int! { var currentIndex: Int! {
pages.firstIndex(of: currentPage) pages.firstIndex(of: currentPage)
} }
var currentViewController: UIViewController! { var currentViewController: UIViewController {
viewControllers?.first viewControllers!.first!
} }
let segmentedControl = ScrollingSegmentedControl<Page>() let segmentedControl = ScrollingSegmentedControl<Page>()

View File

@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.aps-environment</key> <key>com.apple.developer.aps-environment</key>

View File

@ -59,7 +59,6 @@ extension ToastConfiguration {
switch error.type { switch error.type {
case .invalidRequest, .invalidResponse, .invalidModel(_), .mastodonError(_): case .invalidRequest, .invalidResponse, .invalidModel(_), .mastodonError(_):
SentrySDK.capture(error: error) { scope in SentrySDK.capture(error: error) { scope in
scope.setFingerprint([String(describing: error)])
let crumb = Breadcrumb(level: .error, category: "error") let crumb = Breadcrumb(level: .error, category: "error")
crumb.message = title crumb.message = title
crumb.data = [ crumb.data = [

View File

@ -12,8 +12,6 @@ class ToastView: UIView {
let configuration: ToastConfiguration let configuration: ToastConfiguration
private(set) var imageView: UIImageView?
private var panRecognizer: UIPanGestureRecognizer! private var panRecognizer: UIPanGestureRecognizer!
private var shrinkAnimator: UIViewPropertyAnimator? private var shrinkAnimator: UIViewPropertyAnimator?
private var recognizedGesture = false private var recognizedGesture = false
@ -53,8 +51,6 @@ class ToastView: UIView {
layer.shadowOpacity = 0.5 layer.shadowOpacity = 0.5
layer.masksToBounds = false layer.masksToBounds = false
addInteraction(UIPointerInteraction(delegate: self))
let stack = UIStackView() let stack = UIStackView()
stack.translatesAutoresizingMaskIntoConstraints = false stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .horizontal stack.axis = .horizontal
@ -65,7 +61,6 @@ class ToastView: UIView {
imageView.tintColor = .white imageView.tintColor = .white
imageView.contentMode = .scaleAspectFit imageView.contentMode = .scaleAspectFit
stack.addArrangedSubview(imageView) stack.addArrangedSubview(imageView)
self.imageView = imageView
} }
let titleLabel = UILabel() let titleLabel = UILabel()
@ -284,21 +279,3 @@ extension ToastView: UIGestureRecognizerDelegate {
return true return true
} }
} }
extension ToastView: UIPointerInteractionDelegate {
func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
return defaultRegion
}
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
return UIPointerStyle(effect: .highlight(UITargetedPreview(view: self)))
}
func pointerInteraction(_ interaction: UIPointerInteraction, willEnter region: UIPointerRegion, animator: UIPointerInteractionAnimating) {
shouldDismissAutomatically = false
}
func pointerInteraction(_ interaction: UIPointerInteraction, willExit region: UIPointerRegion, animator: UIPointerInteractionAnimating) {
shouldDismissAutomatically = true
}
}

View File

@ -1,45 +0,0 @@
//
// ArrayUniqueTests.swift
// TuskerTests
//
// Created by Shadowfacts on 1/1/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import XCTest
@testable import Tusker
final class ArrayUniqueTests: XCTestCase {
func testUniquesBy() {
let a = Test(string: "test")
let b = Test(string: "test")
XCTAssertNotEqual(a.id, b.id)
XCTAssertNotEqual(a.hashValue, b.hashValue)
XCTAssertEqual([a, b].uniques(by: \.string), [a])
}
class Test: NSObject {
let id = UUID()
let string: String
init(string: String) {
self.string = string
}
override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? Self else {
return false
}
return id == other.id && string == other.string
}
override var hash: Int {
var hasher = Hasher()
hasher.combine(id)
hasher.combine(string)
return hasher.finalize()
}
}
}