Compare commits
14 Commits
5ab22e742b
...
3ea1ad5622
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 3ea1ad5622 | |
Shadowfacts | 5898da3234 | |
Shadowfacts | 9dd966f639 | |
Shadowfacts | 48662ef1f3 | |
Shadowfacts | 854d48e54e | |
Shadowfacts | d4c560d7fc | |
Shadowfacts | 91b7ce3008 | |
Shadowfacts | 4dca231a06 | |
Shadowfacts | b81c83a250 | |
Shadowfacts | f9e619d9e7 | |
Shadowfacts | ae7962ae50 | |
Shadowfacts | 5027660b52 | |
Shadowfacts | 358d81b5cf | |
Shadowfacts | 79b9108a8f |
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -1,5 +1,32 @@
|
|||
# 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)
|
||||
Features/Improvements:
|
||||
- Apply filters to Trending Posts
|
||||
|
|
|
@ -288,6 +288,7 @@
|
|||
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
|
||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.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 */; };
|
||||
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1485,6 +1487,7 @@
|
|||
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
|
||||
D6114E1627F8BB210080E273 /* VersionTests.swift */,
|
||||
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */,
|
||||
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */,
|
||||
D6D4DDE6212518A200E1C4BB /* Info.plist */,
|
||||
);
|
||||
path = TuskerTests;
|
||||
|
@ -2136,6 +2139,7 @@
|
|||
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
|
||||
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
|
||||
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */,
|
||||
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */,
|
||||
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -2278,7 +2282,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 53;
|
||||
CURRENT_PROJECT_VERSION = 59;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -2345,7 +2349,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 53;
|
||||
CURRENT_PROJECT_VERSION = 59;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||
|
@ -2496,7 +2500,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 53;
|
||||
CURRENT_PROJECT_VERSION = 59;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -2524,7 +2528,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 53;
|
||||
CURRENT_PROJECT_VERSION = 59;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -2633,7 +2637,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 53;
|
||||
CURRENT_PROJECT_VERSION = 59;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||
|
@ -2659,7 +2663,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 53;
|
||||
CURRENT_PROJECT_VERSION = 59;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||
|
|
|
@ -155,12 +155,14 @@ class MastodonController: ObservableObject {
|
|||
// are available when Filterers are constructed
|
||||
loadCachedFilters()
|
||||
|
||||
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)
|
||||
loadAccountPreferences()
|
||||
|
||||
NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [unowned self] _ in
|
||||
self.loadAccountPreferences()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Task {
|
||||
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) {
|
||||
if account != nil {
|
||||
completion?(.success(account))
|
||||
|
|
|
@ -16,10 +16,12 @@ public final class AccountPreferences: NSManagedObject {
|
|||
@nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest<AccountPreferences> {
|
||||
let req = NSFetchRequest<AccountPreferences>(entityName: "AccountPreferences")
|
||||
req.predicate = NSPredicate(format: "accountID = %@", account.id)
|
||||
req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
|
||||
return req
|
||||
}
|
||||
|
||||
@NSManaged public var accountID: String
|
||||
@NSManaged var createdAt: Date
|
||||
@NSManaged var pinnedTimelinesData: Data?
|
||||
|
||||
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: [])
|
||||
|
@ -28,6 +30,7 @@ public final class AccountPreferences: NSManagedObject {
|
|||
static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
|
||||
let prefs = AccountPreferences(context: context)
|
||||
prefs.accountID = account.id
|
||||
prefs.createdAt = Date()
|
||||
prefs.pinnedTimelines = [.home, .public(local: true), .public(local: false)]
|
||||
return prefs
|
||||
}
|
||||
|
|
|
@ -153,7 +153,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
loadPersistentStores { (description, error) in
|
||||
if let error = error {
|
||||
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" {
|
||||
|
@ -256,18 +256,19 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
return statusMO
|
||||
}
|
||||
|
||||
func addAll(statuses: [Status], completion: (() -> Void)? = nil) {
|
||||
backgroundContext.perform {
|
||||
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
|
||||
self.save(context: self.backgroundContext)
|
||||
func addAll(statuses: [Status], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) {
|
||||
let context = context ?? backgroundContext
|
||||
context.perform {
|
||||
statuses.forEach { self.upsert(status: $0, context: context) }
|
||||
self.save(context: context)
|
||||
statuses.forEach { self.statusSubject.send($0.id) }
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
func addAll(statuses: [Status]) async {
|
||||
func addAll(statuses: [Status], in context: NSManagedObjectContext? = nil) async {
|
||||
return await withCheckedContinuation { continuation in
|
||||
addAll(statuses: statuses) {
|
||||
addAll(statuses: statuses, in: context) {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
@ -506,10 +507,11 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken)
|
||||
self.backgroundContext.performAndWait {
|
||||
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 changedInstances = false
|
||||
var changedTimelinePositions: [NSManagedObjectID] = []
|
||||
var changedTimelinePositions = Set<NSManagedObjectID>()
|
||||
var changedAccountPrefs = false
|
||||
outer: for transaction in transactions {
|
||||
for change in transaction.changes ?? [] {
|
||||
|
@ -518,7 +520,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
} else if change.changedObjectID.entity.name == "SavedInstance" {
|
||||
changedInstances = true
|
||||
} else if change.changedObjectID.entity.name == "TimelinePosition" {
|
||||
changedTimelinePositions.append(change.changedObjectID)
|
||||
changedTimelinePositions.insert(change.changedObjectID)
|
||||
} else if change.changedObjectID.entity.name == "AccountPreferences" {
|
||||
changedAccountPrefs = true
|
||||
}
|
||||
|
|
|
@ -16,10 +16,12 @@ public final class TimelinePosition: NSManagedObject {
|
|||
@nonobjc class func fetchRequest(timeline: Timeline, account: LocalData.UserAccountInfo) -> NSFetchRequest<TimelinePosition> {
|
||||
let req = NSFetchRequest<TimelinePosition>(entityName: "TimelinePosition")
|
||||
req.predicate = NSPredicate(format: "accountID = %@ AND timelineKind = %@", account.id, toTimelineKind(timeline))
|
||||
req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
|
||||
return req
|
||||
}
|
||||
|
||||
@NSManaged public var accountID: String
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged private var timelineKind: String
|
||||
@NSManaged public var centerStatusID: String?
|
||||
@NSManaged private var statusIDsData: Data?
|
||||
|
@ -36,6 +38,7 @@ public final class TimelinePosition: NSManagedObject {
|
|||
self.init(context: context)
|
||||
self.timeline = timeline
|
||||
self.accountID = account.id
|
||||
self.createdAt = Date()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<?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">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
<attribute name="avatar" optional="YES" attributeType="URI"/>
|
||||
|
@ -30,6 +30,7 @@
|
|||
</entity>
|
||||
<entity name="AccountPreferences" representedClassName="AccountPreferences" syncable="YES">
|
||||
<attribute name="accountID" optional="YES" attributeType="String"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/>
|
||||
</entity>
|
||||
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
|
||||
|
@ -120,6 +121,7 @@
|
|||
<entity name="TimelinePosition" representedClassName="TimelinePosition" syncable="YES">
|
||||
<attribute name="accountID" 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="timelineKind" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
|
|
|
@ -8,16 +8,31 @@
|
|||
|
||||
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 {
|
||||
func uniques() -> [Element] {
|
||||
var buffer = [Element]()
|
||||
var added = Set<Element>()
|
||||
for elem in self {
|
||||
if !added.contains(elem) {
|
||||
buffer.append(elem)
|
||||
added.insert(elem)
|
||||
return uniques(by: { $0 })
|
||||
}
|
||||
}
|
||||
return buffer
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
|
@ -56,7 +58,7 @@
|
|||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Post videos from the camera.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Save photos directly from other people's posts.</string>
|
||||
<string>Save photos directly from other people's posts.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Post photos from the photo library.</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
|
|
|
@ -206,6 +206,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
|
|||
for followed in followed where !saved.contains(where: { $0.name == followed.name }) {
|
||||
items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url)))
|
||||
}
|
||||
items = items.uniques()
|
||||
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.label))
|
||||
return items
|
||||
}
|
||||
|
@ -215,7 +216,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
|
|||
let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!)
|
||||
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
|
||||
do {
|
||||
return try mastodonController.persistentContainer.viewContext.fetch(req)
|
||||
return try mastodonController.persistentContainer.viewContext.fetch(req).uniques(by: \.url)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
|
|
@ -240,6 +240,7 @@ class MainSidebarViewController: UIViewController {
|
|||
for followed in followed where !saved.contains(where: { $0.name == followed.name }) {
|
||||
items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url)))
|
||||
}
|
||||
items = items.uniques()
|
||||
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
||||
return items
|
||||
}
|
||||
|
@ -249,7 +250,7 @@ class MainSidebarViewController: UIViewController {
|
|||
let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!)
|
||||
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
|
||||
do {
|
||||
return try mastodonController.persistentContainer.viewContext.fetch(req)
|
||||
return try mastodonController.persistentContainer.viewContext.fetch(req).uniques(by: \.url)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
|
|
@ -8,15 +8,18 @@
|
|||
import SwiftUI
|
||||
import Pachyderm
|
||||
import CoreData
|
||||
import CloudKit
|
||||
|
||||
struct AdvancedPrefsView : View {
|
||||
@ObservedObject var preferences = Preferences.shared
|
||||
@State private var imageCacheSize: Int64 = 0
|
||||
@State private var mastodonCacheSize: Int64 = 0
|
||||
@State private var cloudKitStatus: CKAccountStatus?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
formattingSection
|
||||
cloudKitSection
|
||||
errorReportingSection
|
||||
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 {
|
||||
Section {
|
||||
Toggle("Report Errors Automatically", isOn: $preferences.reportErrorsAutomatically)
|
||||
|
|
|
@ -103,7 +103,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
.sink { [unowned self] id in
|
||||
switch state {
|
||||
case .unloaded:
|
||||
Task {
|
||||
Task { [self] in
|
||||
await self.load()
|
||||
}
|
||||
case .loaded, .setupInitialSnapshot:
|
||||
|
|
|
@ -213,12 +213,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
clearSelectionOnAppear(animated: animated)
|
||||
|
||||
if case .notLoadedInitial = controller.state {
|
||||
if restoreState() {
|
||||
Task {
|
||||
if await restoreState() {
|
||||
await checkPresent(jumpImmediately: false)
|
||||
}
|
||||
} else {
|
||||
Task {
|
||||
await controller.loadInitial()
|
||||
}
|
||||
}
|
||||
|
@ -329,19 +327,17 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
return activity
|
||||
}
|
||||
|
||||
func restoreState() -> Bool {
|
||||
func restoreState() async -> Bool {
|
||||
guard persistsState,
|
||||
Preferences.shared.timelineStateRestoration,
|
||||
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
|
||||
return false
|
||||
}
|
||||
loadViewIfNeeded()
|
||||
Task {
|
||||
await controller.restoreInitial {
|
||||
await loadStatusesToRestore(position: position)
|
||||
applyItemsToRestore(position: position)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.statuses])
|
||||
let statusIDs = position.statusIDs
|
||||
let centerStatusID = position.centerStatusID
|
||||
let items = position.statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
|
||||
snapshot.appendItems(items, toSection: .statuses)
|
||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||
if let centerID = position.centerStatusID,
|
||||
let index = position.statusIDs.firstIndex(of: centerID),
|
||||
if let centerStatusID,
|
||||
let index = statusIDs.firstIndex(of: centerStatusID),
|
||||
let indexPath = self.dataSource.indexPath(for: items[index]) {
|
||||
// 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
|
||||
|
@ -392,7 +390,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
break
|
||||
}
|
||||
}
|
||||
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerID)")
|
||||
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerStatusID)")
|
||||
} else {
|
||||
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
|
||||
}
|
||||
|
@ -490,15 +488,29 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
return false
|
||||
}
|
||||
if !alwaysPrompt {
|
||||
_ = self.restoreState()
|
||||
Task {
|
||||
_ = await restoreState()
|
||||
}
|
||||
} else {
|
||||
var config = ToastConfiguration(title: "Sync Position")
|
||||
config.edge = .top
|
||||
config.dismissAutomaticallyAfter = 5
|
||||
config.systemImageName = "arrow.triangle.2.circlepath"
|
||||
config.action = { [unowned self] toast in
|
||||
toast.isUserInteractionEnabled = false
|
||||
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)
|
||||
_ = self.restoreState()
|
||||
}
|
||||
}
|
||||
showToast(configuration: config, animated: true)
|
||||
UIAccessibility.post(notification: .announcement, argument: "Synced Position Updated")
|
||||
|
|
|
@ -59,7 +59,9 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
|
|||
guard let vc = currentViewController as? TimelineViewController else {
|
||||
return false
|
||||
}
|
||||
_ = vc.restoreState()
|
||||
Task {
|
||||
_ = await vc.restoreState()
|
||||
}
|
||||
return true
|
||||
}),
|
||||
]
|
||||
|
@ -85,7 +87,7 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
|
|||
}
|
||||
|
||||
func stateRestorationActivity() -> NSUserActivity? {
|
||||
return (currentViewController as! TimelineViewController).stateRestorationActivity()
|
||||
return (currentViewController as? TimelineViewController)?.stateRestorationActivity()
|
||||
}
|
||||
|
||||
func restoreActivity(_ activity: NSUserActivity) {
|
||||
|
|
|
@ -23,8 +23,8 @@ class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIPage
|
|||
var currentIndex: Int! {
|
||||
pages.firstIndex(of: currentPage)
|
||||
}
|
||||
var currentViewController: UIViewController {
|
||||
viewControllers!.first!
|
||||
var currentViewController: UIViewController! {
|
||||
viewControllers?.first
|
||||
}
|
||||
|
||||
let segmentedControl = ScrollingSegmentedControl<Page>()
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.icloud-container-environment</key>
|
||||
<string>Production</string>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.aps-environment</key>
|
||||
|
|
|
@ -59,6 +59,7 @@ extension ToastConfiguration {
|
|||
switch error.type {
|
||||
case .invalidRequest, .invalidResponse, .invalidModel(_), .mastodonError(_):
|
||||
SentrySDK.capture(error: error) { scope in
|
||||
scope.setFingerprint([String(describing: error)])
|
||||
let crumb = Breadcrumb(level: .error, category: "error")
|
||||
crumb.message = title
|
||||
crumb.data = [
|
||||
|
|
|
@ -12,6 +12,8 @@ class ToastView: UIView {
|
|||
|
||||
let configuration: ToastConfiguration
|
||||
|
||||
private(set) var imageView: UIImageView?
|
||||
|
||||
private var panRecognizer: UIPanGestureRecognizer!
|
||||
private var shrinkAnimator: UIViewPropertyAnimator?
|
||||
private var recognizedGesture = false
|
||||
|
@ -51,6 +53,8 @@ class ToastView: UIView {
|
|||
layer.shadowOpacity = 0.5
|
||||
layer.masksToBounds = false
|
||||
|
||||
addInteraction(UIPointerInteraction(delegate: self))
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
stack.axis = .horizontal
|
||||
|
@ -61,6 +65,7 @@ class ToastView: UIView {
|
|||
imageView.tintColor = .white
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
stack.addArrangedSubview(imageView)
|
||||
self.imageView = imageView
|
||||
}
|
||||
|
||||
let titleLabel = UILabel()
|
||||
|
@ -279,3 +284,21 @@ extension ToastView: UIGestureRecognizerDelegate {
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue