Compare commits
6 Commits
1ce72b73db
...
aebd3a910d
Author | SHA1 | Date |
---|---|---|
Shadowfacts | aebd3a910d | |
Shadowfacts | 9f9a214b0a | |
Shadowfacts | a6d29b203c | |
Shadowfacts | c41a81414d | |
Shadowfacts | f320311a78 | |
Shadowfacts | 0d89a0f38f |
|
@ -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?
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue