Compare commits

...

6 Commits

7 changed files with 196 additions and 48 deletions

View File

@ -131,6 +131,20 @@ public class FervorClient {
return try await performRequest(request)
}
public func read(ids: [FervorID]) async throws -> [FervorID] {
var request = URLRequest(url: buildURL(path: "/api/v1/items/read"))
request.httpMethod = "POST"
request.setURLEncodedBody(params: ["ids": ids.joined(separator: ",")])
return try await performRequest(request)
}
public func unread(ids: [FervorID]) async throws -> [FervorID] {
var request = URLRequest(url: buildURL(path: "/api/v1/items/unread"))
request.httpMethod = "POST"
request.setURLEncodedBody(params: ["ids": ids.joined(separator: ",")])
return try await performRequest(request)
}
public struct Auth {
public let accessToken: String
public let refreshToken: String?

View File

@ -102,7 +102,7 @@ class PersistentContainer: NSPersistentContainer {
try await self.saveViewContext()
}
func syncItems(_ syncUpdate: ItemsSyncUpdate) async throws {
func syncItems(_ syncUpdate: ItemsSyncUpdate, setSyncState: @escaping (FervorController.SyncState) -> Void) async throws {
try await backgroundContext.perform {
self.logger.debug("syncItems: deleting \(syncUpdate.delete.count, privacy: .public) items")
let deleteReq = Item.fetchRequest()
@ -122,7 +122,8 @@ class PersistentContainer: NSPersistentContainer {
let existing = try self.backgroundContext.fetch(req)
self.logger.debug("syncItems: updating \(existing.count, privacy: .public) items, inserting \(syncUpdate.upsert.count - existing.count, privacy: .public)")
// todo: this feels like it'll be slow when there are many items
for item in syncUpdate.upsert {
for (index, item) in syncUpdate.upsert.enumerated() {
setSyncState(.updateItems(current: index, total: syncUpdate.upsert.count))
if let existing = existing.first(where: { $0.id == item.id }) {
existing.updateFromServer(item)
} else {

View File

@ -15,12 +15,13 @@ public struct ExcerptGenerator {
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ExcerptGenerator")
static func generateAll(_ fervorController: FervorController) {
static func generateAll(_ fervorController: FervorController) async {
let req = Item.fetchRequest()
req.predicate = NSPredicate(format: "generatedExcerpt = NO")
req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
req.fetchBatchSize = 50
fervorController.persistentContainer.performBackgroundTask { ctx in
let ctx = fervorController.persistentContainer.newBackgroundContext()
await ctx.perform {
guard let items = try? ctx.fetch(req) else { return }
var count = 0
for item in items {
@ -45,7 +46,7 @@ public struct ExcerptGenerator {
NSUpdatedObjectsKey: Array(updated)
], into: [fervorController.persistentContainer.viewContext])
} catch {
logger.error("Unable to save context: \(error.localizedDescription, privacy: .public)")
logger.error("Unable to save context: \(String(describing: error), privacy: .public)")
}
}
}

View File

@ -8,6 +8,7 @@
import Foundation
import Fervor
import OSLog
import Combine
class FervorController {
@ -25,6 +26,8 @@ class FervorController {
private(set) var persistentContainer: PersistentContainer!
@Published private(set) var syncState = SyncState.done
init(instanceURL: URL) {
self.instanceURL = instanceURL
self.client = FervorClient(instanceURL: instanceURL, accessToken: nil)
@ -42,6 +45,12 @@ class FervorController {
self.persistentContainer = PersistentContainer(account: account, fervorController: self)
}
private func setSyncState(_ state: SyncState) {
DispatchQueue.main.async {
self.syncState = state
}
}
func register() async throws -> ClientRegistration {
let registration = try await client.register(clientName: "Frenzy iOS", website: nil, redirectURI: FervorController.oauthRedirectURI)
clientID = registration.clientID
@ -56,49 +65,64 @@ class FervorController {
}
func syncAll() async throws {
guard syncState == .done else {
return
}
// always return to .done, even if we throw and stop syncing early
defer { setSyncState(.done) }
setSyncState(.groupsAndFeeds)
logger.info("Syncing groups and feeds")
async let groups = try client.groups()
async let feeds = try client.feeds()
try await persistentContainer.sync(serverGroups: groups, serverFeeds: feeds)
setSyncState(.items)
let lastSync = try await persistentContainer.lastSyncDate()
logger.info("Syncing items with last sync date: \(String(describing: lastSync), privacy: .public)")
let update = try await client.syncItems(lastSync: lastSync)
try await persistentContainer.syncItems(update)
try await persistentContainer.syncItems(update, setSyncState: setSyncState(_:))
try await persistentContainer.updateLastSyncDate(update.syncTimestamp)
setSyncState(.excerpts)
await ExcerptGenerator.generateAll(self)
}
@MainActor
func syncReadToServer() async throws {
func syncReadToServer() async {
var count = 0
// todo: there should be a batch update api endpoint
for case let item as Item in persistentContainer.viewContext.updatedObjects {
let f = item.read ? client.read(item:) : client.unread(item:)
do {
let _ = try await f(item.id!)
count += 1
} catch {
logger.error("Failed to sync read state: \(error.localizedDescription, privacy: .public)")
item.needsReadStateSync = true
}
}
// try to sync items which failed last time
let req = Item.fetchRequest()
req.predicate = NSPredicate(format: "needsReadStateSync = YES")
if let needsSync = try? persistentContainer.viewContext.fetch(req) {
for item in needsSync {
let f = item.read ? client.read(item:) : client.unread(item:)
if var needsSync = try? persistentContainer.viewContext.fetch(req) {
let firstReadIndex = needsSync.partition(by: \.read)
let unreadIDs = needsSync[..<firstReadIndex].map(\.id.unsafelyUnwrapped)
let readIDs = needsSync[firstReadIndex...].map(\.id.unsafelyUnwrapped)
var updatedIDs = Set<FervorID>()
if !unreadIDs.isEmpty {
do {
let _ = try await f(item.id!)
count += 1
item.needsReadStateSync = false
let ids = try await client.unread(ids: unreadIDs)
updatedIDs.formUnion(ids)
} catch {
logger.error("Failed to sync read state again: \(error.localizedDescription, privacy: .public)")
item.needsReadStateSync = true
// todo: this should probably fail after a certain number of attempts
logger.error("Failed to sync unread state: \(String(describing: error), privacy: .public)")
}
}
if !readIDs.isEmpty {
do {
let ids = try await client.read(ids: readIDs)
updatedIDs.formUnion(ids)
} catch {
logger.error("Failed to sync read state: \(String(describing: error), privacy: .public)")
}
}
count += updatedIDs.count
for item in needsSync where updatedIDs.contains(item.id!) {
item.needsReadStateSync = false
}
}
logger.info("Synced \(count, privacy: .public) read/unread to server")
@ -106,7 +130,7 @@ class FervorController {
do {
try persistentContainer.viewContext.save()
} catch {
logger.error("Failed to save view context: \(error.localizedDescription, privacy: .public)")
logger.error("Failed to save view context: \(String(describing: error), privacy: .public)")
}
}
@ -118,7 +142,7 @@ class FervorController {
_ = try await f(item.id!)
item.needsReadStateSync = false
} catch {
logger.error("Failed to mark item (un)read: \(error.localizedDescription, privacy: .public)")
logger.error("Failed to mark item (un)read: \(String(describing: error), privacy: .public)")
item.needsReadStateSync = true
}
@ -126,9 +150,19 @@ class FervorController {
do {
try persistentContainer.viewContext.save()
} catch {
logger.error("Failed to save view context: \(error.localizedDescription, privacy: .public)")
logger.error("Failed to save view context: \(String(describing: error), privacy: .public)")
}
}
}
}
extension FervorController {
enum SyncState: Equatable {
case groupsAndFeeds
case items
case updateItems(current: Int, total: Int)
case excerpts
case done
}
}

View File

@ -81,11 +81,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
if let fervorController = fervorController {
Task(priority: .userInitiated) {
do {
try await fervorController.syncReadToServer()
} catch {
logger.error("Unable to sync read state to server: \(error.localizedDescription, privacy: .public)")
}
await fervorController.syncReadToServer()
}
}
}
@ -111,12 +107,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
Task(priority: .userInitiated) {
do {
try await self.fervorController.syncAll()
try await fervorController.syncAll()
} catch {
logger.error("Unable to sync from server: \(error.localizedDescription, privacy: .public)")
logger.error("Unable to sync from server: \(String(describing: error), privacy: .public)")
}
ExcerptGenerator.generateAll(fervorController)
}
}
@ -131,6 +125,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
}
func switchToAccount(_ account: LocalData.Account) {
LocalData.mostRecentAccountID = account.id
fervorController = FervorController(account: account)
createAppUI()
syncFromServer()
}
}
extension SceneDelegate: LoginViewControllerDelegate {
@ -147,15 +148,6 @@ extension SceneDelegate: LoginViewControllerDelegate {
}
}
extension SceneDelegate: HomeViewControllerDelegate {
func switchToAccount(_ account: LocalData.Account) {
LocalData.mostRecentAccountID = account.id
fervorController = FervorController(account: account)
createAppUI()
syncFromServer()
}
}
#if targetEnvironment(macCatalyst)
extension NSToolbarItem.Identifier {
static let toggleItemRead = NSToolbarItem.Identifier("ToggleItemRead")

View File

@ -49,6 +49,7 @@ class AppSplitViewController: UISplitViewController {
setViewController(secondaryNav, for: .secondary)
let home = HomeViewController(fervorController: fervorController)
home.delegate = self
let nav = AppNavigationController(rootViewController: home)
setViewController(nav, for: .compact)
}
@ -61,3 +62,10 @@ extension AppSplitViewController: ItemsViewControllerDelegate {
}
}
extension AppSplitViewController: HomeViewControllerDelegate {
func switchToAccount(_ account: LocalData.Account) {
if let delegate = view.window?.windowScene?.delegate as? SceneDelegate {
delegate.switchToAccount(account)
}
}
}

View File

@ -7,6 +7,7 @@
import UIKit
import CoreData
import Combine
protocol HomeViewControllerDelegate: AnyObject {
func switchToAccount(_ account: LocalData.Account)
@ -26,6 +27,10 @@ class HomeViewController: UIViewController {
private var groupResultsController: NSFetchedResultsController<Group>!
private var feedResultsController: NSFetchedResultsController<Feed>!
private var lastSyncState = FervorController.SyncState.done
private var syncStateView: SyncStateView?
private var cancellables = Set<AnyCancellable>()
init(fervorController: FervorController) {
self.fervorController = fervorController
@ -87,6 +92,13 @@ class HomeViewController: UIViewController {
feedResultsController = NSFetchedResultsController(fetchRequest: feedReq, managedObjectContext: fervorController.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
feedResultsController.delegate = self
try! feedResultsController.performFetch()
fervorController.$syncState
.debounce(for: .milliseconds(250), scheduler: RunLoop.main, options: nil)
.sink { [unowned self] in
self.syncStateChanged($0)
}
.store(in: &cancellables)
}
override func viewWillAppear(_ animated: Bool) {
@ -136,6 +148,56 @@ class HomeViewController: UIViewController {
return dataSource
}
private func syncStateChanged(_ newState: FervorController.SyncState) {
if newState == .done && syncStateView == nil {
return
}
func updateView(_ syncStateView: SyncStateView) {
switch newState {
case .groupsAndFeeds:
syncStateView.label.text = "Syncing groups and feeds"
case .items:
syncStateView.label.text = "Syncing items"
case .updateItems(current: let current, total: let total):
syncStateView.label.text = "Updating \(current + 1) of \(total) item\(total == 1 ? "" : "s")"
case .excerpts:
syncStateView.label.text = "Generating excerpts"
case .done:
syncStateView.label.text = "Done syncing"
UIView.animate(withDuration: 0.25, delay: 1, options: .curveEaseIn) {
syncStateView.transform = CGAffineTransform(translationX: 0, y: syncStateView.bounds.height)
} completion: { _ in
syncStateView.removeFromSuperview()
}
}
}
if let syncStateView = self.syncStateView {
updateView(syncStateView)
} else {
let syncStateView = SyncStateView()
self.syncStateView = syncStateView
syncStateView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(syncStateView)
NSLayoutConstraint.activate([
syncStateView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
syncStateView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
syncStateView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
view.layoutIfNeeded()
syncStateView.transform = CGAffineTransform(translationX: 0, y: syncStateView.bounds.height)
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) {
syncStateView.transform = .identity
} completion: { _ in
updateView(syncStateView)
}
}
}
}
extension HomeViewController {
@ -307,3 +369,39 @@ extension HomeViewController: LoginViewControllerDelegate {
(view.window!.windowScene!.delegate as! SceneDelegate).didLogin(with: controller)
}
}
private class SyncStateView: UIView {
let label = UILabel()
init() {
super.init(frame: .zero)
let blur = UIBlurEffect(style: .regular)
let blurView = UIVisualEffectView(effect: blur)
blurView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurView)
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
NSLayoutConstraint.activate([
blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
blurView.topAnchor.constraint(equalTo: topAnchor),
blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
label.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor),
label.centerYAnchor.constraint(equalTo: safeAreaLayoutGuide.centerYAnchor),
safeAreaLayoutGuide.heightAnchor.constraint(equalToConstant: 50),
])
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.1
layer.shadowRadius = 10
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}