Compare commits
8 Commits
307299dd4d
...
e264e8842c
Author | SHA1 | Date |
---|---|---|
Shadowfacts | e264e8842c | |
Shadowfacts | c78ed42a5e | |
Shadowfacts | 6fa71bee38 | |
Shadowfacts | 6bb292ba13 | |
Shadowfacts | c12b9ae879 | |
Shadowfacts | 2380eeee4a | |
Shadowfacts | f4196a2c26 | |
Shadowfacts | ff9286ca48 |
|
@ -19,8 +19,9 @@ public class PersistentContainer: NSPersistentContainer, @unchecked Sendable {
|
||||||
|
|
||||||
public private(set) lazy var backgroundContext: NSManagedObjectContext = {
|
public private(set) lazy var backgroundContext: NSManagedObjectContext = {
|
||||||
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||||
// todo: should the background context really be parented to the view context, or should they both be direct children of the PSC?
|
// the background context needs to be parented directly to the PSC
|
||||||
context.parent = self.viewContext
|
// if it's parented to the viewContext, it blocks the viewContext (and potentially the main thread) when it needs to look things up
|
||||||
|
context.persistentStoreCoordinator = self.persistentStoreCoordinator
|
||||||
return context
|
return context
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,6 @@
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>_XCCurrentVersionName</key>
|
<key>_XCCurrentVersionName</key>
|
||||||
<string>Reader.xcdatamodel</string>
|
<string>Reader 2.xcdatamodel</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21279" systemVersion="22A5331f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
|
<entity name="Feed" representedClassName="Feed" syncable="YES">
|
||||||
|
<attribute name="id" attributeType="String"/>
|
||||||
|
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="title" attributeType="String"/>
|
||||||
|
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||||
|
<relationship name="groups" toMany="YES" deletionRule="Nullify" destinationEntity="Group" inverseName="feeds" inverseEntity="Group"/>
|
||||||
|
<relationship name="items" toMany="YES" deletionRule="Cascade" destinationEntity="Item" inverseName="feed" inverseEntity="Item"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Group" representedClassName="Group" syncable="YES">
|
||||||
|
<attribute name="id" attributeType="String"/>
|
||||||
|
<attribute name="title" attributeType="String"/>
|
||||||
|
<relationship name="feeds" toMany="YES" deletionRule="Nullify" destinationEntity="Feed" inverseName="groups" inverseEntity="Feed"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Item" representedClassName="Item" versionHashModifier="5" syncable="YES">
|
||||||
|
<attribute name="author" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="content" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="excerpt" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="generatedExcerpt" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="id" attributeType="String"/>
|
||||||
|
<attribute name="needsReadStateSync" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="published" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="read" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="title" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||||
|
<relationship name="feed" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="items" inverseEntity="Feed"/>
|
||||||
|
<fetchIndex name="byID">
|
||||||
|
<fetchIndexElement property="id" type="Binary" order="ascending"/>
|
||||||
|
</fetchIndex>
|
||||||
|
<fetchIndex name="byRead">
|
||||||
|
<fetchIndexElement property="read" type="Binary" order="ascending"/>
|
||||||
|
</fetchIndex>
|
||||||
|
</entity>
|
||||||
|
<entity name="SyncState" representedClassName="SyncState" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="lastSync" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
</entity>
|
||||||
|
<fetchRequest name="FetchRequest" entity="Item" predicateString="TRUEPREDICATE"/>
|
||||||
|
</model>
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21223.11" systemVersion="22A5266r" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21279" systemVersion="22A5331f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="Feed" representedClassName="Feed" syncable="YES">
|
<entity name="Feed" representedClassName="Feed" syncable="YES">
|
||||||
<attribute name="id" attributeType="String"/>
|
<attribute name="id" attributeType="String"/>
|
||||||
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
@ -30,10 +30,4 @@
|
||||||
<attribute name="lastSync" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="lastSync" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
</entity>
|
</entity>
|
||||||
<fetchRequest name="FetchRequest" entity="Item" predicateString="TRUEPREDICATE"/>
|
<fetchRequest name="FetchRequest" entity="Item" predicateString="TRUEPREDICATE"/>
|
||||||
<elements>
|
|
||||||
<element name="Feed" positionX="-54" positionY="9" width="128" height="119"/>
|
|
||||||
<element name="Group" positionX="-63" positionY="-18" width="128" height="74"/>
|
|
||||||
<element name="Item" positionX="-45" positionY="63" width="128" height="194"/>
|
|
||||||
<element name="SyncState" positionX="-63" positionY="90" width="128" height="44"/>
|
|
||||||
</elements>
|
|
||||||
</model>
|
</model>
|
|
@ -75,7 +75,18 @@
|
||||||
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</CommandLineArgument>
|
</CommandLineArgument>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.SQLDebug 1"
|
||||||
|
isEnabled = "NO">
|
||||||
|
</CommandLineArgument>
|
||||||
</CommandLineArguments>
|
</CommandLineArguments>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "SQLITE_AUTO_TRACE"
|
||||||
|
value = "1"
|
||||||
|
isEnabled = "NO">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
|
|
@ -104,6 +104,7 @@ class BackgroundManager: NSObject {
|
||||||
do {
|
do {
|
||||||
try await fervorController.persistentContainer.upsertItems(items)
|
try await fervorController.persistentContainer.upsertItems(items)
|
||||||
logger.info("Upserted \(items.count) items during background refresh")
|
logger.info("Upserted \(items.count) items during background refresh")
|
||||||
|
logger.info("Most recent unread item: \(items.first(where: { $0.read == false })!.title!, privacy: .public)")
|
||||||
|
|
||||||
await WidgetHelper.updateWidgetData(fervorController: fervorController)
|
await WidgetHelper.updateWidgetData(fervorController: fervorController)
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import Foundation
|
||||||
import CoreData
|
import CoreData
|
||||||
import Persistence
|
import Persistence
|
||||||
|
|
||||||
enum ItemListType: Hashable {
|
enum ItemListType: Hashable, Equatable {
|
||||||
case unread
|
case unread
|
||||||
case all
|
case all
|
||||||
case group(Group)
|
case group(Group)
|
||||||
|
@ -52,7 +52,9 @@ enum ItemListType: Hashable {
|
||||||
case .all:
|
case .all:
|
||||||
return nil
|
return nil
|
||||||
case .group(let group):
|
case .group(let group):
|
||||||
req.predicate = NSPredicate(format: "read = NO AND feed in %@", group.feeds!)
|
// use the feed ids, because passing the NSSet of feeds into the predicate results in a multithreading violation if the request is used on a different context
|
||||||
|
let feedIDs = group.feeds!.map { ($0 as! Feed).objectID }
|
||||||
|
req.predicate = NSPredicate(format: "read = NO AND feed in %@", feedIDs)
|
||||||
case .feed(let feed):
|
case .feed(let feed):
|
||||||
req.predicate = NSPredicate(format: "read = NO AND feed = %@", feed)
|
req.predicate = NSPredicate(format: "read = NO AND feed = %@", feed)
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,7 +45,11 @@ class AppSplitViewController: UISplitViewController {
|
||||||
setViewController(sidebarNav, for: .primary)
|
setViewController(sidebarNav, for: .primary)
|
||||||
|
|
||||||
secondaryNav = UINavigationController()
|
secondaryNav = UINavigationController()
|
||||||
secondaryNav.isNavigationBarHidden = true
|
// the toggle sidebar button only appears if there's a navigation bar
|
||||||
|
// so we just make always transparent, rather than disabling it
|
||||||
|
let secondaryNavBarAppearance = UINavigationBarAppearance()
|
||||||
|
secondaryNavBarAppearance.configureWithTransparentBackground()
|
||||||
|
secondaryNav.navigationBar.standardAppearance = secondaryNavBarAppearance
|
||||||
secondaryNav.view.backgroundColor = .appBackground
|
secondaryNav.view.backgroundColor = .appBackground
|
||||||
setViewController(secondaryNav, for: .secondary)
|
setViewController(secondaryNav, for: .secondary)
|
||||||
|
|
||||||
|
|
|
@ -7,9 +7,16 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Fervor
|
import Fervor
|
||||||
|
import Persistence
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let signposter = OSSignposter(subsystem: "net.shadowfacts.Reader", category: "HomeCollectionViewCell")
|
||||||
|
|
||||||
class HomeCollectionViewCell: UICollectionViewListCell {
|
class HomeCollectionViewCell: UICollectionViewListCell {
|
||||||
|
|
||||||
|
private var currentItemCountTask: Task<Void, Never>?
|
||||||
|
private var itemCount: Int?
|
||||||
|
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
override func updateConfiguration(using state: UICellConfigurationState) {
|
override func updateConfiguration(using state: UICellConfigurationState) {
|
||||||
var backgroundConfig = UIBackgroundConfiguration.listGroupedCell().updated(for: state)
|
var backgroundConfig = UIBackgroundConfiguration.listGroupedCell().updated(for: state)
|
||||||
|
@ -22,4 +29,47 @@ class HomeCollectionViewCell: UICollectionViewListCell {
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
currentItemCountTask?.cancel()
|
||||||
|
itemCount = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUI(item: ItemListType, persistentContainer: PersistentContainer) {
|
||||||
|
var config = UIListContentConfiguration.valueCell()
|
||||||
|
config.text = item.title
|
||||||
|
if let itemCount {
|
||||||
|
config.secondaryText = itemCount.formatted(.number)
|
||||||
|
}
|
||||||
|
config.secondaryTextProperties.color = .tintColor
|
||||||
|
self.contentConfiguration = config
|
||||||
|
|
||||||
|
currentItemCountTask = Task(priority: .userInitiated) {
|
||||||
|
let state = signposter.beginInterval("fetch count", id: signposter.makeSignpostID(), "\(String(item.hashValue, radix: 16), privacy: .public)")
|
||||||
|
if let count = await fetchCount(item: item, in: persistentContainer),
|
||||||
|
!Task.isCancelled {
|
||||||
|
self.itemCount = count
|
||||||
|
config.secondaryText = count.formatted(.number)
|
||||||
|
self.contentConfiguration = config
|
||||||
|
}
|
||||||
|
signposter.endInterval("fetch count", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchCount(item: ItemListType, in persistentContainer: PersistentContainer) async -> Int? {
|
||||||
|
guard let request = item.countFetchRequest else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return await withCheckedContinuation({ continuation in
|
||||||
|
let state = signposter.beginInterval("waiting to perform", id: signposter.makeSignpostID(), "\(String(item.hashValue, radix: 16), privacy: .public)")
|
||||||
|
persistentContainer.performBackgroundTask { context in
|
||||||
|
signposter.endInterval("waiting to perform", state)
|
||||||
|
let state = signposter.beginInterval("count", id: signposter.makeSignpostID(), "\(String(item.hashValue, radix: 16), privacy: .public)")
|
||||||
|
let count = try? context.count(for: request)
|
||||||
|
signposter.endInterval("count", state)
|
||||||
|
continuation.resume(returning: count)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -126,15 +126,8 @@ class HomeViewController: UIViewController {
|
||||||
config.text = section.title
|
config.text = section.title
|
||||||
supplementaryView.contentConfiguration = config
|
supplementaryView.contentConfiguration = config
|
||||||
}
|
}
|
||||||
let listCell = UICollectionView.CellRegistration<HomeCollectionViewCell, ItemListType> { cell, indexPath, item in
|
let listCell = UICollectionView.CellRegistration<HomeCollectionViewCell, ItemListType> { [unowned self] cell, indexPath, item in
|
||||||
var config = UIListContentConfiguration.valueCell()
|
cell.updateUI(item: item, persistentContainer: self.fervorController.persistentContainer)
|
||||||
config.text = item.title
|
|
||||||
if let req = item.countFetchRequest,
|
|
||||||
let count = try? self.fervorController.persistentContainer.viewContext.count(for: req) {
|
|
||||||
config.secondaryText = "\(count)"
|
|
||||||
config.secondaryTextProperties.color = .tintColor
|
|
||||||
}
|
|
||||||
cell.contentConfiguration = config
|
|
||||||
|
|
||||||
cell.accessories = [.disclosureIndicator()]
|
cell.accessories = [.disclosureIndicator()]
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,6 +60,22 @@ class ItemsViewController: UIViewController {
|
||||||
|
|
||||||
var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
|
var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||||
configuration.backgroundColor = .clear
|
configuration.backgroundColor = .clear
|
||||||
|
configuration.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in
|
||||||
|
guard let itemID = self.dataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let item = fervorController.persistentContainer.viewContext.object(with: itemID) as! Item
|
||||||
|
let action = UIContextualAction(style: .normal, title: item.read ? "Mark as Unread" : "Mark as Read") { _, _, completion in
|
||||||
|
Task {
|
||||||
|
await self.fervorController.markItem(item, read: !item.read)
|
||||||
|
completion(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
action.image = UIImage(systemName: item.read ? "checkmark.circle" : "checkmark.circle.fill")
|
||||||
|
return UISwipeActionsConfiguration(actions: [
|
||||||
|
action
|
||||||
|
])
|
||||||
|
}
|
||||||
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
|
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
|
||||||
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
|
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
|
||||||
collectionView.delegate = self
|
collectionView.delegate = self
|
||||||
|
|
|
@ -72,7 +72,10 @@ class ReadViewController: UIViewController {
|
||||||
webView.isOpaque = false
|
webView.isOpaque = false
|
||||||
webView.backgroundColor = .clear
|
webView.backgroundColor = .clear
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
|
// TODO: Xcode 14 RC doesn't have the macOS 13 SDK, so we can't use this
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
webView.isFindInteractionEnabled = true
|
webView.isFindInteractionEnabled = true
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
if let content = itemContentHTML() {
|
if let content = itemContentHTML() {
|
||||||
webView.loadHTMLString(content, baseURL: item.url)
|
webView.loadHTMLString(content, baseURL: item.url)
|
||||||
|
|
|
@ -11,6 +11,7 @@ import Persistence
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "net.shadowfacts.Reader", category: "WidgetHelper")
|
private let logger = Logger(subsystem: "net.shadowfacts.Reader", category: "WidgetHelper")
|
||||||
|
private let signposter = OSSignposter(logger: logger)
|
||||||
|
|
||||||
struct WidgetHelper {
|
struct WidgetHelper {
|
||||||
private init() {}
|
private init() {}
|
||||||
|
@ -26,7 +27,9 @@ struct WidgetHelper {
|
||||||
req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
|
req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
|
||||||
req.fetchLimit = 32
|
req.fetchLimit = 32
|
||||||
req.predicate = NSPredicate(format: "read = NO")
|
req.predicate = NSPredicate(format: "read = NO")
|
||||||
|
let state = signposter.beginInterval("fetch")
|
||||||
var items = (try? context.fetch(req)) ?? []
|
var items = (try? context.fetch(req)) ?? []
|
||||||
|
signposter.endInterval("fetch", state)
|
||||||
|
|
||||||
var prioritizedItems: [Item] = []
|
var prioritizedItems: [Item] = []
|
||||||
|
|
||||||
|
@ -51,6 +54,8 @@ struct WidgetHelper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("Saving widget data with first item: '\(prioritizedItems.first!.title ?? "<untitled>", privacy: .public)'")
|
||||||
|
|
||||||
WidgetData(recentItems: prioritizedItems).save(account: fervorController.account!)
|
WidgetData(recentItems: prioritizedItems).save(account: fervorController.account!)
|
||||||
|
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
|
Loading…
Reference in New Issue