Compare commits

...

5 Commits

30 changed files with 816 additions and 211 deletions

View File

@ -8,7 +8,7 @@
import Foundation
public enum Timeline: Equatable {
public enum Timeline: Equatable, Hashable {
case home
case `public`(local: Bool)
case tag(hashtag: String)

View File

@ -52,7 +52,7 @@
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; };
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */; };
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */; };
D61F759929384D4D00C0B37F /* FiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759829384D4D00C0B37F /* FiltersView.swift */; };
D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759829384D4D00C0B37F /* CustomizeTimelinesView.swift */; };
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759A29384F9C00C0B37F /* FilterMO.swift */; };
D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759C2938574B00C0B37F /* FilterRow.swift */; };
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */; };
@ -186,6 +186,10 @@
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; };
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */; };
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DE828D962C2006341DA /* TimelineLikeController.swift */; };
D68A76DA29511CA6001DA1B3 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */; };
D68A76E329524D2A001DA1B3 /* ListMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E229524D2A001DA1B3 /* ListMO.swift */; };
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */; };
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */; };
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; };
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
@ -424,7 +428,7 @@
D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = "<group>"; };
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleFollowHashtagService.swift; sourceTree = "<group>"; };
D61F759829384D4D00C0B37F /* FiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersView.swift; sourceTree = "<group>"; };
D61F759829384D4D00C0B37F /* CustomizeTimelinesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeTimelinesView.swift; sourceTree = "<group>"; };
D61F759A29384F9C00C0B37F /* FilterMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMO.swift; sourceTree = "<group>"; };
D61F759C2938574B00C0B37F /* FilterRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterRow.swift; sourceTree = "<group>"; };
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparator.swift; sourceTree = "<group>"; };
@ -560,6 +564,10 @@
D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = "<group>"; };
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmReblogStatusPreviewView.swift; sourceTree = "<group>"; };
D6895DE828D962C2006341DA /* TimelineLikeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeController.swift; sourceTree = "<group>"; };
D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPreferences.swift; sourceTree = "<group>"; };
D68A76E229524D2A001DA1B3 /* ListMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListMO.swift; sourceTree = "<group>"; };
D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimelinesView.swift; sourceTree = "<group>"; };
D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddHashtagPinnedTimelineView.swift; sourceTree = "<group>"; };
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
@ -798,14 +806,16 @@
path = "Instance Cell";
sourceTree = "<group>";
};
D61F759729384D4200C0B37F /* Filters */ = {
D61F759729384D4200C0B37F /* Customize Timelines */ = {
isa = PBXGroup;
children = (
D61F759829384D4D00C0B37F /* FiltersView.swift */,
D61F759829384D4D00C0B37F /* CustomizeTimelinesView.swift */,
D61F759C2938574B00C0B37F /* FilterRow.swift */,
D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */,
D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */,
D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */,
);
path = Filters;
path = "Customize Timelines";
sourceTree = "<group>";
};
D623A53B2635F4E20095BD04 /* Poll */ = {
@ -895,6 +905,8 @@
D61F759A29384F9C00C0B37F /* FilterMO.swift */,
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
D6D706A62948D4D0000827ED /* TimlineState.swift */,
D68A76E229524D2A001DA1B3 /* ListMO.swift */,
D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
);
@ -924,7 +936,7 @@
D6F2E960249E772F005846BB /* Crash Reporter */,
D627943C23A5635D00D38C68 /* Explore */,
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
D61F759729384D4200C0B37F /* Filters */,
D61F759729384D4200C0B37F /* Customize Timelines */,
D641C788213DD86D004B4513 /* Large Image */,
D627944B23A9A02400D38C68 /* Lists */,
D641C782213DD7F0004B4513 /* Main */,
@ -1871,6 +1883,7 @@
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D68A76E329524D2A001DA1B3 /* ListMO.swift in Sources */,
D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */,
D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */,
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
@ -1979,6 +1992,7 @@
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */,
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */,
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */,
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
@ -2006,7 +2020,7 @@
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
D61F759929384D4D00C0B37F /* FiltersView.swift in Sources */,
D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */,
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */,
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
@ -2040,6 +2054,7 @@
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */,
D68A76DA29511CA6001DA1B3 /* AccountPreferences.swift in Sources */,
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */,
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
@ -2073,6 +2088,7 @@
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */,
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,

View File

@ -40,6 +40,7 @@ class MastodonController: ObservableObject {
let instanceURL: URL
var accountInfo: LocalData.UserAccountInfo?
var accountPreferences: AccountPreferences!
let client: Client!
@ -154,6 +155,13 @@ 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)
}
Task {
do {
async let ownAccount = try getOwnAccount()
@ -317,6 +325,17 @@ class MastodonController: ObservableObject {
DispatchQueue.main.async {
self.lists = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
}
let context = self.persistentContainer.backgroundContext
context.perform {
for list in lists {
if let existing = try? context.fetch(ListMO.fetchRequest(id: list.id)).first {
existing.updateFrom(apiList: list)
} else {
_ = ListMO(apiList: list, context: context)
}
}
self.persistentContainer.save(context: context)
}
}
}
}

View File

@ -0,0 +1,35 @@
//
// AccountPreferences.swift
// Tusker
//
// Created by Shadowfacts on 12/19/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
@objc(AccountPreferences)
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)
return req
}
@NSManaged public var accountID: String
@NSManaged var pinnedTimelinesData: Data?
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: [])
var pinnedTimelines: [Timeline]
static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
let prefs = AccountPreferences(context: context)
prefs.accountID = account.id
prefs.pinnedTimelines = [.home, .public(local: true), .public(local: false)]
return prefs
}
}

