From 00da250b21c0c79bc63603f6d94aa08f47487ca8 Mon Sep 17 00:00:00 2001 From: Marcin Czachurski Date: Wed, 19 Apr 2023 15:32:25 +0200 Subject: [PATCH] #45 Improvements on home timeline. Changes: - downloading 40 statuses - fixing duplicates - downloading only by user push to refresh --- CoreData/StatusData+Status.swift | 33 ++++++++ CoreData/StatusDataHandler.swift | 32 ++++++-- Vernissage.xcodeproj/project.pbxproj | 12 +-- Vernissage/Services/HomeTimelineService.swift | 77 ++++++++++++------- Vernissage/Views/HomeFeedView.swift | 13 +++- 5 files changed, 126 insertions(+), 41 deletions(-) diff --git a/CoreData/StatusData+Status.swift b/CoreData/StatusData+Status.swift index 76efcb8..b0f7385 100644 --- a/CoreData/StatusData+Status.swift +++ b/CoreData/StatusData+Status.swift @@ -44,4 +44,37 @@ extension StatusData { self.visibility = status.visibility.rawValue } } + + func updateFrom(_ status: Status) { + if let reblog = status.reblog { + self.updateFrom(reblog) + + self.rebloggedAccountAvatar = status.account.avatar + self.rebloggedAccountDisplayName = status.account.displayName + self.rebloggedAccountId = status.account.id + self.rebloggedAccountUsername = status.account.acct + } else { + self.accountAvatar = status.account.avatar + self.accountDisplayName = status.account.displayName + self.accountUsername = status.account.acct + self.applicationName = status.application?.name + self.applicationWebsite = status.application?.website + self.bookmarked = status.bookmarked + self.content = status.content.htmlValue + self.favourited = status.favourited + self.favouritesCount = Int32(status.favouritesCount) + self.inReplyToAccount = status.inReplyToAccount + self.inReplyToId = status.inReplyToId + self.muted = status.muted + self.pinned = status.pinned + self.reblogged = status.reblogged + self.reblogsCount = Int32(status.reblogsCount) + self.repliesCount = Int32(status.repliesCount) + self.sensitive = status.sensitive + self.spoilerText = status.spoilerText + self.uri = status.uri + self.url = status.url + self.visibility = status.visibility.rawValue + } + } } diff --git a/CoreData/StatusDataHandler.swift b/CoreData/StatusDataHandler.swift index c520e98..2928e2b 100644 --- a/CoreData/StatusDataHandler.swift +++ b/CoreData/StatusDataHandler.swift @@ -12,8 +12,8 @@ class StatusDataHandler { public static let shared = StatusDataHandler() private init() { } - func getAllStatuses(accountId: String) -> [StatusData] { - let context = CoreDataHandler.shared.container.viewContext + func getAllStatuses(accountId: String, viewContext: NSManagedObjectContext? = nil) -> [StatusData] { + let context = viewContext ?? CoreDataHandler.shared.container.viewContext let fetchRequest = StatusData.fetchRequest() let sortDescriptor = NSSortDescriptor(key: "id", ascending: true) @@ -28,6 +28,26 @@ class StatusDataHandler { } } + func getAllOlderStatuses(accountId: String, statusId: String, viewContext: NSManagedObjectContext? = nil) -> [StatusData] { + let context = viewContext ?? CoreDataHandler.shared.container.viewContext + let fetchRequest = StatusData.fetchRequest() + + let sortDescriptor = NSSortDescriptor(key: "id", ascending: true) + fetchRequest.sortDescriptors = [sortDescriptor] + + let predicate1 = NSPredicate(format: "id < %@", statusId) + let predicate2 = NSPredicate(format: "pixelfedAccount.id = %@", accountId) + + fetchRequest.predicate = NSCompoundPredicate.init(type: .and, subpredicates: [predicate1, predicate2]) + + do { + return try context.fetch(fetchRequest) + } catch { + CoreDataError.shared.handle(error, message: "Error during fetching status (getStatusData).") + return [] + } + } + func getStatusData(accountId: String, statusId: String, viewContext: NSManagedObjectContext? = nil) -> StatusData? { let context = viewContext ?? CoreDataHandler.shared.container.viewContext let fetchRequest = StatusData.fetchRequest() @@ -84,13 +104,13 @@ class StatusDataHandler { } } - func remove(accountId: String, statusId: String) { + func remove(accountId: String, statusId: String, viewContext: NSManagedObjectContext? = nil) { let status = self.getStatusData(accountId: accountId, statusId: statusId) guard let status else { return } - let context = CoreDataHandler.shared.container.viewContext + let context = viewContext ?? CoreDataHandler.shared.container.viewContext context.delete(status) do { @@ -100,8 +120,8 @@ class StatusDataHandler { } } - func remove(accountId: String, statuses: [StatusData]) { - let context = CoreDataHandler.shared.container.viewContext + func remove(accountId: String, statuses: [StatusData], viewContext: NSManagedObjectContext? = nil) { + let context = viewContext ?? CoreDataHandler.shared.container.viewContext for status in statuses { context.delete(status) diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index 3ff8c47..e12bee5 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -1187,7 +1187,7 @@ CODE_SIGN_ENTITLEMENTS = VernissageWidget/VernissageWidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 122; + CURRENT_PROJECT_VERSION = 123; DEVELOPMENT_TEAM = B2U9FEKYP8; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = VernissageWidget/Info.plist; @@ -1215,7 +1215,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = VernissageWidget/VernissageWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 122; + CURRENT_PROJECT_VERSION = 123; DEVELOPMENT_TEAM = B2U9FEKYP8; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = VernissageWidget/Info.plist; @@ -1242,7 +1242,7 @@ CODE_SIGN_ENTITLEMENTS = VernissageShare/VernissageShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 122; + CURRENT_PROJECT_VERSION = 123; DEVELOPMENT_TEAM = B2U9FEKYP8; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = VernissageShare/Info.plist; @@ -1269,7 +1269,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = VernissageShare/VernissageShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 122; + CURRENT_PROJECT_VERSION = 123; DEVELOPMENT_TEAM = B2U9FEKYP8; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = VernissageShare/Info.plist; @@ -1418,7 +1418,7 @@ CODE_SIGN_ENTITLEMENTS = Vernissage/Vernissage.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 122; + CURRENT_PROJECT_VERSION = 123; DEVELOPMENT_ASSET_PATHS = "\"Vernissage/Preview Content\""; DEVELOPMENT_TEAM = B2U9FEKYP8; ENABLE_PREVIEWS = YES; @@ -1459,7 +1459,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Vernissage/Vernissage.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 122; + CURRENT_PROJECT_VERSION = 123; DEVELOPMENT_ASSET_PATHS = "\"Vernissage/Preview Content\""; DEVELOPMENT_TEAM = B2U9FEKYP8; ENABLE_PREVIEWS = YES; diff --git a/Vernissage/Services/HomeTimelineService.swift b/Vernissage/Services/HomeTimelineService.swift index fa9feeb..734933b 100644 --- a/Vernissage/Services/HomeTimelineService.swift +++ b/Vernissage/Services/HomeTimelineService.swift @@ -15,6 +15,8 @@ public class HomeTimelineService { public static let shared = HomeTimelineService() private init() { } + private let defaultAmountOfDownloadedStatuses = 40 + public func loadOnBottom(for account: AccountModel) async throws -> Int { // Load data from API and operate on CoreData on background context. let backgroundContext = CoreDataHandler.shared.newBackgroundContext() @@ -36,7 +38,7 @@ public class HomeTimelineService { return newStatuses.count } - public func loadOnTop(for account: AccountModel) async throws -> String? { + public func refreshTimeline(for account: AccountModel) async throws -> String? { // Load data from API and operate on CoreData on background context. let backgroundContext = CoreDataHandler.shared.newBackgroundContext() @@ -113,7 +115,7 @@ public class HomeTimelineService { // There can be more then 40 newest statuses, that's why we have to sometimes send more then one request. while true { do { - let downloadedStatuses = try await client.getHomeTimeline(minId: newestStatusId, limit: 40) + let downloadedStatuses = try await client.getHomeTimeline(minId: newestStatusId, limit: self.defaultAmountOfDownloadedStatuses) guard let firstStatus = downloadedStatuses.first else { break } @@ -139,38 +141,59 @@ public class HomeTimelineService { // Retrieve statuses from API. let client = PixelfedClient(baseURL: account.serverUrl).getAuthenticated(token: accessToken) - let statuses = try await client.getHomeTimeline(limit: 40) + let statuses = try await client.getHomeTimeline(limit: self.defaultAmountOfDownloadedStatuses) - // Retrieve all statuses from database. - let dbStatuses = StatusDataHandler.shared.getAllStatuses(accountId: account.id) - let lastSeenStatusId = dbStatuses.last?.rebloggedStatusId ?? dbStatuses.last?.id + // Retrieve newest visible status (last visible by user). + let dbNewestStatus = StatusDataHandler.shared.getMaximumStatus(accountId: account.id, viewContext: backgroundContext) + let lastSeenStatusId = dbNewestStatus?.rebloggedStatusId ?? dbNewestStatus?.id - // Remove statuses that are not in 40 downloaded once. - var dbStatusesToRemove: [StatusData] = [] - for dbStatus in dbStatuses where !statuses.contains(where: { status in status.id == dbStatus.id }) { - dbStatusesToRemove.append(dbStatus) - } - - if !dbStatusesToRemove.isEmpty { - StatusDataHandler.shared.remove(accountId: account.id, statuses: dbStatusesToRemove) - } - - // Update existing one. - for dbStatus in dbStatuses { - if let status = statuses.first(where: { item in item.id == dbStatus.id}) { - dbStatus.favourited = status.favourited + // Update all existing statuses in database. + for status in statuses { + if let dbStatus = StatusDataHandler.shared.getStatusData(accountId: account.id, statusId: status.id, viewContext: backgroundContext) { + dbStatus.updateFrom(status) } } // Add statuses which are not existing in database, but has been downloaded via API. var statusesToAdd: [Status] = [] - for status in statuses where !dbStatuses.contains(where: { statusData in statusData.id == status.id }) { + for status in statuses where StatusDataHandler.shared.getStatusData(accountId: account.id, + statusId: status.id, + viewContext: backgroundContext) == nil { statusesToAdd.append(status) } + // Collection with statuses to remove from database. + var dbStatusesToRemove: [StatusData] = [] + + // Find statuses to delete (older then the last one from API). + if let lastStatus = statuses.last { + let dbOlderStatuses = StatusDataHandler.shared.getAllOlderStatuses(accountId: account.id, + statusId: lastStatus.id, + viewContext: backgroundContext) + if !dbOlderStatuses.isEmpty { + dbStatusesToRemove.append(contentsOf: dbOlderStatuses) + } + } + + // Find statuses to delete (duplicates). + var existingStatusIds: [String] = [] + let allDbStatuses = StatusDataHandler.shared.getAllStatuses(accountId: account.id, viewContext: backgroundContext) + for dbStatus in allDbStatuses { + if existingStatusIds.contains(where: { $0 == dbStatus.id }) { + dbStatusesToRemove.append(dbStatus) + } else { + existingStatusIds.append(dbStatus.id) + } + } + + // Delete statuses from database. + if !dbStatusesToRemove.isEmpty { + StatusDataHandler.shared.remove(accountId: account.id, statuses: dbStatusesToRemove, viewContext: backgroundContext) + } + // Save statuses in database. if !statusesToAdd.isEmpty { - _ = try await self.save(statuses: statusesToAdd, for: account, on: backgroundContext) + _ = try await self.add(statusesToAdd, for: account, on: backgroundContext) } return lastSeenStatusId @@ -187,15 +210,15 @@ public class HomeTimelineService { // Retrieve statuses from API. let client = PixelfedClient(baseURL: account.serverUrl).getAuthenticated(token: accessToken) - let statuses = try await client.getHomeTimeline(maxId: maxId, minId: minId, limit: 20) + let statuses = try await client.getHomeTimeline(maxId: maxId, minId: minId, limit: self.defaultAmountOfDownloadedStatuses) // Save statuses in database. - return try await self.save(statuses: statuses, for: account, on: backgroundContext) + return try await self.add(statuses, for: account, on: backgroundContext) } - private func save(statuses: [Status], - for account: AccountModel, - on backgroundContext: NSManagedObjectContext + private func add(_ statuses: [Status], + for account: AccountModel, + on backgroundContext: NSManagedObjectContext ) async throws -> [Status] { guard let accountDataFromDb = AccountDataHandler.shared.getAccountData(accountId: account.id, viewContext: backgroundContext) else { diff --git a/Vernissage/Views/HomeFeedView.swift b/Vernissage/Views/HomeFeedView.swift index 0bb63d2..4362f6d 100644 --- a/Vernissage/Views/HomeFeedView.swift +++ b/Vernissage/Views/HomeFeedView.swift @@ -101,7 +101,7 @@ struct HomeFeedView: View { private func refreshData() async { do { if let account = self.applicationState.account { - if let lastSeenStatusId = try await HomeTimelineService.shared.loadOnTop(for: account) { + if let lastSeenStatusId = try await HomeTimelineService.shared.refreshTimeline(for: account) { try await HomeTimelineService.shared.save(lastSeenStatusId: lastSeenStatusId, for: account) self.applicationState.lastSeenStatusId = lastSeenStatusId @@ -115,8 +115,17 @@ struct HomeFeedView: View { private func loadData() async { do { + // We have to load data automatically only when the database is empty. + guard self.dbStatuses.isEmpty else { + withAnimation { + self.state = .loaded + } + + return + } + if let account = self.applicationState.account { - _ = try await HomeTimelineService.shared.loadOnTop(for: account) + _ = try await HomeTimelineService.shared.refreshTimeline(for: account) } self.applicationState.amountOfNewStatuses = 0