diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift index 006773aeb..d6a807959 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift @@ -22,7 +22,7 @@ struct FeedCardNavigationLink: View { .opacity(0) .buttonStyle(PlainButtonStyle()) .onAppear { - viewModel.itemAppeared(item: item, dataService: dataService) + Task { await viewModel.itemAppeared(item: item, dataService: dataService) } } FeedCard(item: item) } @@ -60,7 +60,7 @@ struct GridCardNavigationLink: View { } }) .onAppear { - viewModel.itemAppeared(item: item, dataService: dataService) + Task { await viewModel.itemAppeared(item: item, dataService: dataService) } } } .aspectRatio(1.8, contentMode: .fill) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 0d40f279f..793d9e431 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -12,6 +12,10 @@ import Views @AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = UIDevice.isIPhone @ObservedObject var viewModel: HomeFeedViewModel + func loadItems(isRefresh: Bool) { + Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) } + } + var body: some View { Group { HomeFeedView( @@ -19,7 +23,7 @@ import Views viewModel: viewModel ) .refreshable { - viewModel.loadItems(dataService: dataService, isRefresh: true) + loadItems(isRefresh: true) } .searchable( text: $viewModel.searchTerm, @@ -35,13 +39,13 @@ import Views .onChange(of: viewModel.searchTerm) { _ in // Maybe we should debounce this, but // it feels like it works ok without - viewModel.loadItems(dataService: dataService, isRefresh: true) + loadItems(isRefresh: true) } .onChange(of: viewModel.selectedLabels) { _ in - viewModel.loadItems(dataService: dataService, isRefresh: true) + loadItems(isRefresh: true) } .onSubmit(of: .search) { - viewModel.loadItems(dataService: dataService, isRefresh: true) + loadItems(isRefresh: true) } .sheet(item: $viewModel.itemUnderLabelEdit) { item in ApplyLabelsView(mode: .item(item)) { _ in } @@ -52,7 +56,7 @@ import Views .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in // Don't refresh the list if the user is currently reading an article if viewModel.selectedLinkItem == nil { - viewModel.loadItems(dataService: dataService, isRefresh: true) + loadItems(isRefresh: true) } } .onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushJSONArticle"))) { notification in @@ -72,9 +76,9 @@ import Views ) } } - .onAppear { + .onAppear { // TODO: use task instead if viewModel.items.isEmpty { - viewModel.loadItems(dataService: dataService, isRefresh: true) + loadItems(isRefresh: true) } } } @@ -284,6 +288,10 @@ import Views } } + func loadItems(isRefresh: Bool) { + Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) } + } + var body: some View { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 325), spacing: 24)], spacing: 24) { @@ -319,7 +327,7 @@ import Views .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in DispatchQueue.main.async { if !viewModel.isLoading, offset > 240 { - viewModel.loadItems(dataService: dataService, isRefresh: true) + loadItems(isRefresh: true) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 8d4212605..3e75b4d98 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -30,14 +30,14 @@ import Views init() {} - func itemAppeared(item: LinkedItem, dataService: DataService) { + func itemAppeared(item: LinkedItem, dataService: DataService) async { if isLoading { return } let itemIndex = items.firstIndex(where: { $0.id == item.id }) let thresholdIndex = items.index(items.endIndex, offsetBy: -5) // Check if user has scrolled to the last five items in the list if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 { - loadItems(dataService: dataService, isRefresh: false) + await loadItems(dataService: dataService, isRefresh: false) } } @@ -45,66 +45,59 @@ import Views items.insert(item, at: 0) } - func loadItems(dataService: DataService, isRefresh: Bool) { + func loadItems(dataService: DataService, isRefresh: Bool) async { let thisSearchIdx = searchIdx searchIdx += 1 isLoading = true // Cache the viewer - if dataService.currentViewer == nil { Task { _ = try? await dataService.fetchViewer() } } - // TODO: fix issues with this list - dataService.libraryItemsPublisher( + let queryResult = try? await dataService.fetchLinkedItems( limit: 10, - sortDescending: true, searchQuery: searchQuery, cursor: isRefresh ? nil : cursor ) - .sink( - receiveCompletion: { [weak self] completion in - guard case .failure = completion else { return } - dataService.viewContext.perform { - let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false)] - fetchRequest.predicate = NSPredicate( - format: "serverSyncStatus != %i", Int64(ServerSyncStatus.needsDeletion.rawValue) - ) - if let fetchedItems = try? dataService.viewContext.fetch(fetchRequest) { - self?.items = fetchedItems - self?.cursor = nil - self?.isLoading = false - } - } - }, - receiveValue: { [weak self] result in - // Search results aren't guaranteed to return in order so this - // will discard old results that are returned while a user is typing. - // For example if a user types 'Canucks', often the search results - // for 'C' are returned after 'Canucks' because it takes the backend - // much longer to compute. - if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 { - return - } - self?.items = { - let itemIDs = isRefresh ? result.items : (self?.items ?? []).map(\.objectID) + result.items - var itemObjects = [LinkedItem]() - dataService.viewContext.performAndWait { - itemObjects = itemIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItem } - } - return itemObjects - }() - dataService.prefetchPages(itemSlugs: (self?.items ?? []).map(\.unwrappedSlug)) - self?.isLoading = false - self?.receivedIdx = thisSearchIdx - self?.cursor = result.cursor + // Search results aren't guaranteed to return in order so this + // will discard old results that are returned while a user is typing. + // For example if a user types 'Canucks', often the search results + // for 'C' are returned after 'Canucks' because it takes the backend + // much longer to compute. + if thisSearchIdx > 0, thisSearchIdx <= receivedIdx { + return + } + + if let queryResult = queryResult { + items = { + let itemIDs = isRefresh ? queryResult.items : items.map(\.objectID) + queryResult.items + var itemObjects = [LinkedItem]() + dataService.viewContext.performAndWait { + itemObjects = itemIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItem } + } + return itemObjects + }() + dataService.prefetchPages(itemSlugs: items.map(\.unwrappedSlug)) + isLoading = false + receivedIdx = thisSearchIdx + cursor = queryResult.cursor + } else { + await dataService.viewContext.perform { + let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false)] + fetchRequest.predicate = NSPredicate( + format: "serverSyncStatus != %i", Int64(ServerSyncStatus.needsDeletion.rawValue) + ) + if let fetchedItems = try? dataService.viewContext.fetch(fetchRequest) { + self.items = fetchedItems + self.cursor = nil + self.isLoading = false + } } - ) - .store(in: &subscriptions) + } } func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) { diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index 3df45fda1..dcce41ed7 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -1,7 +1,7 @@ import CoreData import Foundation -public struct HomeFeedData { +public struct HomeFeedData { // TODO: rename this public let items: [NSManagedObjectID] public let cursor: String? diff --git a/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift b/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift index d242c0b23..e64cee327 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift @@ -36,7 +36,6 @@ extension DataService { } private func syncLinkedItems(unsyncedLinkedItems: [LinkedItem]) { - logger.debug("SYNCHINGGG") for item in unsyncedLinkedItems { guard let syncStatus = ServerSyncStatus(rawValue: Int(item.serverSyncStatus)) else { continue } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift index c9e7d8ed2..f152d8bc6 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift @@ -5,17 +5,13 @@ import Models import SwiftGraphQL public extension DataService { - // swiftlint:disable:next function_body_length - func libraryItemsPublisher( + func fetchLinkedItems( limit: Int, - sortDescending: Bool, searchQuery: String?, cursor: String? - ) -> AnyPublisher { - // Attempt to sync items that have unsynched items - Task { - try? await syncOfflineItemsWithServerIfNeeded() - } + ) async throws -> HomeFeedData { + // Send offline changes to server before fetching items + try? await syncOfflineItemsWithServerIfNeeded() struct InternalHomeFeedData { let items: [InternalLinkedItem] @@ -50,7 +46,7 @@ public extension DataService { sharedOnly: .present(false), sort: OptionalArgument( InputObjects.SortParams( - order: .present(sortDescending ? .descending : .ascending), + order: .present(.descending), by: .updatedTime ) ), @@ -64,33 +60,29 @@ public extension DataService { let path = appEnvironment.graphqlPath let headers = networker.defaultHeaders - return Deferred { - Future { promise in - send(query, to: path, headers: headers) { result in - switch result { - case let .success(payload): - switch payload.data { - case let .success(result: result): - if let items = result.items.persist(context: self.backgroundContext) { - promise(.success(HomeFeedData(items: items.map(\.objectID), cursor: result.cursor))) - } else { - promise(.failure(.unknown)) - } - case .error: - promise(.failure(.unknown)) - } - case .failure: - promise(.failure(.unknown)) + return try await withCheckedThrowingContinuation { continuation in + send(query, to: path, headers: headers) { [weak self] queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + + switch payload.data { + case let .success(result: result): + if let context = self?.backgroundContext, let items = result.items.persist(context: context) { + continuation.resume(returning: HomeFeedData(items: items.map(\.objectID), cursor: result.cursor)) + } else { + continuation.resume(throwing: BasicError.message(messageText: "CoreData error")) } + case .error: + continuation.resume(throwing: BasicError.message(messageText: "LinkedItem fetch error")) } } } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() } } -let homeFeedItemSelection = Selection.Article { +private let articleSelection = Selection.Article { InternalLinkedItem( id: try $0.id(), title: try $0.title(), @@ -114,5 +106,5 @@ let homeFeedItemSelection = Selection.Article { } private let articleEdgeSelection = Selection.ArticleEdge { - try $0.node(selection: homeFeedItemSelection) + try $0.node(selection: articleSelection) }