View File

@ -0,0 +1,41 @@
//
// ListMO.swift
// Tusker
//
// Created by Shadowfacts on 12/20/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
@objc(ListMO)
public final class ListMO: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<ListMO> {
return NSFetchRequest(entityName: "List")
}
@nonobjc public class func fetchRequest(id: String) -> NSFetchRequest<ListMO> {
let req = NSFetchRequest<ListMO>(entityName: "List")
req.predicate = NSPredicate(format: "id = %@", id)
return req
}
@NSManaged public var id: String
@NSManaged public var title: String
}
extension ListMO {
convenience init(apiList list: List, context: NSManagedObjectContext) {
self.init(context: context)
self.updateFrom(apiList: list)
}
func updateFrom(apiList list: List) {
self.id = list.id
self.title = list.title
}
}

View File

@ -12,10 +12,11 @@ import Pachyderm
import Combine
import OSLog
import Sentry
import CloudKit
fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore")
class MastodonCachePersistentStore: NSPersistentContainer {
class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")!
@ -38,6 +39,9 @@ class MastodonCachePersistentStore: NSPersistentContainer {
return context
}()
private var remoteChangeHandlerQueue = DispatchQueue(label: "PersistentStore remote changes")
private var lastRemoteChangeToken: NSPersistentHistoryToken?
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
// would need to audit existing uses to make sure everything happens on the main thread
// and when updating things on the background context would need to switch to main, refetch, and then publish
@ -46,6 +50,9 @@ class MastodonCachePersistentStore: NSPersistentContainer {
let relationshipSubject = PassthroughSubject<String, Never>()
init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
let group = DispatchGroup()
var instancesToMigrate: [URL]? = nil
var hashtagsToMigrate: [Hashtag]? = nil
if transient {
super.init(name: "transient_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
@ -55,6 +62,28 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} else {
super.init(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
var localStoreLocation = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
localStoreLocation.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
let localStoreDescription = NSPersistentStoreDescription(url: localStoreLocation)
localStoreDescription.configuration = "Local"
localStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
localStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
var cloudStoreLocation = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
cloudStoreLocation.appendPathComponent("cloud.sqlite", isDirectory: false)
let cloudStoreDescription = NSPersistentStoreDescription(url: cloudStoreLocation)
cloudStoreDescription.configuration = "Cloud"
let options = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.space.vaccor.Tusker")
options.databaseScope = .private
cloudStoreDescription.cloudKitContainerOptions = options
cloudStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
cloudStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
persistentStoreDescriptions = [
cloudStoreDescription,
localStoreDescription,
]
// workaround for migrating from using id in name to persistenceKey
// can be removed after a sufficient time has passed
if accountInfo!.id.contains("/") {
@ -82,19 +111,70 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} catch {}
}
}
// migrate saved data from local store to cloud store
// this can be removed pre-app store release
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
if FileManager.default.fileExists(atPath: defaultPath.path) {
group.enter()
let defaultDesc = NSPersistentStoreDescription(url: defaultPath)
defaultDesc.configuration = "Default"
let defaultPSC = NSPersistentContainer(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
defaultPSC.persistentStoreDescriptions = [defaultDesc]
defaultPSC.loadPersistentStores { _, error in
guard error == nil else {
group.leave()
return
}
defaultPSC.performBackgroundTask { context in
if let instances = try? context.fetch(SavedInstance.fetchRequestWithoutAccountForMigrating()) {
instancesToMigrate = instances.map(\.url)
instances.forEach(context.delete(_:))
}
if let hashtags = try? context.fetch(SavedHashtag.fetchRequestWithoutAccountForMigrating()) {
hashtagsToMigrate = hashtags.map { Hashtag(name: $0.name, url: $0.url) }
hashtags.forEach(context.delete(_:))
}
if context.hasChanges {
try? context.save()
}
group.leave()
}
}
}
}
group.wait()
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")
}
if description.configuration == "Cloud" {
self.backgroundContext.perform {
instancesToMigrate?.forEach({ url in
_ = SavedInstance(url: url, account: accountInfo!, context: self.backgroundContext)
})
hashtagsToMigrate?.forEach({ hashtag in
_ = SavedHashtag(hashtag: hashtag, account: accountInfo!, context: self.backgroundContext)
})
self.save(context: self.backgroundContext)
}
}
}
// changes to the Cloud CD model in development need this to be uncommented to update the CK schema
// #if DEBUG
// try! initializeCloudKitSchema(options: [])
// #endif
viewContext.automaticallyMergesChangesFromParent = true
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
}
func save(context: NSManagedObjectContext) {
@ -403,4 +483,45 @@ class MastodonCachePersistentStore: NSPersistentContainer {
return changes
}
// the remote change notifications only handle deletes, inserts get handled by the regular managed object did change notifications
@objc private func remoteChanges(_ notification: Foundation.Notification) {
guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
return
}
remoteChangeHandlerQueue.async {
defer {
self.lastRemoteChangeToken = token
}
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] {
var changes: (hashtags: Bool, instances: Bool) = (false, false)
outer: for transaction in transactions {
for change in transaction.changes ?? [] {
if change.changedObjectID.entity.name == "SavedHashtag" {
changes.hashtags = true
} else if change.changedObjectID.entity.name == "SavedInstance" {
changes.instances = true
}
if changes.hashtags && changes.instances {
break outer
}
}
}
if changes.hashtags {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
}
}
if changes.instances {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
}
}
}
}
}
}
}

View File

