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 = {
|
||||
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?
|
||||
context.parent = self.viewContext
|
||||
// the background context needs to be parented directly to the PSC
|
||||
// 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
|
||||
}()
|
||||
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>_XCCurrentVersionName</key>
|
||||
<string>Reader.xcdatamodel</string>
|
||||
<string>Reader 2.xcdatamodel</string>
|
||||
</dict>
|
||||
</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"?>
|
||||
<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">
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
|
@ -30,10 +30,4 @@
|
|||
<attribute name="lastSync" attributeType="Date" usesScalarValueType="NO"/>
|
||||
</entity>
|
||||
<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>
|
|
@ -75,7 +75,18 @@
|
|||
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.SQLDebug 1"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
</CommandLineArguments>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "SQLITE_AUTO_TRACE"
|
||||
value = "1"
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
|
|
@ -104,6 +104,7 @@ class BackgroundManager: NSObject {
|
|||
do {
|
||||
try await fervorController.persistentContainer.upsertItems(items)
|
||||
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)
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import Foundation
|
|||
import CoreData
|
||||
import Persistence
|
||||
|
||||
enum ItemListType: Hashable {
|
||||
enum ItemListType: Hashable, Equatable {
|
||||
case unread
|
||||
case all
|
||||
case group(Group)
|
||||
|
@ -52,7 +52,9 @@ enum ItemListType: Hashable {
|
|||
case .all:
|
||||
return nil
|
||||
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):
|
||||
req.predicate = NSPredicate(format: "read = NO AND feed = %@", feed)
|
||||
}
|
||||
|
|
|
@ -45,7 +45,11 @@ class AppSplitViewController: UISplitViewController {
|
|||
setViewController(sidebarNav, for: .primary)
|
||||
|
||||
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
|
||||
setViewController(secondaryNav, for: .secondary)
|
||||
|
||||
|
|
|
@ -7,9 +7,16 @@
|
|||
|
||||
import UIKit
|
||||
import Fervor
|
||||
import Persistence
|
||||
import OSLog
|
||||
|
||||
private let signposter = OSSignposter(subsystem: "net.shadowfacts.Reader", category: "HomeCollectionViewCell")
|
||||
|
||||
class HomeCollectionViewCell: UICollectionViewListCell {
|
||||
|
||||
private var currentItemCountTask: Task<Void, Never>?
|
||||
private var itemCount: Int?
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
override func updateConfiguration(using state: UICellConfigurationState) {
|
||||
var backgroundConfig = UIBackgroundConfiguration.listGroupedCell().updated(for: state)
|
||||
|
@ -22,4 +29,47 @@ class HomeCollectionViewCell: UICollectionViewListCell {
|
|||
}
|
||||
#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
|
||||
supplementaryView.contentConfiguration = config
|
||||
}
|
||||
let listCell = UICollectionView.CellRegistration<HomeCollectionViewCell, ItemListType> { cell, indexPath, item in
|
||||
var config = UIListContentConfiguration.valueCell()
|
||||
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
|
||||
let listCell = UICollectionView.CellRegistration<HomeCollectionViewCell, ItemListType> { [unowned self] cell, indexPath, item in
|
||||
cell.updateUI(item: item, persistentContainer: self.fervorController.persistentContainer)
|
||||
|
||||
cell.accessories = [.disclosureIndicator()]
|
||||
}
|
||||
|
|
|
@ -60,6 +60,22 @@ class ItemsViewController: UIViewController {
|
|||
|
||||
var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||
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)
|
||||
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
|
||||
collectionView.delegate = self
|
||||
|
|
|
@ -72,7 +72,10 @@ class ReadViewController: UIViewController {
|
|||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
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
|
||||
#endif
|
||||
}
|
||||
if let content = itemContentHTML() {
|
||||
webView.loadHTMLString(content, baseURL: item.url)
|
||||
|
|
|
@ -11,6 +11,7 @@ import Persistence
|
|||
import OSLog
|
||||
|
||||
private let logger = Logger(subsystem: "net.shadowfacts.Reader", category: "WidgetHelper")
|
||||
private let signposter = OSSignposter(logger: logger)
|
||||
|
||||
struct WidgetHelper {
|
||||
private init() {}
|
||||
|
@ -26,7 +27,9 @@ struct WidgetHelper {
|
|||
req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
|
||||
req.fetchLimit = 32
|
||||
req.predicate = NSPredicate(format: "read = NO")
|
||||
let state = signposter.beginInterval("fetch")
|
||||
var items = (try? context.fetch(req)) ?? []
|
||||
signposter.endInterval("fetch", state)
|
||||
|
||||
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!)
|
||||
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
|
|
Loading…
Reference in New Issue