diff --git a/apple/Omnivore.xcodeproj/project.pbxproj b/apple/Omnivore.xcodeproj/project.pbxproj index 0a4d64fc6..c0407f6a6 100644 --- a/apple/Omnivore.xcodeproj/project.pbxproj +++ b/apple/Omnivore.xcodeproj/project.pbxproj @@ -1251,7 +1251,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist; @@ -1261,7 +1261,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; @@ -1282,7 +1282,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist; @@ -1292,7 +1292,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1363,7 +1363,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; @@ -1374,7 +1374,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; @@ -1397,7 +1397,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; @@ -1408,7 +1408,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1463,7 +1463,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.2; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1495,7 +1495,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.4.2; + MARKETING_VERSION = 1.5.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1534,7 +1534,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.4.2; + MARKETING_VERSION = 1.5.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1573,7 +1573,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1611,7 +1611,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1696,7 +1696,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.4.2; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; SDKROOT = iphoneos; @@ -1750,7 +1750,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.2; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1778,7 +1778,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.4.2; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; SDKROOT = iphoneos; diff --git a/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/Omnivore-iOS.xcscheme b/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/Omnivore-iOS.xcscheme index da134da69..219adbd00 100644 --- a/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/Omnivore-iOS.xcscheme +++ b/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/Omnivore-iOS.xcscheme @@ -60,6 +60,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + enableThreadSanitizer = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" @@ -76,6 +77,13 @@ ReferencedContainer = "container:Omnivore.xcodeproj"> + + + + () let services: Services - public init(services: Services, feedItem: FeedItem) { + public init(services: Services, linkedItem: LinkedItem) { self.services = services - self.feedItem = feedItem + self.linkedItem = linkedItem } - public func loadHighlights(completion onComplete: @escaping ([Highlight]) -> Void) { - guard let viewer = services.dataService.currentViewer else { return } - - services.dataService.pdfHighlightsPublisher(username: viewer.username, slug: feedItem.slug).sink( - receiveCompletion: { [weak self] completion in - guard case .failure = completion else { return } - onComplete(self?.allHighlights(fetchedHighlights: []) ?? []) - }, - receiveValue: { [weak self] highlights in - onComplete(self?.allHighlights(fetchedHighlights: highlights) ?? []) - } - ) - .store(in: &subscriptions) - } - - private func allHighlights(fetchedHighlights: [Highlight]) -> [Highlight] { - var resultSet = [String: Highlight]() - - for highlight in services.dataService.cachedHighlights(pdfID: feedItem.id) { - resultSet[highlight.id] = highlight - } - for highlight in fetchedHighlights { - resultSet[highlight.id] = highlight - } - for highlightId in services.dataService.fetchRemovedHighlightIds(pdfID: feedItem.id) { - resultSet.removeValue(forKey: highlightId) - } - return Array(resultSet.values) + public func loadHighlightPatches(completion onComplete: @escaping ([String]) -> Void) { + onComplete(linkedItem.highlights.asArray(of: Highlight.self).map { $0.patch ?? "" }) } public func createHighlight(shortId: String, highlightID: String, quote: String, patch: String) { - services.dataService.persistHighlight( - pdfID: feedItem.id, - highlight: Highlight( - id: highlightID, - shortId: shortId, - quote: quote, - prefix: nil, - suffix: nil, - patch: patch, - annotation: nil, - createdByMe: true - ) + _ = services.dataService.createHighlight( + shortId: shortId, + highlightID: highlightID, + quote: quote, + patch: patch, + articleId: linkedItem.unwrappedID ) - - services.dataService - .createHighlightPublisher( - shortId: shortId, - highlightID: highlightID, - quote: quote, - patch: patch, - articleId: feedItem.id - ) - .sink { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.errorMessage = error.localizedDescription - } receiveValue: { value in - print("highlight value", value) - } - .store(in: &subscriptions) } public func mergeHighlight( @@ -86,84 +39,40 @@ public final class PDFViewerViewModel: ObservableObject { patch: String, overlapHighlightIdList: [String] ) { - services.dataService.persistHighlight( - pdfID: feedItem.id, - highlight: Highlight( - id: highlightID, - shortId: shortId, - quote: quote, - prefix: nil, - suffix: nil, - patch: patch, - annotation: nil, - createdByMe: true - ) + _ = services.dataService.mergeHighlights( + shortId: shortId, + highlightID: highlightID, + quote: quote, + patch: patch, + articleId: linkedItem.unwrappedID, + overlapHighlightIdList: overlapHighlightIdList ) - - removeLocalHighlights(highlightIds: overlapHighlightIdList) - - services.dataService - .mergeHighlightPublisher( - shortId: shortId, - highlightID: highlightID, - quote: quote, - patch: patch, - articleId: feedItem.id, - overlapHighlightIdList: overlapHighlightIdList - ) - .sink { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.errorMessage = error.localizedDescription - } receiveValue: { value in - print("highlight value", value) - } - .store(in: &subscriptions) } public func removeHighlights(highlightIds: [String]) { - removeLocalHighlights(highlightIds: highlightIds) - - highlightIds.forEach { highlightId in - services.dataService.deleteHighlightPublisher(highlightId: highlightId) - .sink { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.errorMessage = error.localizedDescription - } receiveValue: { value in - print("remove highlight value", value) - } - .store(in: &subscriptions) + highlightIds.forEach { highlightID in + services.dataService.deleteHighlight(highlightID: highlightID) } } public func updateItemReadProgress(percent: Double, anchorIndex: Int) { - services.dataService - .updateArticleReadingProgressPublisher( - itemID: feedItem.id, - readingProgress: percent, - anchorIndex: anchorIndex - ) - .sink { completion in - guard case let .failure(error) = completion else { return } - print(error) - } receiveValue: { _ in - } - .store(in: &subscriptions) + services.dataService.updateLinkReadingProgress( + itemID: linkedItem.unwrappedID, + readingProgress: percent, + anchorIndex: anchorIndex + ) } public func highlightShareURL(shortId: String) -> URL? { let baseURL = services.dataService.appEnvironment.serverBaseURL var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) - if let viewer = services.dataService.currentViewer?.username { - components?.path = "/\(viewer)/\(feedItem.slug)/highlights/\(shortId)" + if let username = services.dataService.currentViewer?.username { + components?.path = "/\(username)/\(linkedItem.unwrappedSlug)/highlights/\(shortId)" } else { return nil } return components?.url } - - private func removeLocalHighlights(highlightIds: [String]) { - services.dataService.removeHighlights(pdfID: feedItem.id, highlightIds: highlightIds) - } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift index dbf0084ca..d6a807959 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift @@ -6,7 +6,7 @@ import Views struct FeedCardNavigationLink: View { @EnvironmentObject var dataService: DataService - let item: FeedItem + let item: LinkedItem @ObservedObject var viewModel: HomeFeedViewModel @@ -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) } @@ -34,7 +34,7 @@ struct GridCardNavigationLink: View { @State private var scale = 1.0 - let item: FeedItem + let item: LinkedItem let actionHandler: (GridCardAction) -> Void @Binding var isContextMenuOpen: Bool @@ -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 46720c206..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,18 +39,16 @@ 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)) { labels in - viewModel.updateLabels(itemID: item.id, labels: labels) - } + ApplyLabelsView(mode: .item(item)) { _ in } } } .navigationTitle("Home") @@ -54,17 +56,18 @@ 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("PushFeedItem"))) { notification in - if let feedItem = notification.userInfo?["feedItem"] as? FeedItem { - viewModel.pushFeedItem(item: feedItem) - viewModel.selectedLinkItem = feedItem - } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushJSONArticle"))) { notification in + guard let jsonArticle = notification.userInfo?["article"] as? JSONArticle else { return } + guard let objectID = dataService.persist(jsonArticle: jsonArticle) else { return } + guard let linkedItem = dataService.viewContext.object(with: objectID) as? LinkedItem else { return } + viewModel.pushFeedItem(item: linkedItem) + viewModel.selectedLinkItem = linkedItem } .formSheet(isPresented: $viewModel.snoozePresented) { - SnoozeView(snoozePresented: $viewModel.snoozePresented, itemToSnooze: $viewModel.itemToSnooze) { + SnoozeView(snoozePresented: $viewModel.snoozePresented, itemToSnoozeID: $viewModel.itemToSnoozeID) { viewModel.snoozeUntil( dataService: dataService, linkId: $0.feedItemId, @@ -73,14 +76,11 @@ import Views ) } } - .onAppear { + .onAppear { // TODO: use task instead if viewModel.items.isEmpty { - viewModel.loadItems(dataService: dataService, isRefresh: true) + loadItems(isRefresh: true) } } - .onChange(of: viewModel.selectedLinkItem) { _ in - viewModel.commitItemUpdates() - } } } @@ -145,7 +145,7 @@ import Views @EnvironmentObject var dataService: DataService @Binding var prefersListLayout: Bool - @State private var itemToRemove: FeedItem? + @State private var itemToRemove: LinkedItem? @State private var confirmationShown = false @ObservedObject var viewModel: HomeFeedViewModel @@ -165,7 +165,11 @@ import Views ) Button(action: { withAnimation(.linear(duration: 0.4)) { - viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived) + viewModel.setLinkArchived( + dataService: dataService, + objectID: item.objectID, + archived: !item.isArchived + ) } }, label: { Label( @@ -182,7 +186,7 @@ import Views ) if FeatureFlag.enableSnooze { Button { - viewModel.itemToSnooze = item + viewModel.itemToSnoozeID = item.id viewModel.snoozePresented = true } label: { Label { Text("Snooze") } icon: { Image.moon } @@ -193,7 +197,7 @@ import Views if !item.isArchived { Button { withAnimation(.linear(duration: 0.4)) { - viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true) + viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: true) } } label: { Label("Archive", systemImage: "archivebox") @@ -201,7 +205,7 @@ import Views } else { Button { withAnimation(.linear(duration: 0.4)) { - viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false) + viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: false) } } label: { Label("Unarchive", systemImage: "tray.and.arrow.down.fill") @@ -223,7 +227,7 @@ import Views Button("Remove Link", role: .destructive) { if let itemToRemove = itemToRemove { withAnimation { - viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id) + viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID) } } self.itemToRemove = nil @@ -233,7 +237,7 @@ import Views .swipeActions(edge: .leading, allowsFullSwipe: true) { if FeatureFlag.enableSnooze { Button { - viewModel.itemToSnooze = item + viewModel.itemToSnoozeID = item.id viewModel.snoozePresented = true } label: { Label { Text("Snooze") } icon: { Image.moon } @@ -260,25 +264,22 @@ import Views } } } - .onAppear { - viewModel.sendProgressUpdates = false - } } } struct HomeFeedGridView: View { @EnvironmentObject var dataService: DataService - @State private var itemToRemove: FeedItem? + @State private var itemToRemove: LinkedItem? @State private var confirmationShown = false @State var isContextMenuOpen = false @ObservedObject var viewModel: HomeFeedViewModel - func contextMenuActionHandler(item: FeedItem, action: GridCardAction) { + func contextMenuActionHandler(item: LinkedItem, action: GridCardAction) { switch action { case .toggleArchiveStatus: - viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived) + viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: !item.isArchived) case .delete: itemToRemove = item confirmationShown = true @@ -287,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) { @@ -301,7 +306,7 @@ import Views Button("Remove Link", role: .destructive) { if let itemToRemove = itemToRemove { withAnimation { - viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id) + viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID) } } self.itemToRemove = nil @@ -322,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) } } } @@ -331,9 +336,6 @@ import Views LoadingSection() } } - .onAppear { - viewModel.sendProgressUpdates = true - } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift index 3ca413ff5..f185006e8 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift @@ -9,7 +9,7 @@ import Views #if os(macOS) struct HomeFeedView: View { @EnvironmentObject var dataService: DataService - @State private var itemToRemove: FeedItem? + @State private var itemToRemove: LinkedItem? @State private var confirmationShown = false @ObservedObject var viewModel: HomeFeedViewModel @@ -40,7 +40,7 @@ import Views ) if FeatureFlag.enableSnooze { Button { - viewModel.itemToSnooze = item + viewModel.itemToSnoozeID = item.id viewModel.snoozePresented = true } label: { Label { Text("Snooze") } icon: { Image.moon } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index ed61a4341..1048bb76d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -1,4 +1,5 @@ import Combine +import CoreData import Models import Services import SwiftUI @@ -8,24 +9,17 @@ import Views @MainActor final class HomeFeedViewModel: ObservableObject { var currentDetailViewModel: LinkItemDetailViewModel? - /// Track progress updates to be committed when user navigates back to grid view - var uncommittedReadingProgressUpdates = [String: Double]() - - /// Track label updates to be committed when user navigates back to grid view - var uncommittedLabelUpdates = [String: [FeedItemLabel]]() - - @Published var items = [FeedItem]() + @Published var items = [LinkedItem]() @Published var isLoading = false @Published var showPushNotificationPrimer = false - @Published var itemUnderLabelEdit: FeedItem? + @Published var itemUnderLabelEdit: LinkedItem? @Published var searchTerm = "" - @Published var selectedLabels = [FeedItemLabel]() + @Published var selectedLabels = [LinkedItemLabel]() @Published var snoozePresented = false - @Published var itemToSnooze: FeedItem? - @Published var selectedLinkItem: FeedItem? + @Published var itemToSnoozeID: String? + @Published var selectedLinkItem: LinkedItem? var cursor: String? - var sendProgressUpdates = false // These are used to make sure we handle search result // responses in the right order @@ -36,114 +30,132 @@ import Views init() {} - func itemAppeared(item: FeedItem, 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 { - Task { await loadItems(dataService: dataService, isRefresh: false) } + await loadItems(dataService: dataService, isRefresh: false) } } - func pushFeedItem(item: FeedItem) { + func pushFeedItem(item: LinkedItem) { items.insert(item, at: 0) } - func loadItems(dataService: DataService, isRefresh: Bool) { - // Clear offline highlights since we'll be populating new FeedItems with the correct highlights set - dataService.clearHighlights() - + 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() } } - 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 } - 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 + + // 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 { + let newItems: [LinkedItem] = { + var itemObjects = [LinkedItem]() + dataService.viewContext.performAndWait { + itemObjects = queryResult.items.compactMap { dataService.viewContext.object(with: $0) as? LinkedItem } } + return itemObjects + }() + items = isRefresh ? newItems : items + newItems + isLoading = false + receivedIdx = thisSearchIdx + cursor = queryResult.cursor + dataService.prefetchPages(itemSlugs: newItems.map(\.unwrappedSlug)) + } else if searchTermIsEmpty { + await dataService.viewContext.perform { + let fetchRequest: NSFetchRequest = LinkedItem.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false)] + fetchRequest.predicate = self.itemRequestPredicate +// // TODO: Filter on label - dataService.prefetchPages(items: result.items) - - self?.items = isRefresh ? result.items : (self?.items ?? []) + result.items - self?.isLoading = false - self?.receivedIdx = thisSearchIdx - self?.cursor = result.cursor + if let fetchedItems = try? dataService.viewContext.fetch(fetchRequest) { + self.items = fetchedItems + self.cursor = nil + self.isLoading = false + } } + } + } + + private var itemRequestPredicate: NSPredicate { + let undeletedPredicate = NSPredicate( + format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) ) - .store(in: &subscriptions) - } - func setLinkArchived(dataService: DataService, linkId: String, archived: Bool) { - isLoading = true - - // First remove the link from the internal list, - // then make a call to remove it. The isLoading block should - // prevent our local change from being overwritten, but we - // might need to cache a local list of archived links - if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { - items.remove(at: itemIndex) + if searchTerm.contains("in:all") { + // include everything undeleted + return undeletedPredicate } - dataService.archiveLinkPublisher(itemID: linkId, archived: archived) - .sink( - receiveCompletion: { [weak self] completion in - guard case .failure = completion else { return } - self?.isLoading = false - NSNotification.operationFailed(message: archived ? "Failed to archive link" : "Failed to unarchive link") - }, - receiveValue: { [weak self] _ in - self?.isLoading = false - Snackbar.show(message: archived ? "Link archived" : "Link moved to Inbox") - } + if searchTerm.contains("in:archive") { + let inArchivePredicate = NSPredicate( + format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: true) as NSNumber ) - .store(in: &subscriptions) - } - - func removeLink(dataService: DataService, linkId: String) { - isLoading = true - - if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { - items.remove(at: itemIndex) + return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate]) } - dataService.removeLinkPublisher(itemID: linkId) - .sink( - receiveCompletion: { [weak self] completion in - guard case .failure = completion else { return } - self?.isLoading = false - Snackbar.show(message: "Failed to remove link") - }, - receiveValue: { [weak self] _ in - self?.isLoading = false - Snackbar.show(message: "Link removed") - } + if searchTerm.contains("type:file") { + // include pdf only + let isPDFPredicate = NSPredicate( + format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF" ) - .store(in: &subscriptions) + return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate]) + } + + // default to "in:inbox" (non-archived items) + let notInArchivePredicate = NSPredicate( + format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate]) + } + + // Exclude filters when testing if user has enetered a search term + private var searchTermIsEmpty: Bool { + searchTerm + .replacingOccurrences(of: "in:inbox", with: "") + .replacingOccurrences(of: "in:all", with: "") + .replacingOccurrences(of: "in:archive", with: "") + .replacingOccurrences(of: "type:file", with: "") + .replacingOccurrences(of: " ", with: "") + .isEmpty + } + + func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) { + // TODO: remove this by making list always fetch from Coredata + guard let itemIndex = items.firstIndex(where: { $0.objectID == objectID }) else { return } + items.remove(at: itemIndex) + dataService.archiveLink(objectID: objectID, archived: archived) + Snackbar.show(message: archived ? "Link archived" : "Link moved to Inbox") + } + + func removeLink(dataService: DataService, objectID: NSManagedObjectID) { + guard let itemIndex = items.firstIndex(where: { $0.objectID == objectID }) else { return } + items.remove(at: itemIndex) + Snackbar.show(message: "Link removed") + dataService.removeLink(objectID: objectID) } func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) { @@ -173,41 +185,6 @@ import Views .store(in: &subscriptions) } - /// Update `FeedItem`s with the cached reading progress and label values so it can animate when the - /// user navigates back to the grid view (and also avoid mutations of the grid items - /// that can cause the `NavigationView` to pop. - func commitItemUpdates() { - for (key, value) in uncommittedReadingProgressUpdates { - updateProgress(itemID: key, progress: value) - } - for (key, value) in uncommittedLabelUpdates { - updateLabels(itemID: key, labels: value) - } - uncommittedReadingProgressUpdates = [:] - uncommittedLabelUpdates = [:] - } - - private func updateProgress(itemID: String, progress: Double) { - guard sendProgressUpdates, let item = items.first(where: { $0.id == itemID }) else { return } - if let index = items.firstIndex(of: item) { - items[index].readingProgress = progress - } - } - - func updateLabels(itemID: String, labels: [FeedItemLabel]) { - // If item is being being displayed then delay the state update of labels until - // user is no longer reading the item. - if selectedLinkItem != nil { - uncommittedLabelUpdates[itemID] = labels - return - } - - guard let item = items.first(where: { $0.id == itemID }) else { return } - if let index = items.firstIndex(of: item) { - items[index].labels = labels - } - } - private var searchQuery: String? { if searchTerm.isEmpty, selectedLabels.isEmpty { return nil @@ -217,7 +194,7 @@ import Views if !selectedLabels.isEmpty { query.append(" label:") - query.append(selectedLabels.map(\.name).joined(separator: ",")) + query.append(selectedLabels.map { $0.name ?? "" }.joined(separator: ",")) } return query diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift index 5c81b1b70..46d326e31 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -5,8 +5,8 @@ import Views struct ApplyLabelsView: View { enum Mode { - case item(FeedItem) - case list([FeedItemLabel]) + case item(LinkedItem) + case list([LinkedItemLabel]) var navTitle: String { switch self { @@ -28,12 +28,11 @@ struct ApplyLabelsView: View { } let mode: Mode - let commitLabelChanges: ([FeedItemLabel]) -> Void + let commitLabelChanges: ([LinkedItemLabel]) -> Void @EnvironmentObject var dataService: DataService @Environment(\.presentationMode) private var presentationMode @StateObject var viewModel = LabelsViewModel() - @State private var labelSearchFilter = "" var innerBody: some View { List { @@ -41,7 +40,7 @@ struct ApplyLabelsView: View { if viewModel.selectedLabels.isEmpty { Text("No labels are currently assigned.") } - ForEach(viewModel.selectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in + ForEach(viewModel.selectedLabels.applySearchFilter(viewModel.labelSearchFilter), id: \.self) { label in HStack { TextChip(feedItemLabel: label) Spacer() @@ -57,7 +56,7 @@ struct ApplyLabelsView: View { } } Section(header: Text("Available Labels")) { - ForEach(viewModel.unselectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in + ForEach(viewModel.unselectedLabels.applySearchFilter(viewModel.labelSearchFilter), id: \.self) { label in HStack { TextChip(feedItemLabel: label) Spacer() @@ -101,7 +100,7 @@ struct ApplyLabelsView: View { action: { switch mode { case let .item(feedItem): - viewModel.saveItemLabelChanges(itemID: feedItem.id, dataService: dataService) { labels in + viewModel.saveItemLabelChanges(itemID: feedItem.unwrappedID, dataService: dataService) { labels in commitLabelChanges(labels) presentationMode.wrappedValue.dismiss() } @@ -128,7 +127,7 @@ struct ApplyLabelsView: View { #if os(iOS) innerBody .searchable( - text: $labelSearchFilter, + text: $viewModel.labelSearchFilter, placement: .navigationBarDrawer(displayMode: .always) ) #else @@ -147,11 +146,11 @@ struct ApplyLabelsView: View { } } -private extension Sequence where Element == FeedItemLabel { - func applySearchFilter(_ searchFilter: String) -> [FeedItemLabel] { +private extension Sequence where Element == LinkedItemLabel { + func applySearchFilter(_ searchFilter: String) -> [LinkedItemLabel] { if searchFilter.isEmpty { return map { $0 } // return the identity of the sequence } - return filter { $0.name.lowercased().contains(searchFilter.lowercased()) } + return filter { ($0.name ?? "").lowercased().contains(searchFilter.lowercased()) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index 35159e8ca..84bb1fa46 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -7,40 +7,39 @@ import Views final class LabelsViewModel: ObservableObject { private var hasLoadedInitialLabels = false @Published var isLoading = false - @Published var selectedLabels = [FeedItemLabel]() - @Published var unselectedLabels = [FeedItemLabel]() - @Published var labels = [FeedItemLabel]() + @Published var selectedLabels = [LinkedItemLabel]() + @Published var unselectedLabels = [LinkedItemLabel]() + @Published var labels = [LinkedItemLabel]() @Published var showCreateEmailModal = false + @Published var labelSearchFilter = "" var subscriptions = Set() - /// Loads initial set of labels when a edit labels list is displayed - /// - Parameters: - /// - dataService: `DataService` reference - /// - item: Optional `FeedItem` for applying labels to a single item - /// - initiallySelectedLabels: Optional `[FeedItemLabel]` for filtering a list of items - func loadLabels(dataService: DataService, item: FeedItem? = nil, initiallySelectedLabels: [FeedItemLabel]? = nil) { + func loadLabels( + dataService: DataService, + item: LinkedItem? = nil, + initiallySelectedLabels: [LinkedItemLabel]? = nil + ) { guard !hasLoadedInitialLabels else { return } isLoading = true dataService.labelsPublisher().sink( receiveCompletion: { _ in }, - receiveValue: { [weak self] allLabels in - self?.isLoading = false - self?.labels = allLabels - self?.hasLoadedInitialLabels = true - if let item = item { - self?.selectedLabels = item.labels - self?.unselectedLabels = allLabels.filter { label in - !item.labels.contains(where: { $0.id == label.id }) - } - } - if let initiallySelectedLabels = initiallySelectedLabels { - self?.selectedLabels = initiallySelectedLabels - self?.unselectedLabels = allLabels.filter { label in - !initiallySelectedLabels.contains(where: { $0.id == label.id }) + receiveValue: { [weak self] labelIDs in + guard let self = self else { return } + dataService.viewContext.performAndWait { + self.labels = labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel } + } + let selLabels = initiallySelectedLabels ?? item?.labels.asArray(of: LinkedItemLabel.self) ?? [] + for label in self.labels { + if selLabels.contains(label) { + self.selectedLabels.append(label) + } else { + self.unselectedLabels.append(label) } } + self.hasLoadedInitialLabels = true + self.isLoading = false } ) .store(in: &subscriptions) @@ -57,10 +56,12 @@ final class LabelsViewModel: ObservableObject { receiveCompletion: { [weak self] _ in self?.isLoading = false }, - receiveValue: { [weak self] result in + receiveValue: { [weak self] labelID in + if let label = dataService.viewContext.object(with: labelID) as? LinkedItemLabel { + self?.labels.insert(label, at: 0) + self?.unselectedLabels.insert(label, at: 0) + } self?.isLoading = false - self?.labels.insert(result, at: 0) - self?.unselectedLabels.insert(result, at: 0) self?.showCreateEmailModal = false } ) @@ -82,23 +83,31 @@ final class LabelsViewModel: ObservableObject { .store(in: &subscriptions) } - func saveItemLabelChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) { + func saveItemLabelChanges( + itemID: String, + dataService: DataService, + onComplete: @escaping ([LinkedItemLabel]) -> Void + ) { isLoading = true - dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.id)).sink( + dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.unwrappedID)).sink( receiveCompletion: { [weak self] _ in self?.isLoading = false }, - receiveValue: { onComplete($0) } + receiveValue: { labelIDs in + onComplete( + labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel } + ) + } ) .store(in: &subscriptions) } - func addLabelToItem(_ label: FeedItemLabel) { + func addLabelToItem(_ label: LinkedItemLabel) { selectedLabels.insert(label, at: 0) unselectedLabels.removeAll { $0.id == label.id } } - func removeLabelFromItem(_ label: FeedItemLabel) { + func removeLabelFromItem(_ label: LinkedItemLabel) { unselectedLabels.insert(label, at: 0) selectedLabels.removeAll { $0.id == label.id } } diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift index 6ae0f7ad9..876564b45 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift @@ -6,43 +6,39 @@ import Utils import Views enum PDFProvider { - static var pdfViewerProvider: ((URL, FeedItem) -> AnyView)? + static var pdfViewerProvider: ((URL, LinkedItem) -> AnyView)? } @MainActor final class LinkItemDetailViewModel: ObservableObject { let homeFeedViewModel: HomeFeedViewModel - @Published var item: FeedItem + @Published var item: LinkedItem @Published var webAppWrapperViewModel: WebAppWrapperViewModel? var subscriptions = Set() - init(item: FeedItem, homeFeedViewModel: HomeFeedViewModel) { + init(item: LinkedItem, homeFeedViewModel: HomeFeedViewModel) { self.item = item self.homeFeedViewModel = homeFeedViewModel } func handleArchiveAction(dataService: DataService) { - homeFeedViewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived) + homeFeedViewModel.setLinkArchived( + dataService: dataService, + objectID: item.objectID, + archived: !item.isArchived + ) } func handleDeleteAction(dataService: DataService) { - homeFeedViewModel.removeLink(dataService: dataService, linkId: item.id) + homeFeedViewModel.removeLink(dataService: dataService, objectID: item.objectID) } func updateItemReadStatus(dataService: DataService) { - dataService - .updateArticleReadingProgressPublisher( - itemID: item.id, - readingProgress: item.isRead ? 0 : 100, - anchorIndex: 0 - ) - .sink { completion in - guard case let .failure(error) = completion else { return } - print(error) - } receiveValue: { [weak self] feedItem in - self?.item.readingProgress = feedItem.readingProgress - } - .store(in: &subscriptions) + dataService.updateLinkReadingProgress( + itemID: item.unwrappedID, + readingProgress: item.isRead ? 0 : 100, + anchorIndex: 0 + ) } func loadWebAppWrapper(dataService: DataService, rawAuthCookie: String?) async { @@ -51,12 +47,20 @@ enum PDFProvider { return currentViewer } - return try? await dataService.fetchViewer() + guard let viewerObjectID = try? await dataService.fetchViewer() else { return nil } + + var result: Viewer? + + await dataService.viewContext.perform { + result = dataService.viewContext.object(with: viewerObjectID) as? Viewer + } + + return result }() if let viewer = viewer { createWebAppWrapperViewModel( - username: viewer.username, + username: viewer.unwrappedUsername, dataService: dataService, rawAuthCookie: rawAuthCookie ) @@ -68,7 +72,7 @@ enum PDFProvider { let urlRequest = URLRequest.webRequest( baseURL: dataService.appEnvironment.webAppBaseURL, - urlPath: "/app/\(username)/\(item.slug)", + urlPath: "/app/\(username)/\(item.unwrappedSlug)", queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"] ) @@ -78,12 +82,10 @@ enum PDFProvider { rawAuthCookie: rawAuthCookie ) - newWebAppWrapperViewModel.performActionSubject.sink { [weak self] action in + newWebAppWrapperViewModel.performActionSubject.sink { action in switch action { case let .shareHighlight(highlightID): print("show share modal for highlight with id: \(highlightID)") - case let .updateReadingProgess(progress: progress): - self?.homeFeedViewModel.uncommittedReadingProgressUpdates[self?.item.id ?? ""] = Double(progress) } } .store(in: &newWebAppWrapperViewModel.subscriptions) diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/NewsletterEmailsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/NewsletterEmailsView.swift index 5f171b349..ae9660ffc 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/NewsletterEmailsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/NewsletterEmailsView.swift @@ -16,9 +16,11 @@ final class NewsletterEmailsViewModel: ObservableObject { dataService.newsletterEmailsPublisher().sink( receiveCompletion: { _ in }, - receiveValue: { [weak self] result in + receiveValue: { [weak self] objectIDs in self?.isLoading = false - self?.emails = result + dataService.viewContext.perform { + self?.emails = objectIDs.compactMap { dataService.viewContext.object(with: $0) as? NewsletterEmail } + } self?.hasLoadedInitialEmails = true } ) @@ -32,9 +34,13 @@ final class NewsletterEmailsViewModel: ObservableObject { receiveCompletion: { [weak self] _ in self?.isLoading = false }, - receiveValue: { [weak self] result in + receiveValue: { [weak self] objectID in self?.isLoading = false - self?.emails.insert(result, at: 0) + dataService.viewContext.perform { + if let item = dataService.viewContext.object(with: objectID) as? NewsletterEmail { + self?.emails.insert(item, at: 0) + } + } } ) .store(in: &subscriptions) @@ -97,7 +103,7 @@ struct NewsletterEmailsView: View { Snackbar.show(message: "Email copied") }, - label: { Text(newsletterEmail.email) } + label: { Text(newsletterEmail.unwrappedEmail) } ) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index aab2f65ab..07d967767 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -18,11 +18,24 @@ import Views } func loadProfileData(dataService: DataService) async { - guard let viewer = try? await dataService.fetchViewer() else { return } + if let currentViewer = dataService.currentViewer { + loadProfileCardData(viewer: currentViewer) + return + } + guard let viewerObjectID = try? await dataService.fetchViewer() else { return } + + await dataService.viewContext.perform { + if let viewer = dataService.viewContext.object(with: viewerObjectID) as? Viewer { + self.loadProfileCardData(viewer: viewer) + } + } + } + + private func loadProfileCardData(viewer: Viewer) { profileCardData = ProfileCardData( - name: viewer.name, - username: viewer.username, + name: viewer.unwrappedName, + username: viewer.unwrappedUsername, imageURL: viewer.profileImageURL.flatMap { URL(string: $0) } ) } diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift index 135be02c1..596ff4d69 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -29,6 +29,7 @@ public struct RootView: View { InnerRootView(viewModel: viewModel) .environmentObject(viewModel.services.authenticator) .environmentObject(viewModel.services.dataService) + .environment(\.managedObjectContext, viewModel.services.dataService.viewContext) .onAppear { if let pdfViewerProvider = pdfViewerProvider { viewModel.configurePDFProvider(pdfViewerProvider: pdfViewerProvider) @@ -59,10 +60,17 @@ struct InnerRootView: View { } }) #endif - .snackBar( - isShowing: $viewModel.showSnackbar, - text: Text(viewModel.snackbarMessage ?? "") - ) + .snackBar(isShowing: $viewModel.showSnackbar, message: viewModel.snackbarMessage) + // Schedule the dismissal every time we present the snackbar. + .onChange(of: viewModel.showSnackbar) { newValue in + if newValue { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + viewModel.showSnackbar = false + } + } + } + } #if os(iOS) .customAlert(isPresented: $viewModel.showPushNotificationPrimer) { pushNotificationPrimerView diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift index a8afdf799..3ed6b0654 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift @@ -33,9 +33,9 @@ public final class RootViewModel: ObservableObject { func configurePDFProvider(pdfViewerProvider: @escaping (URL, PDFViewerViewModel) -> AnyView) { guard PDFProvider.pdfViewerProvider == nil else { return } - PDFProvider.pdfViewerProvider = { [weak self] url, feedItem in + PDFProvider.pdfViewerProvider = { [weak self] url, linkedItem in guard let self = self else { return AnyView(Text("")) } - return pdfViewerProvider(url, PDFViewerViewModel(services: self.services, feedItem: feedItem)) + return pdfViewerProvider(url, PDFViewerViewModel(services: self.services, linkedItem: linkedItem)) } } @@ -64,9 +64,11 @@ public final class RootViewModel: ObservableObject { return } - if let viewer = try? await services.dataService.fetchViewer() { - let path = linkRequestPath(username: viewer.username, requestID: linkRequestID) - webLinkPath = SafariWebLinkPath(id: UUID(), path: path) + if let viewerObjectID = try? await services.dataService.fetchViewer() { + if let viewer = services.dataService.viewContext.object(with: viewerObjectID) as? Viewer { + let path = linkRequestPath(username: viewer.unwrappedUsername, requestID: linkRequestID) + webLinkPath = SafariWebLinkPath(id: UUID(), path: path) + } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift index a4544fafb..7e219b424 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift @@ -6,8 +6,9 @@ import WebKit #if os(iOS) struct WebReader: UIViewRepresentable { - let articleContent: ArticleContent - let item: FeedItem + let htmlContent: String + let highlightsJSONString: String + let item: LinkedItem let openLinkAction: (URL) -> Void let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void let navBarVisibilityRatioUpdater: (Double) -> Void @@ -99,7 +100,8 @@ import WebKit func loadContent(webView: WKWebView) { webView.loadHTMLString( WebReaderContent( - articleContent: articleContent, + htmlContent: htmlContent, + highlightsJSONString: highlightsJSONString, item: item, isDark: UITraitCollection.current.userInterfaceStyle == .dark, fontSize: fontSize() diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index d0c8cf42d..67a45339b 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -7,7 +7,7 @@ import WebKit #if os(iOS) struct WebReaderContainerView: View { - let item: FeedItem + let item: LinkedItem let homeFeedViewModel: HomeFeedViewModel @State private var showFontSizePopover = false @@ -33,14 +33,6 @@ import WebKit } func webViewActionHandler(message: WKScriptMessage, replyHandler: WKScriptMessageReplyHandler?) { - if message.name == WebViewAction.readingProgressUpdate.rawValue { - let messageBody = message.body as? [String: Double] - - if let messageBody = messageBody, let progress = messageBody["progress"] { - homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress) - } - } - if let replyHandler = replyHandler { viewModel.webViewActionWithReplyHandler( message: message, @@ -53,12 +45,6 @@ import WebKit if message.name == WebViewAction.highlightAction.rawValue { handleHighlightAction(message: message) } - - if message.name == WebViewAction.readingProgressUpdate.rawValue { - guard let messageBody = message.body as? [String: Double] else { return } - guard let progress = messageBody["progress"] else { return } - homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress) - } } private func handleHighlightAction(message: WKScriptMessage) { @@ -107,7 +93,7 @@ import WebKit action: { homeFeedViewModel.setLinkArchived( dataService: dataService, - linkId: item.id, + objectID: item.objectID, archived: !item.isArchived ) }, @@ -139,7 +125,7 @@ import WebKit } .alert("Are you sure?", isPresented: $showDeleteConfirmation) { Button("Remove Link", role: .destructive) { - homeFeedViewModel.removeLink(dataService: dataService, linkId: item.id) + homeFeedViewModel.removeLink(dataService: dataService, objectID: item.objectID) } Button("Cancel", role: .cancel, action: {}) } @@ -149,7 +135,8 @@ import WebKit ZStack { if let articleContent = viewModel.articleContent { WebReader( - articleContent: articleContent, + htmlContent: articleContent.htmlContent, + highlightsJSONString: articleContent.highlightsJSONString, item: item, openLinkAction: { #if os(macOS) @@ -205,7 +192,7 @@ import WebKit .contentShape(Rectangle()) .onAppear { if !viewModel.isLoading { - viewModel.loadContent(dataService: dataService, slug: item.slug) + viewModel.loadContent(dataService: dataService, slug: item.unwrappedSlug) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift index 1f0b813ea..779079c38 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContent.swift @@ -4,26 +4,29 @@ import Utils struct WebReaderContent { let textFontSize: Int - let articleContent: ArticleContent - let item: FeedItem + let htmlContent: String + let highlightsJSONString: String + let item: LinkedItem let themeKey: String init( - articleContent: ArticleContent, - item: FeedItem, + htmlContent: String, + highlightsJSONString: String, + item: LinkedItem, isDark: Bool, fontSize: Int ) { self.textFontSize = fontSize - self.articleContent = articleContent + self.htmlContent = htmlContent + self.highlightsJSONString = highlightsJSONString self.item = item self.themeKey = isDark ? "Gray" : "LightGray" } // swiftlint:disable line_length var styledContent: String { - let savedAt = "new Date(\(item.savedAt.timeIntervalSince1970 * 1000)).toISOString()" - let createdAt = "new Date(\(item.createdAt.timeIntervalSince1970 * 1000)).toISOString()" + let savedAt = "new Date(\(item.unwrappedSavedAt.timeIntervalSince1970 * 1000)).toISOString()" + let createdAt = "new Date(\(item.unwrappedCreatedAt.timeIntervalSince1970 * 1000)).toISOString()" let publishedAt = item.publishDate != nil ? "new Date(\(item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" : "undefined" return """ @@ -39,7 +42,7 @@ struct WebReaderContent {