@ -14,24 +14,32 @@ import WebURLFoundationExtras
@objc(SavedHashtag)
public final class SavedHashtag: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<SavedHashtag> {
@nonobjc class func fetchRequestWithoutAccountForMigrating() -> NSFetchRequest<SavedHashtag> {
return NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
}
@nonobjc public class func fetchRequest(name: String) -> NSFetchRequest<SavedHashtag> {
@nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest<SavedHashtag> {
let req = NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
req.predicate = NSPredicate(format: "name LIKE[cd] %@", name)
req.predicate = NSPredicate(format: "accountID = %@", account.id)
return req
}
@nonobjc class func fetchRequest(name: String, account: LocalData.UserAccountInfo) -> NSFetchRequest<SavedHashtag> {
let req = NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
req.predicate = NSPredicate(format: "name LIKE[cd] %@ AND accountID = %@", name, account.id)
return req
}
@NSManaged public var accountID: String
@NSManaged public var name: String
@NSManaged public var url: URL
}
extension SavedHashtag {
convenience init(hashtag: Hashtag, context: NSManagedObjectContext) {
convenience init(hashtag: Hashtag, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) {
self.init(context: context)
self.accountID = account.id
self.name = hashtag.name
self.url = URL(hashtag.url)!
}

View File

@ -12,23 +12,31 @@ import CoreData
@objc(SavedInstance)
public final class SavedInstance: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<SavedInstance> {
@nonobjc class func fetchRequestWithoutAccountForMigrating() -> NSFetchRequest<SavedInstance> {
return NSFetchRequest<SavedInstance>(entityName: "SavedInstance")
}
@nonobjc public class func fetchRequest(url: URL) -> NSFetchRequest<SavedInstance> {
let req = fetchRequest()
req.predicate = NSPredicate(format: "url = %@", url as NSURL)
@nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest<SavedInstance> {
let req = NSFetchRequest<SavedInstance>(entityName: "SavedInstance")
req.predicate = NSPredicate(format: "accountID = %@", account.id)
return req
}
@nonobjc class func fetchRequest(url: URL, account: LocalData.UserAccountInfo) -> NSFetchRequest<SavedInstance> {
let req = NSFetchRequest<SavedInstance>(entityName: "SavedInstance")
req.predicate = NSPredicate(format: "url = %@ AND accountID = %@", url as NSURL, account.id)
return req
}
@NSManaged public var accountID: String
@NSManaged public var url: URL
}
extension SavedInstance {
convenience init(url: URL, context: NSManagedObjectContext) {
convenience init(url: URL, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) {
self.init(context: context)
self.accountID = account.id
self.url = url
}
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21512" systemVersion="22A380" 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">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" optional="YES" attributeType="URI"/>
@ -28,6 +28,10 @@
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="AccountPreferences" representedClassName="AccountPreferences" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/>
</entity>
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
<attribute name="action" attributeType="String" defaultValueString="warn"/>
<attribute name="context" attributeType="String"/>
@ -46,6 +50,15 @@
<attribute name="name" attributeType="String"/>
<attribute name="url" attributeType="URI"/>
</entity>
<entity name="List" representedClassName="ListMO" syncable="YES">
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="title" optional="YES" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Relationship" representedClassName="RelationshipMO" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
@ -60,21 +73,13 @@
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="relationship" inverseEntity="Account"/>
</entity>
<entity name="SavedHashtag" representedClassName="SavedHashtag" syncable="YES">
<attribute name="name" attributeType="String"/>
<attribute name="url" attributeType="URI"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="name"/>
</uniquenessConstraint>
</uniquenessConstraints>
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
</entity>
<entity name="SavedInstance" representedClassName="SavedInstance" syncable="YES">
<attribute name="url" attributeType="URI"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="url"/>
</uniquenessConstraint>
</uniquenessConstraints>
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
</entity>
<entity name="Status" representedClassName="StatusMO" syncable="YES">
<attribute name="applicationName" optional="YES" attributeType="String"/>
@ -117,4 +122,19 @@
<attribute name="timelineKind" attributeType="String" valueTransformerName="pachydermTimeline" customClassName="Tusker.TimelineContainer"/>
<relationship name="statuses" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="Status" inverseName="timelines" inverseEntity="Status"/>
</entity>
<configuration name="Cloud" usedWithCloudKit="YES">
<memberEntity name="SavedHashtag"/>
<memberEntity name="SavedInstance"/>
<memberEntity name="AccountPreferences"/>
</configuration>
<configuration name="Local">
<memberEntity name="Account"/>
<memberEntity name="Filter"/>
<memberEntity name="FilterKeyword"/>
<memberEntity name="FollowedHashtag"/>
<memberEntity name="Relationship"/>
<memberEntity name="Status"/>
<memberEntity name="TimelineState"/>
<memberEntity name="List"/>
</configuration>
</model>

View File

@ -25,18 +25,22 @@ extension Timeline {
}
}
var tabBarImage: UIImage? {
var image: UIImage {
switch self {
case .home:
return UIImage(systemName: "house.fill")
return UIImage(systemName: "house.fill")!
case let .public(local):
if local {
return UIImage(systemName: "person.and.person.fill")
return UIImage(systemName: "person.and.person.fill")!
} else {
return UIImage(systemName: "globe")
return UIImage(systemName: "globe")!
}
default:
return nil
case .list(id: _):
return UIImage(systemName: "list.bullet")!
case .tag(hashtag: _):
return UIImage(systemName: "number")!
case .direct:
return UIImage(systemName: "enveloep.fill")!
}
}

View File

@ -2,37 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SentryDSN</key>
<string>$(SENTRY_DSN)</string>
<key>OSLogPreferences</key>
<dict>
<key>space.vaccor.Tusker</key>
<dict>
<key>DEFAULT-OPTIONS</key>
<dict>
<key>TTL</key>
<dict>
<key>Fault</key>
<integer>30</integer>
<key>Error</key>
<integer>30</integer>
<key>Debug</key>
<integer>15</integer>
<key>Info</key>
<integer>30</integer>
<key>Default</key>
<integer>30</integer>
</dict>
<key>Level</key>
<dict>
<key>Persist</key>
<string>Debug</string>
<key>Enable</key>
<string>Debug</string>
</dict>
</dict>
</dict>
</dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
@ -87,7 +56,7 @@
<key>NSMicrophoneUsageDescription</key>
<string>Post videos from the camera.</string>
<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>
<string>Post photos from the photo library.</string>
<key>NSUserActivityTypes</key>
@ -102,6 +71,37 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-profile</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.main-scene</string>
</array>
<key>OSLogPreferences</key>
<dict>
<key>space.vaccor.Tusker</key>
<dict>
<key>DEFAULT-OPTIONS</key>
<dict>
<key>Level</key>
<dict>
<key>Enable</key>
<string>Debug</string>
<key>Persist</key>
<string>Debug</string>
</dict>
<key>TTL</key>
<dict>
<key>Debug</key>
<integer>15</integer>
<key>Default</key>
<integer>30</integer>
<key>Error</key>
<integer>30</integer>
<key>Fault</key>
<integer>30</integer>
<key>Info</key>
<integer>30</integer>
</dict>
</dict>
</dict>
</dict>
<key>SentryDSN</key>
<string>$(SENTRY_DSN)</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@ -140,6 +140,7 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>

View File

@ -400,7 +400,8 @@ struct ComposeAutocompleteHashtagsView: View {
}
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) {
let savedTags = ((try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? [])
let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
let savedTags = ((try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? [])
.map { Hashtag(name: $0.name, url: $0.url) }
hashtags = (searchResults + savedTags + trendingTags)

View File

@ -0,0 +1,113 @@
//
// AddHashtagPinnedTimelineView.swift
// Tusker
//
// Created by Shadowfacts on 12/20/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct AddHashtagPinnedTimelineView: View {
@EnvironmentObject private var mastodonController: MastodonController
@Environment(\.dismiss) private var dismiss
@Binding var pinnedTimelines: [Timeline]
@StateObject private var viewModel = SearchViewModel()
@State private var searchTask: Task<Void, Never>?
@State private var isSearching = false
@State private var searchResults: [String] = []
private var savedAndFollowedHashtags: [String] {
var tags = Set<String>()
let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
for saved in (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? [] {
tags.insert(saved.name)
}
for followed in mastodonController.followedHashtags {
tags.insert(followed.name)
}
return Array(tags).sorted(using: SemiCaseSensitiveComparator())
}
var body: some View {
NavigationView {
list
.navigationTitle("Search")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $viewModel.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: Text("Search for hashtags"))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
.navigationViewStyle(.stack)
.onReceive(viewModel.$searchQuery, perform: { newValue in
isSearching = !newValue.isEmpty
})
.onReceive(viewModel.$searchQuery.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main), perform: { _ in
searchTask?.cancel()
searchTask = Task {
try? await updateSearchResults()
}
})
}
private var list: some View {
List {
Section {
if viewModel.searchQuery.isEmpty {
forEachTag(savedAndFollowedHashtags)
} else {
forEachTag(searchResults)
}
} header: {
ProgressView()
.progressViewStyle(.circular)
.opacity(isSearching ? 1 : 0)
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(EmptyView())
.listRowSeparator(.hidden)
}
}
.listStyle(.grouped)
}
private func forEachTag(_ tags: [String]) -> some View {
ForEach(tags, id: \.self) { tag in
Button {
pinnedTimelines.append(.tag(hashtag: tag))
dismiss()
} label: {
Text("#\(tag)")
}
.tint(.primary)
.disabled(pinnedTimelines.contains(.tag(hashtag: tag)))
}
}
private func updateSearchResults() async throws {
guard !viewModel.searchQuery.isEmpty else {
return
}
isSearching = true
let req = Client.search(query: viewModel.searchQuery, types: [.hashtags])
let (results, _) = try await mastodonController.run(req)
searchResults = results.hashtags.map(\.name)
isSearching = false
}
}
private class SearchViewModel: ObservableObject {
@Published var searchQuery = ""
}
//struct AddHashtagPinnedTimelineView_Previews: PreviewProvider {
// static var previews: some View {
// AddHashtagPinnedTimelineView()
// }
//}

View File

@ -1,5 +1,5 @@
//
// FiltersView.swift
// CustomizeTimelinesView.swift
// Tusker
//
// Created by Shadowfacts on 11/30/22.
@ -9,18 +9,18 @@
import SwiftUI
import Pachyderm
struct FiltersView: View {
struct CustomizeTimelinesView: View {
let mastodonController: MastodonController
var body: some View {
FiltersList()
CustomizeTimelinesList()
.environmentObject(mastodonController)
.environment(\.managedObjectContext, mastodonController.persistentContainer.viewContext)
}
}
struct FiltersList: View {
struct CustomizeTimelinesList: View {
@EnvironmentObject private var mastodonController: MastodonController
@ObservedObject private var preferences = Preferences.shared
@FetchRequest(sortDescriptors: []) private var filters: FetchedResults<FilterMO>
@ -50,6 +50,8 @@ struct FiltersList: View {
private var navigationBody: some View {
List {
PinnedTimelinesView(accountPreferences: mastodonController.accountPreferences)
Section {
Toggle(isOn: $preferences.hideReblogsInTimelines) {
Text("Hide Reblogs")
@ -62,18 +64,27 @@ struct FiltersList: View {
}
Section {
filtersForEach(unexpiredFilters)
NavigationLink {
EditFilterView(filter: EditedFilter(), create: true, originallyExpired: false)
} label: {
Label("Add Filter", systemImage: "plus")
.foregroundColor(.accentColor)
}
} header: {
Text("Active Filters")
}
filtersSection(unexpiredFilters, header: Text("Active"))
filtersSection(expiredFilters, header: Text("Expired"))
if !expiredFilters.isEmpty {
Section {
filtersForEach(expiredFilters)
} header: {
Text("Expired Filters")
}
}
}
.navigationTitle(Text("Filters"))
.navigationTitle(Text("Customize Timelines"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
@ -95,30 +106,26 @@ struct FiltersList: View {
}
@ViewBuilder
private func filtersSection(_ filters: [FilterMO], header: some View) -> some View {
private func filtersForEach(_ filters: [FilterMO]) -> some View {
if !filters.isEmpty {
Section {
ForEach(filters, id: \.id) { filter in
NavigationLink {
EditFilterView(filter: EditedFilter(filter), create: false, originallyExpired: filter.expiresAt != nil && filter.expiresAt! <= Date())
} label: {
FilterRow(filter: filter)
}
.contextMenu {
Button(role: .destructive) {
deleteFilter(filter)
} label: {
Label("Delete Filter", systemImage: "trash")
}
}
ForEach(filters, id: \.id) { filter in
NavigationLink {
EditFilterView(filter: EditedFilter(filter), create: false, originallyExpired: filter.expiresAt != nil && filter.expiresAt! <= Date())
} label: {
FilterRow(filter: filter)
}
.onDelete { indices in
for filter in indices.map({ filters[$0] }) {
.contextMenu {
Button(role: .destructive) {
deleteFilter(filter)
} label: {
Label("Delete Filter", systemImage: "trash")
}
}
} header: {
header
}
.onDelete { indices in
for filter in indices.map({ filters[$0] }) {
deleteFilter(filter)
}
}
}
}

View File

@ -108,7 +108,6 @@ struct EditFilterView: View {
}
}
Toggle("Expires", isOn: expires)
if expires.wrappedValue {
@ -143,7 +142,7 @@ struct EditFilterView: View {
Text("Contexts")
}
}
.navigationTitle("Edit Filter")
.navigationTitle(create ? "Add Filter" : "Edit Filter")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
@ -151,7 +150,7 @@ struct EditFilterView: View {
ProgressView()
.progressViewStyle(.circular)
} else {
Button(create ? "Create" : "Save") {
Button("Save") {
saveFilter()
}
.disabled(!filter.isValid(for: mastodonController) || (!edited && originallyExpired))

View File

@ -0,0 +1,130 @@
//
// PinnedTimelinesView.swift
// Tusker
//
// Created by Shadowfacts on 12/20/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct PinnedTimelinesView: View {
@EnvironmentObject private var mastodonController: MastodonController
@ObservedObject private var accountPreferences: AccountPreferences
@State private var isShowingAddHashtagSheet = false
// store this separately from AccountPreferences in the view, b/c the @LazilyDecoding wrapper breaks animations
@State private var pinnedTimelines: [Timeline]
init(accountPreferences: AccountPreferences) {
self.accountPreferences = accountPreferences
self.pinnedTimelines = accountPreferences.pinnedTimelines
}
var body: some View {
Section {
ForEach(pinnedTimelines, id: \.id) { timeline in
HStack {
Label {
if case .list(id: let id) = timeline,
let list = mastodonController.lists.first(where: { $0.id == id }) {
Text(list.title)
} else if case .tag(hashtag: let tag) = timeline {
Text(tag)
} else {
Text(timeline.title)
}
} icon: {
Image(uiImage: timeline.image.withRenderingMode(.alwaysTemplate))
}
Spacer()
Image(systemName: "line.3.horizontal")
.foregroundColor(Color(.lightGray))
.accessibilityHidden(true)
}
}
.onMove { indices, newOffset in
pinnedTimelines.move(fromOffsets: indices, toOffset: newOffset)
}
.onDelete { indices in
pinnedTimelines.remove(atOffsets: indices)
}
Menu {
ForEach([Timeline.home, .public(local: true), .public(local: false)], id: \.id) { timeline in
Button {
withAnimation {
pinnedTimelines.append(timeline)
}
} label: {
Label {
Text(timeline.title)
} icon: {
Image(uiImage: timeline.image)
}
}
.disabled(pinnedTimelines.contains(timeline))
}
Menu("List…") {
ForEach(mastodonController.lists, id: \.id) { list in
Button {
withAnimation {
pinnedTimelines.append(list.timeline)
}
} label: {
Text(list.title)
}
.disabled(pinnedTimelines.contains(list.timeline))
}
}
Button {
isShowingAddHashtagSheet = true
} label: {
Label("Hashtag…", systemImage: "number")
}
} label: {
Label("Add…", systemImage: "plus")
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
} header: {
Text("Pinned Timelines")
}
.sheet(isPresented: $isShowingAddHashtagSheet, content: {
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
})
.onReceive(accountPreferences.publisher(for: \.pinnedTimelinesData)) { _ in
if pinnedTimelines != accountPreferences.pinnedTimelines {
pinnedTimelines = accountPreferences.pinnedTimelines
}
}
.onChange(of: pinnedTimelines) { newValue in
if accountPreferences.pinnedTimelines != newValue {
accountPreferences.pinnedTimelines = newValue
}
}
}
}
fileprivate extension Timeline {
var id: String {
switch self {
case .home:
return "home"
case .public(local: let local):
return "public:\(local)"
case .list(id: let id):
return "list:\(id)"
case .tag(hashtag: let tag):
return "tag:\(tag)"
case .direct:
return "direct"
}
}
}

View File

@ -206,7 +206,8 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
@MainActor
private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] {
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? []
let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? []
var items = saved.map {
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
}
@ -219,7 +220,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
@MainActor
private func fetchSavedInstances() -> [SavedInstance] {
let req = SavedInstance.fetchRequest()
let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!)
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
do {
return try mastodonController.persistentContainer.viewContext.fetch(req)
@ -278,7 +279,8 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
func removeSavedHashtag(_ hashtag: Hashtag) {
let context = mastodonController.persistentContainer.viewContext
if let hashtag = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
let req = SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)
if let hashtag = try? context.fetch(req).first {
context.delete(hashtag)
try! context.save()
}
@ -286,7 +288,8 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
func removeSavedInstance(_ instanceURL: URL) {
let context = mastodonController.persistentContainer.viewContext
if let instance = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first {
let req = SavedInstance.fetchRequest(url: instanceURL, account: mastodonController.accountInfo!)
if let instance = try? context.fetch(req).first {
context.delete(instance)
try! context.save()
}

View File

@ -232,7 +232,8 @@ class MainSidebarViewController: UIViewController {
@MainActor
private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] {
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? []
let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? []
var items = saved.map {
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
}
@ -245,7 +246,7 @@ class MainSidebarViewController: UIViewController {
@MainActor
private func fetchSavedInstances() -> [SavedInstance] {
let req = SavedInstance.fetchRequest()
let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!)
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
do {
return try mastodonController.persistentContainer.viewContext.fetch(req)

View File

@ -11,9 +11,6 @@ import Pachyderm
class NotificationsPageViewController: SegmentedPageViewController<NotificationsPageViewController.Page> {
private let notificationsTitle = NSLocalizedString("Notifications", comment: "notifications tab title")
private let mentionsTitle = NSLocalizedString("Mentions", comment: "mentions tab title")
weak var mastodonController: MastodonController!
var initialMode: NotificationsMode?
@ -22,20 +19,14 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
self.initialMode = initialMode
self.mastodonController = mastodonController
let notifications = NotificationsTableViewController(allowedTypes: Pachyderm.Notification.Kind.allCases, mastodonController: mastodonController)
notifications.title = notificationsTitle
notifications.userActivity = UserActivityManager.checkNotificationsActivity(mode: .allNotifications)
super.init(pages: [.all, .mentions]) { page in
let vc = NotificationsTableViewController(allowedTypes: page.allowedTypes, mastodonController: mastodonController)
vc.title = page.title
vc.userActivity = page.userActivity
return vc
}
let mentions = NotificationsTableViewController(allowedTypes: [.mention], mastodonController: mastodonController)
mentions.title = mentionsTitle
mentions.userActivity = UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly)
super.init(pages: [
(.all, notificationsTitle, notifications),
(.mentions, mentionsTitle, mentions),
])
title = notificationsTitle
title = Page.all.title
tabBarItem.image = UIImage(systemName: "bell.fill")
}
@ -61,9 +52,40 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
selectPage(page, animated: false)
}
enum Page {
enum Page: SegmentedPageViewControllerPage {
case all
case mentions
var title: String {
switch self {
case .all:
return NSLocalizedString("Notifications", comment: "notifications tab title")
case .mentions:
return NSLocalizedString("Mentions", comment: "mentions tab title")
}
}
var segmentedControlTitle: String {
title
}
var allowedTypes: [Pachyderm.Notification.Kind] {
switch self {
case .all:
return Pachyderm.Notification.Kind.allCases
case .mentions:
return [.mention]
}
}
var userActivity: NSUserActivity {
switch self {
case .all:
return UserActivityManager.checkNotificationsActivity(mode: .allNotifications)
case .mentions:
return UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly)
}
}
}
}

View File

@ -39,7 +39,7 @@ class OnboardingViewController: UINavigationController {
@MainActor
private func tryLoginTo(instanceURL: URL) async throws {
let mastodonController = MastodonController(instanceURL: instanceURL)
let mastodonController = MastodonController(instanceURL: instanceURL, transient: true)
let clientID: String
let clientSecret: String
do {

View File

@ -16,7 +16,8 @@ class HashtagTimelineViewController: TimelineViewController {
var toggleSaveButton: UIBarButtonItem!
private var isHashtagSaved: Bool {
mastodonController.persistentContainer.viewContext.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name))
let req = SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)
return mastodonController.persistentContainer.viewContext.objectExists(for: req)
}
private var isHashtagFollowed: Bool {
@ -47,10 +48,10 @@ class HashtagTimelineViewController: TimelineViewController {
private func toggleSave() {
let context = mastodonController.persistentContainer.viewContext
if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)).first {
context.delete(existing)
} else {
_ = SavedHashtag(hashtag: hashtag, context: context)
_ = SavedHashtag(hashtag: hashtag, account: mastodonController.accountInfo!, context: context)
}
mastodonController.persistentContainer.save(context: context)
}

View File

@ -26,7 +26,8 @@ class InstanceTimelineViewController: TimelineViewController {
private var toggleSaveButton: UIBarButtonItem!
private var isInstanceSaved: Bool {
parentMastodonController!.persistentContainer.viewContext.objectExists(for: SavedInstance.fetchRequest(url: instanceURL))
let req = SavedInstance.fetchRequest(url: instanceURL, account: parentMastodonController!.accountInfo!)
return parentMastodonController!.persistentContainer.viewContext.objectExists(for: req)
}
private var toggleSaveButtonTitle: String {
if isInstanceSaved {
@ -83,12 +84,13 @@ class InstanceTimelineViewController: TimelineViewController {
// MARK: - Interaction
@objc func toggleSaveButtonPressed() {
let context = parentMastodonController!.persistentContainer.viewContext
let existing = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first
let req = SavedInstance.fetchRequest(url: instanceURL, account: parentMastodonController!.accountInfo!)
let existing = try? context.fetch(req).first
if let existing = existing {
context.delete(existing)
delegate?.didUnsaveInstance(url: instanceURL)
} else {
_ = SavedInstance(url: instanceURL, context: context)
_ = SavedInstance(url: instanceURL, account: parentMastodonController!.accountInfo!, context: context)
delegate?.didSaveInstance(url: instanceURL)
}
mastodonController.persistentContainer.save(context: context)

View File

@ -8,6 +8,7 @@
import UIKit
import SwiftUI
import Pachyderm
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> {
@ -17,33 +18,27 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
weak var mastodonController: MastodonController!
private var pinnedTimelinesObservation: NSKeyValueObservation?
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
let home = TimelineViewController(for: .home, mastodonController: mastodonController)
home.title = homeTitle
home.persistsState = true
let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController)
federated.title = federatedTitle
federated.persistsState = true
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
local.title = localTitle
local.persistsState = true
super.init(pages: [
(.home, "Home", home),
(.local, "Local", local),
(.federated, "Federated", federated),
])
let pages = mastodonController.accountPreferences.pinnedTimelines.map {
Page(mastodonController: mastodonController, timeline: $0)
}
super.init(pages: pages) { page in
let vc = TimelineViewController(for: page.timeline, mastodonController: page.mastodonController)
vc.title = page.segmentedControlTitle
vc.persistsState = true
return vc
}
title = homeTitle
tabBarItem.image = UIImage(systemName: "house.fill")
let filtersItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), style: .plain, target: self, action: #selector(filtersPressed))
filtersItem.accessibilityLabel = "Filters"
navigationItem.leftBarButtonItem = filtersItem
let customizeItem = UIBarButtonItem(image: UIImage(systemName: "slider.horizontal.3"), style: .plain, target: self, action: #selector(customizePressed))
customizeItem.accessibilityLabel = "Customize Timelines"
navigationItem.rightBarButtonItem = customizeItem
let jumpToPresentName = NSMutableAttributedString("Jump to Present")
// otherwise it pronounces it as 'pɹizˈənt'
@ -51,7 +46,7 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
jumpToPresentName.addAttribute(.accessibilitySpeechIPANotation, value: "ˈprɛ.zənt", range: NSRange(location: "Jump to ".count, length: "Present".count))
segmentedControl.accessibilityCustomActions = [
UIAccessibilityCustomAction(attributedName: jumpToPresentName, actionHandler: { [unowned self] _ in
guard let vc = pageControllers[currentIndex] as? TimelineViewController else {
guard let vc = currentViewController as? TimelineViewController else {
return false
}
Task {
@ -60,42 +55,61 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
return true
})
]
pinnedTimelinesObservation = mastodonController.accountPreferences.observe(\.pinnedTimelinesData, changeHandler: { [unowned self] _, _ in
let pages = self.mastodonController.accountPreferences.pinnedTimelines.map {
Page(mastodonController: self.mastodonController, timeline: $0)
}
self.setPages(pages, animated: false)
})
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func selectTimeline(_ timeline: Timeline, animated: Bool) {
self.selectPage(Page(mastodonController: mastodonController, timeline: timeline), animated: animated)
}
func stateRestorationActivity() -> NSUserActivity? {
return (pageControllers[currentIndex] as! TimelineViewController).stateRestorationActivity()
return (currentViewController as! TimelineViewController).stateRestorationActivity()
}
func restoreActivity(_ activity: NSUserActivity) {
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
return
}
let page: Page
switch timeline {
case .home:
page = .home
case .public(local: false):
page = .federated
case .public(local: true):
page = .local
default:
return
}
let page = Page(mastodonController: mastodonController, timeline: timeline)
selectPage(page, animated: false)
}
@objc private func filtersPressed() {
present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true)
}
enum Page: Hashable {
case home
case local
case federated
@objc private func customizePressed() {
present(UIHostingController(rootView: CustomizeTimelinesView(mastodonController: mastodonController)), animated: true)
}
}
extension TimelinesPageViewController {
struct Page: SegmentedPageViewControllerPage {
let mastodonController: MastodonController
let timeline: Timeline
static func ==(lhs: Page, rhs: Page) -> Bool {
return lhs.timeline == rhs.timeline
}
func hash(into hasher: inout Hasher) {
hasher.combine(timeline)
}
var segmentedControlTitle: String {
if case let .list(id) = timeline,
let list = try? mastodonController.persistentContainer.viewContext.fetch(ListMO.fetchRequest(id: id)).first {
return list.title
} else {
return timeline.title
}
}
}
}

View File

@ -111,7 +111,7 @@ extension MenuActionProvider {
mastodonController.loggedIn {
let name = hashtag.name.lowercased()
let context = mastodonController.persistentContainer.viewContext
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name)).first
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name, account: mastodonController.accountInfo!)).first
let saveSubtitle = "Saved hashtags appear in the Explore section of Tusker"
let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus")
actionsSection = [
@ -119,7 +119,7 @@ extension MenuActionProvider {
if let existing = existing {
context.delete(existing)
} else {
_ = SavedHashtag(hashtag: hashtag, context: context)
_ = SavedHashtag(hashtag: hashtag, account: mastodonController.accountInfo!, context: context)
}
mastodonController.persistentContainer.save(context: context)
})

View File

@ -8,35 +8,39 @@
import UIKit
class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageViewControllerDelegate, TabbedPageViewController {
protocol SegmentedPageViewControllerPage: Hashable {
var segmentedControlTitle: String { get }
}
let pages: [Page]
let pageControllers: [UIViewController]
class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIPageViewController, UIPageViewControllerDelegate, TabbedPageViewController {
private(set) var pages: [Page]!
private let pageProvider: (Page) -> UIViewController
private var pageControllers = [Page: UIViewController]()
private var initialPage: Page
private var currentPage: Page
var currentIndex: Int {
pages.firstIndex(of: currentPage)!
var currentIndex: Int! {
pages.firstIndex(of: currentPage)
}
var currentViewController: UIViewController {
viewControllers!.first!
}
let segmentedControl = ScrollingSegmentedControl<Page>()
init(pages: [(Page, String, UIViewController)]) {
init(pages: [Page], pageProvider: @escaping (Page) -> UIViewController) {
precondition(!pages.isEmpty)
self.pages = pages.map(\.0)
self.pageControllers = pages.map(\.2)
self.pageProvider = pageProvider
initialPage = self.pages.first!
currentPage = self.pages.first!
initialPage = pages.first!
currentPage = pages.first!
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
// this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView
// before the view has necessarily loaded
segmentedControl.options = pages.map {
.init(value: $0.0, name: $0.1)
}
setPages(pages, animated: false)
segmentedControl.didSelectOption = { [unowned self] option in
if let option {
self.selectPage(option, animated: true)
@ -54,6 +58,26 @@ class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageV
fatalError("init(coder:) has not been implemented")
}
func setPages(_ pages: [Page], animated: Bool) {
precondition(!pages.isEmpty)
self.pages = pages
if !pages.contains(currentPage) {
selectPage(pages.first!, animated: animated)
}
for key in pageControllers.keys where !pages.contains(key) {
pageControllers.removeValue(forKey: key)
}
// this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView
// before the view has necessarily loaded
segmentedControl.options = pages.map {
.init(value: $0, name: $0.segmentedControlTitle)
}
}
override func viewDidLoad() {
super.viewDidLoad()
@ -80,12 +104,23 @@ class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageV
initialPage = page
return
}
let prevIndex = currentIndex
currentPage = page
let index = pages.firstIndex(of: page)!
let newController = pageControllers[index]
let direction: UIPageViewController.NavigationDirection
if let prevIndex = currentIndex {
let index = pages.firstIndex(of: page)!
direction = index - prevIndex > 0 ? .forward : .reverse
} else {
direction = .forward
}
currentPage = page
let newController: UIViewController
if let existing = pageControllers[page] {
newController = existing
} else {
newController = pageProvider(page)
pageControllers[page] = newController
}
let direction: UIPageViewController.NavigationDirection = index - prevIndex > 0 ? .forward : .reverse
setViewControllers([newController], direction: direction, animated: animated)
navigationItem.title = newController.title
@ -108,7 +143,7 @@ class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageV
extension SegmentedPageViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
if let scrollableVC = pageControllers[currentIndex] as? TabBarScrollableViewController {
if let scrollableVC = currentViewController as? TabBarScrollableViewController {
scrollableVC.tabBarScrollToTop()
}
}
@ -116,7 +151,7 @@ extension SegmentedPageViewController: TabBarScrollableViewController {
extension SegmentedPageViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
if let current = pageControllers[currentIndex] as? BackgroundableViewController {
if let current = currentViewController as? BackgroundableViewController {
current.sceneDidEnterBackground()
}
}
@ -124,7 +159,7 @@ extension SegmentedPageViewController: BackgroundableViewController {
extension SegmentedPageViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
if let current = pageControllers[currentIndex] as? StatusBarTappableViewController {
if let current = currentViewController as? StatusBarTappableViewController {
return current.handleStatusBarTapped(xPosition: xPosition)
}
return .continue

View File

@ -216,23 +216,11 @@ class UserActivityManager {
return
}
switch timeline {
case .home, .public(true), .public(false):
if mastodonController.accountPreferences.pinnedTimelines.contains(timeline) {
navigationController.popToRootViewController(animated: false)
let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController
let page: TimelinesPageViewController.Page
switch timeline {
case .home:
page = .home
case .public(local: false):
page = .federated
case .public(local: true):
page = .local
default:
fatalError()
}
rootController.selectPage(page, animated: false)
default:
rootController.selectTimeline(timeline, animated: false)
} else {
let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController)
navigationController.pushViewController(timeline, animated: false)
}

View File

@ -2,6 +2,18 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.space.vaccor.Tusker</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>

View File

@ -89,6 +89,9 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
label.accessibilityLabel = "\(option.name), \(index + 1) of \(options.count)"
optionsStack.addArrangedSubview(label)
}
updateSelectedIndicatorView()
invalidateIntrinsicContentSize()
}
func setSelectedOption(_ value: Value, animated: Bool) {

View File

@ -529,6 +529,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
attrStr.append(showStr)
filteredLabel.attributedText = attrStr
setContentViewMode(.filtered)
return
case .hide:
fatalError("unreachable")
}