Compare commits

...

14 Commits

Author SHA1 Message Date
Shadowfacts 3ea1ad5622 Bump build number and update changelog 2023-01-01 15:28:55 -05:00
Shadowfacts 5898da3234 Maybe fix race condition when account is loaded as profile statuses VC is dealloc'd 2023-01-01 15:27:25 -05:00
Shadowfacts 9dd966f639 Fix duplicate saved instances not being uniqued correctly 2023-01-01 15:27:25 -05:00
Shadowfacts 48662ef1f3 Bump build number and update changelog 2023-01-01 15:12:21 -05:00
Shadowfacts 854d48e54e Unique saved hashtag/instance items
This may happen when migrating to iCloud, if the same hashtag is saved
on multiple devices.
2023-01-01 14:49:04 -05:00
Shadowfacts d4c560d7fc Add createdAt to AccountPreferences and TimelinePosition to guard against race conditions when creating/migrating 2023-01-01 12:58:44 -05:00
Shadowfacts 91b7ce3008 Add pointer interaction to ToastView 2023-01-01 12:35:40 -05:00
Shadowfacts 4dca231a06 Add loading animation while syncing timeline position 2023-01-01 12:25:44 -05:00
Shadowfacts b81c83a250 Add iCloud env entitlement and ITSAppUsesNonExemptEncryption 2022-12-31 16:58:39 -05:00
Shadowfacts f9e619d9e7 Deduplicate updated timeline positions when handling remote changes 2022-12-31 16:58:20 -05:00
Shadowfacts ae7962ae50 Better Sentry messages 2022-12-31 16:57:43 -05:00
Shadowfacts 5027660b52 Maybe fix crash when restoring unloaded statuses due to race condition 2022-12-31 16:57:13 -05:00
Shadowfacts 358d81b5cf Fix crash when accessing SegmentedPageViewController before it's loaded 2022-12-31 16:46:00 -05:00
Shadowfacts 79b9108a8f Add CloudKit status indicator to advanced prefs 2022-12-31 11:24:42 -05:00
20 changed files with 251 additions and 58 deletions

View File

@ -1,5 +1,32 @@
# 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,6 +288,7 @@
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 */; };
@ -672,6 +673,7 @@
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>"; };
@ -1485,6 +1487,7 @@
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;
@ -2136,6 +2139,7 @@
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;
@ -2278,7 +2282,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 = 53; CURRENT_PROJECT_VERSION = 59;
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 = (
@ -2345,7 +2349,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 = 53; CURRENT_PROJECT_VERSION = 59;
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;
@ -2496,7 +2500,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 = 53; CURRENT_PROJECT_VERSION = 59;
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 = (
@ -2524,7 +2528,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 = 53; CURRENT_PROJECT_VERSION = 59;
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 = (
@ -2633,7 +2637,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 = 53; CURRENT_PROJECT_VERSION = 59;
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;
@ -2659,7 +2663,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 = 53; CURRENT_PROJECT_VERSION = 59;
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,12 +155,14 @@ class MastodonController: ObservableObject {
// are available when Filterers are constructed // are available when Filterers are constructed
loadCachedFilters() loadCachedFilters()
if let existing = try? persistentContainer.viewContext.fetch(AccountPreferences.fetchRequest(account: accountInfo!)).first { loadAccountPreferences()
accountPreferences = existing
} else { NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator)
accountPreferences = AccountPreferences.default(account: accountInfo!, context: persistentContainer.viewContext) .receive(on: DispatchQueue.main)
persistentContainer.save(context: persistentContainer.viewContext) .sink { [unowned self] _ in
} self.loadAccountPreferences()
}
.store(in: &cancellables)
Task { Task {
do { do {
@ -177,6 +179,16 @@ 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,10 +16,12 @@ 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: [])
@ -28,6 +30,7 @@ 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") fatalError("Unable to load persistent store: \(String(describing: error))")
} }
if description.configuration == "Cloud" { if description.configuration == "Cloud" {
@ -256,18 +256,19 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
return statusMO return statusMO
} }
func addAll(statuses: [Status], completion: (() -> Void)? = nil) { func addAll(statuses: [Status], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) {
backgroundContext.perform { let context = context ?? backgroundContext
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) } context.perform {
self.save(context: self.backgroundContext) statuses.forEach { self.upsert(status: $0, context: context) }
self.save(context: context)
statuses.forEach { self.statusSubject.send($0.id) } statuses.forEach { self.statusSubject.send($0.id) }
completion?() completion?()
} }
} }
func addAll(statuses: [Status]) async { func addAll(statuses: [Status], in context: NSManagedObjectContext? = nil) async {
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
addAll(statuses: statuses) { addAll(statuses: statuses, in: context) {
continuation.resume() continuation.resume()
} }
} }
@ -506,10 +507,11 @@ 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: [NSManagedObjectID] = [] var changedTimelinePositions = Set<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 ?? [] {
@ -518,7 +520,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.append(change.changedObjectID) changedTimelinePositions.insert(change.changedObjectID)
} else if change.changedObjectID.entity.name == "AccountPreferences" { } else if change.changedObjectID.entity.name == "AccountPreferences" {
changedAccountPrefs = true changedAccountPrefs = true
} }

View File

@ -16,10 +16,12 @@ 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?
@ -36,6 +38,7 @@ 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="22A380" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22C65" 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,6 +30,7 @@
</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">
@ -120,6 +121,7 @@
<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,16 +8,31 @@
import Foundation import Foundation
extension Array where Element: Hashable { extension Array {
func uniques() -> [Element] { func uniques<ID: Hashable>(by identify: (Element) -> ID) -> [Element] {
var buffer = [Element]() var uniques = Set<Hashed<Element, ID>>()
var added = Set<Element>()
for elem in self { for elem in self {
if !added.contains(elem) { uniques.insert(Hashed(element: elem, id: identify(elem)))
buffer.append(elem)
added.insert(elem)
}
} }
return buffer return uniques.map(\.element)
}
}
extension Array where Element: Hashable {
func uniques() -> [Element] {
return uniques(by: { $0 })
}
}
fileprivate struct Hashed<Element, ID: Hashable>: Hashable {
let element: Element
let id: ID
static func ==(lhs: Self, rhs: Self) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
} }
} }

View File

@ -2,6 +2,8 @@
<!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>
@ -56,7 +58,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's posts.</string> <string>Save photos directly from other people&apos;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,6 +206,7 @@ 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
} }
@ -215,7 +216,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) return try mastodonController.persistentContainer.viewContext.fetch(req).uniques(by: \.url)
} catch { } catch {
return [] return []
} }

View File

@ -240,6 +240,7 @@ 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
} }
@ -249,7 +250,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) return try mastodonController.persistentContainer.viewContext.fetch(req).uniques(by: \.url)
} catch { } catch {
return [] return []
} }

View File

@ -8,15 +8,18 @@
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
} }
@ -49,6 +52,39 @@ 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 { Task { [self] in
await self.load() await self.load()
} }
case .loaded, .setupInitialSnapshot: case .loaded, .setupInitialSnapshot:

View File

@ -213,12 +213,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
clearSelectionOnAppear(animated: animated) clearSelectionOnAppear(animated: animated)
if case .notLoadedInitial = controller.state { if case .notLoadedInitial = controller.state {
if restoreState() { Task {
Task { if await restoreState() {
await checkPresent(jumpImmediately: false) await checkPresent(jumpImmediately: false)
} } else {
} else {
Task {
await controller.loadInitial() await controller.loadInitial()
} }
} }
@ -329,18 +327,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return activity return activity
} }
func restoreState() -> Bool { func restoreState() async -> 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()
Task { await controller.restoreInitial {
await controller.restoreInitial { await loadStatusesToRestore(position: position)
await loadStatusesToRestore(position: position) applyItemsToRestore(position: position)
applyItemsToRestore(position: position)
}
} }
return true return true
} }
@ -367,17 +363,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
} }
await mastodonController.persistentContainer.addAll(statuses: statuses) await mastodonController.persistentContainer.addAll(statuses: statuses, in: mastodonController.persistentContainer.viewContext)
} }
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 centerID = position.centerStatusID, if let centerStatusID,
let index = position.statusIDs.firstIndex(of: centerID), let index = statusIDs.firstIndex(of: centerStatusID),
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
@ -392,7 +390,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
break break
} }
} }
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerID)") stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerStatusID)")
} else { } else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID") stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
} }
@ -490,15 +488,29 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return false return false
} }
if !alwaysPrompt { if !alwaysPrompt {
_ = self.restoreState() Task {
_ = 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.dismissToast(animated: true) toast.isUserInteractionEnabled = false
_ = self.restoreState() UIView.animateKeyframes(withDuration: 1, delay: 0, options: .repeat) {
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,7 +59,9 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
guard let vc = currentViewController as? TimelineViewController else { guard let vc = currentViewController as? TimelineViewController else {
return false return false
} }
_ = vc.restoreState() Task {
_ = await vc.restoreState()
}
return true return true
}), }),
] ]
@ -85,7 +87,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,6 +2,8 @@
<!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,6 +59,7 @@ 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,6 +12,8 @@ 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
@ -51,6 +53,8 @@ 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
@ -61,6 +65,7 @@ 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()
@ -279,3 +284,21 @@ 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

@ -0,0 +1,45 @@
//
// 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()
}
}
}