diff --git a/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/SafariExtension (iOS).xcscheme b/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/SafariExtension (iOS).xcscheme new file mode 100644 index 000000000..dcf3aaaa4 --- /dev/null +++ b/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/SafariExtension (iOS).xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/SafariExtension (macOS) 1.xcscheme b/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/SafariExtension (macOS) 1.xcscheme new file mode 100644 index 000000000..7d134d118 --- /dev/null +++ b/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/SafariExtension (macOS) 1.xcscheme @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/SafariExtension (macOS).xcscheme b/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/SafariExtension (macOS).xcscheme new file mode 100644 index 000000000..e67aac480 --- /dev/null +++ b/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/SafariExtension (macOS).xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/ShareExtension-Mac.xcscheme b/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/ShareExtension-Mac.xcscheme new file mode 100644 index 000000000..fa3255a83 --- /dev/null +++ b/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/ShareExtension-Mac.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme b/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme new file mode 100644 index 000000000..81c98b04c --- /dev/null +++ b/apple/Omnivore.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift index a2759fea8..7f5ad4aff 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift @@ -5,6 +5,7 @@ import SwiftUI import Utils import Views +@MainActor public class ShareExtensionViewModel: ObservableObject { @Published public var status: ShareExtensionStatus = .processing @Published public var title: String = "" diff --git a/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift b/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift index b7696be7f..248665c95 100644 --- a/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift +++ b/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift @@ -237,7 +237,7 @@ import Utils } .fullScreenCover(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) { NotebookView( - itemObjectID: viewModel.pdfItem.objectID, + viewModel: NotebookViewModel(item: viewModel.pdfItem.item), hasHighlightMutations: $hasPerformedHighlightMutations, onDeleteHighlight: { highlightId in coordinator.removeHighlightFromPDF(highlightId: highlightId) diff --git a/apple/OmnivoreKit/Sources/App/Services.swift b/apple/OmnivoreKit/Sources/App/Services.swift index 9015e4f20..9ad72a7bc 100644 --- a/apple/OmnivoreKit/Sources/App/Services.swift +++ b/apple/OmnivoreKit/Sources/App/Services.swift @@ -5,6 +5,7 @@ import OSLog import Services import Utils +@MainActor public final class Services { static let fetchTaskID = "app.omnivore.fetchLinkedItems" static let secondsToWaitBeforeNextBackgroundRefresh: TimeInterval = isDebug ? 0 : 3600 // 1 hour @@ -84,6 +85,7 @@ public final class Services { Task { do { let fetchedItemCount = try await services.dataService.fetchLinkedItemsBackgroundTask() + BadgeCountHandler.updateBadgeCount(dataService: services.dataService) task.setTaskCompleted(success: true) } catch { EventTracker.track( diff --git a/apple/OmnivoreKit/Sources/App/Views/TabBar/CustomTabBar.swift b/apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomTabBar.swift similarity index 100% rename from apple/OmnivoreKit/Sources/App/Views/TabBar/CustomTabBar.swift rename to apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomTabBar.swift diff --git a/apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomToolBar.swift b/apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomToolBar.swift new file mode 100644 index 000000000..6cc415cee --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomToolBar.swift @@ -0,0 +1,66 @@ +import Foundation +import SwiftUI +import Views + +struct CustomToolBar: View { + let isFollowing: Bool + let isArchived: Bool + let moveToInboxAction: () -> Void + let archiveAction: () -> Void + let unarchiveAction: () -> Void + let shareAction: () -> Void + let deleteAction: () -> Void + + var barColor: Color { + switch ThemeManager.currentTheme { + case .apollo: + return Color.themeMiddleGray + case .dark: + return Color.themeMiddleGray + case .light: + return Color.themeDarkWhiteGray + case .sepia: + return Color.themeDarkWhiteGray + case .system: + return Color.isDarkMode ? Color.themeMiddleGray : Color.themeDarkWhiteGray + } + } + + var body: some View { + VStack { + barColor + .frame(height: 0.5) + .frame(maxWidth: .infinity) + HStack(spacing: 0) { + if isFollowing { + ToolBarButton(image: Image.tabLibrary, action: moveToInboxAction) + } else if isArchived { + ToolBarButton(image: Image.toolbarUnarchive, action: unarchiveAction) + } else { + ToolBarButton(image: Image.toolbarArchive, action: archiveAction) + } + ToolBarButton(image: Image.toolbarShare, action: shareAction) + ToolBarButton(image: Image.toolbarTrash, action: deleteAction) + } + .padding(.top, 8) + } + .padding(.bottom, 35) + .background(ThemeManager.currentBgColor) + } +} + +struct ToolBarButton: View { + let image: Image + let action: () -> Void + + var body: some View { + Button(action: { + action() + }, label: { + image + .frame(width: 28, height: 28) + .foregroundColor(ThemeManager.currentTheme.toolbarColor) + .frame(maxWidth: .infinity) + }) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/DeleteAccountView.swift b/apple/OmnivoreKit/Sources/App/Views/DeleteAccountView.swift new file mode 100644 index 000000000..0d27b8c37 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/DeleteAccountView.swift @@ -0,0 +1,24 @@ +import Models +import Services +import SwiftUI +import Utils +import Views + +struct DeleteAccountView: View { + @EnvironmentObject var dataService: DataService + @EnvironmentObject var authenticator: Authenticator + + public var body: some View { + VStack(alignment: .center) { + Text("Deleting account...") + ProgressView() + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { + authenticator.logout(dataService: dataService, isAccountDeletion: true) + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Highlights/NotebookView.swift b/apple/OmnivoreKit/Sources/App/Views/Highlights/NotebookView.swift index 6e8bc2b92..802e20899 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Highlights/NotebookView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Highlights/NotebookView.swift @@ -9,16 +9,16 @@ typealias DeleteHighlightAction = (String) -> Void struct NotebookView: View { + @StateObject var viewModel: NotebookViewModel + @EnvironmentObject var dataService: DataService @Environment(\.presentationMode) private var presentationMode - @StateObject var viewModel = NotebookViewModel() @State var showAnnotationModal = false @State var errorAlertMessage: String? @State var showErrorAlertMessage = false @State var noteAnnotation = "" - let itemObjectID: NSManagedObjectID @Binding var hasHighlightMutations: Bool @State var setLabelsHighlight: Highlight? @State var showShareView: Bool = false @@ -168,7 +168,7 @@ annotation: $noteAnnotation, onSave: { viewModel.updateNoteAnnotation( - itemObjectID: itemObjectID, + itemObjectID: viewModel.item.objectID, annotation: noteAnnotation, dataService: dataService ) @@ -224,7 +224,7 @@ #endif } .task { - viewModel.load(itemObjectID: itemObjectID, dataService: dataService) + viewModel.load(itemObjectID: viewModel.item.objectID, dataService: dataService) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Highlights/NotebookViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Highlights/NotebookViewModel.swift index 33db22c4f..cdfa59039 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Highlights/NotebookViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Highlights/NotebookViewModel.swift @@ -21,9 +21,15 @@ struct NoteItemParams: Identifiable { } @MainActor final class NotebookViewModel: ObservableObject { + let item: Models.LibraryItem + @Published var noteItem: NoteItemParams? @Published var highlightItems = [HighlightListItemParams]() + init(item: Models.LibraryItem) { + self.item = item + } + func load(itemObjectID: NSManagedObjectID, dataService: DataService) { if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? Models.LibraryItem { loadHighlights(item: linkedItem) @@ -102,7 +108,14 @@ struct NoteItemParams: Identifiable { } func highlightsAsMarkdown() -> String { - highlightItems.map { highlightAsMarkdown(item: $0) }.lazy.joined(separator: "\n\n") + var buffer = "\(item.unwrappedTitle)\n" + if let author = item.author { + buffer += "by: \(author)\n" + } + if let url = item.pageURLString { + buffer += "url: \(url)\n" + } + return buffer + "\n\n" + highlightItems.map { highlightAsMarkdown(item: $0) }.lazy.joined(separator: "\n\n") } private func loadHighlights(item: Models.LibraryItem) { diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/BadgeCountHandler.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/BadgeCountHandler.swift new file mode 100644 index 000000000..8de7cd9a5 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/BadgeCountHandler.swift @@ -0,0 +1,26 @@ +import CoreData +import Foundation +import Models +import Services +import SwiftUI + +@MainActor +enum BadgeCountHandler { + @AppStorage("Filters::badgeFilter") public static var badgeFilter = "in:inbox" + + public static func updateBadgeCount(dataService: DataService) { + // if let badgeFilterId = badgeFilterId { + dataService.backgroundContext.performAndWait { + if let filter = Filter.lookup(byFilter: badgeFilter, inContext: dataService.backgroundContext), + let internalFilter = InternalFilter.make(from: [filter]).first + { + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() + fetchRequest.predicate = internalFilter.predicate + + if let count = try? dataService.backgroundContext.count(for: fetchRequest) { + UIApplication.shared.applicationIconBadgeNumber = count + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryFeatureCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryFeatureCardNavigationLink.swift index 389a3b4a8..07e1d86d9 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryFeatureCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryFeatureCardNavigationLink.swift @@ -39,7 +39,7 @@ struct LibraryFeatureCardNavigationLink: View { } ) .confirmationDialog("", isPresented: $showFeatureActions) { - if FeaturedItemFilter(rawValue: viewModel.featureFilter) == .pinned { + if FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) == .pinned { Button("Unpin", action: { viewModel.unpinItem(dataService: dataService, item: item) }) @@ -53,7 +53,7 @@ struct LibraryFeatureCardNavigationLink: View { Button("Remove", action: { viewModel.removeLibraryItem(dataService: dataService, objectID: item.objectID) }) - if FeaturedItemFilter(rawValue: viewModel.featureFilter) != .pinned { + if FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) != .pinned { Button("Mark Read", action: { viewModel.markRead(dataService: dataService, item: item) }) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemFetcher.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemFetcher.swift index 1d26dcf88..ebe8626cf 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemFetcher.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemFetcher.swift @@ -9,13 +9,13 @@ import Utils import Views @MainActor final class LibraryItemFetcher: NSObject, ObservableObject { - let folder = "inbox" - @Published var items = [Models.LibraryItem]() - var itemsPublisher: Published<[Models.LibraryItem]>.Publisher { $items } + @Published var featureItems = [Models.LibraryItem]() private var fetchedResultsController: NSFetchedResultsController? + @AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue + var cursor: String? // These are used to make sure we handle search result @@ -23,10 +23,11 @@ import Views var searchIdx = 0 var receivedIdx = 0 - var syncCursor: String? - - func setItems(_: NSManagedObjectContext, _ items: [Models.LibraryItem]) { + func setItems(_ context: NSManagedObjectContext, _ items: [Models.LibraryItem]) { self.items = items + if let filter = FeaturedItemFilter(rawValue: featureFilter) { + updateFeatureFilter(context: context, filter: filter) + } } func loadCurrentViewer(dataService: DataService) async { @@ -45,37 +46,7 @@ import Views } } - func syncItems(dataService: DataService) async { - let syncStart = Date.now - let lastSyncDate = dataService.lastItemSyncTime - - try? await dataService.syncOfflineItemsWithServerIfNeeded() - - let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate, - cursor: nil) - - syncCursor = syncResult?.cursor - if let syncResult = syncResult, syncResult.hasMore { - dataService.syncLinkedItemsInBackground(since: lastSyncDate) { - // do nothing - } - } else { - dataService.lastItemSyncTime = syncStart - } - - // If possible start prefetching new pages in the background - if - let itemIDs = syncResult?.updatedItemIDs, - let username = dataService.currentViewer?.username, - !itemIDs.isEmpty - { - Task.detached(priority: .background) { - await dataService.prefetchPages(itemIDs: itemIDs, username: username) - } - } - } - - func loadSearchQuery(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async { + func loadSearchQuery(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool, loadCursor: String? = nil) async { let thisSearchIdx = searchIdx searchIdx += 1 @@ -86,7 +57,7 @@ import Views let queryResult = try? await dataService.loadLinkedItems( limit: 10, searchQuery: searchQuery(filterState), - cursor: isRefresh ? nil : cursor + cursor: isRefresh ? nil : loadCursor ?? cursor ) if let appliedFilter = filterState.appliedFilter, let queryResult = queryResult { @@ -123,7 +94,6 @@ import Views await withTaskGroup(of: Void.self) { group in group.addTask { await self.loadCurrentViewer(dataService: dataService) } group.addTask { await self.loadLabels(dataService: dataService) } - group.addTask { await self.syncItems(dataService: dataService) } group.addTask { await self.updateFetchController(dataService: dataService, filterState: filterState) } await group.waitForAll() } @@ -136,11 +106,16 @@ import Views updateFetchController(dataService: dataService, filterState: filterState) } } + + NotificationCenter.default.post(name: NSNotification.PerformSync, object: nil, userInfo: nil) + + BadgeCountHandler.updateBadgeCount(dataService: dataService) } - func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async { + func loadMoreItems(dataService: DataService, filterState: FetcherFilterState) async { if let appliedFilter = filterState.appliedFilter, appliedFilter.shouldRemoteSearch { - await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh) + let idx = max(items.count - 1, 0) + await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: false, loadCursor: idx.description) } } @@ -216,10 +191,14 @@ import Views private func searchQuery(_ filterState: FetcherFilterState) -> String { let sort = LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest - var query = sort.queryString + var query = "" if let queryString = filterState.appliedFilter?.filter { - query = "\(queryString) \(sort.queryString)" + query = "\(queryString)" + } + + if !query.contains("sort:") { + query = "\(query) \(sort.queryString)" } if !filterState.searchTerm.isEmpty { @@ -251,6 +230,28 @@ import Views return query } + + func refreshFeatureItems(dataService: DataService) { + if let featureFilter = FeaturedItemFilter(rawValue: self.featureFilter) { + updateFeatureFilter(context: dataService.viewContext, filter: featureFilter) + } + } + + func updateFeatureFilter(context: NSManagedObjectContext, filter: FeaturedItemFilter?) { + if let filter = filter { + Task { + featureFilter = filter.rawValue + + featureItems = await loadFeatureItems( + context: context, + predicate: filter.predicate, + sort: filter.sortDescriptor + ) + } + } else { + featureItems = [] + } + } } extension LibraryItemFetcher: NSFetchedResultsControllerDelegate { diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemListNavigationLink.swift similarity index 79% rename from apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift rename to apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemListNavigationLink.swift index a52d67cfd..5fb630fce 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemListNavigationLink.swift @@ -14,7 +14,7 @@ struct MacFeedCardNavigationLink: View { var body: some View { ZStack { - LibraryItemCard(item: item, viewer: dataService.currentViewer) + LibraryItemCard(item: LibraryItemData.make(from: item), viewer: dataService.currentViewer) NavigationLink(destination: LinkItemDetailView( linkedItemObjectID: item.objectID, isPDF: item.isPDF @@ -22,23 +22,19 @@ struct MacFeedCardNavigationLink: View { EmptyView() }).opacity(0) } - .onAppear { - Task { await viewModel.itemAppeared(item: item, dataService: dataService) } - } } } -struct FeedCardNavigationLink: View { +struct LibraryItemListNavigationLink: View { @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController - let item: Models.LibraryItem - let isInMultiSelectMode: Bool + @ObservedObject var item: Models.LibraryItem @ObservedObject var viewModel: HomeFeedViewModel var body: some View { ZStack { - LibraryItemCard(item: item, viewer: dataService.currentViewer) + LibraryItemCard(item: LibraryItemData.make(from: item), viewer: dataService.currentViewer) PresentationLink( transition: PresentationLinkTransition.slide( options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing, @@ -56,19 +52,17 @@ struct FeedCardNavigationLink: View { } ) } - .task { - await viewModel.itemAppeared(item: item, dataService: dataService) - } } } -struct GridCardNavigationLink: View { +struct LibraryItemGridCardNavigationLink: View { @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController @State private var scale = 1.0 - let item: Models.LibraryItem + @ObservedObject var item: Models.LibraryItem + let actionHandler: (GridCardAction) -> Void @Binding var isContextMenuOpen: Bool @@ -89,12 +83,9 @@ struct GridCardNavigationLink: View { isPDF: item.isPDF ) }, label: { - GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler) + GridCard(item: LibraryItemData.make(from: item), isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler) } ) - .task { - await viewModel.itemAppeared(item: item, dataService: dataService) - } .aspectRatio(1.0, contentMode: .fill) .background( Color.secondarySystemGroupedBackground diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibrarySyncManager.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibrarySyncManager.swift new file mode 100644 index 000000000..bb4e72709 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibrarySyncManager.swift @@ -0,0 +1,40 @@ +import Foundation +import Services + +class LibrarySyncManager { + var syncCursor: String? + + func syncItems(dataService: DataService) async { + let syncStart = Date.now + let lastSyncDate = dataService.lastItemSyncTime + + if lastSyncDate.timeIntervalSinceNow > -4 { + print("skipping sync as last sync was too recent: ", lastSyncDate) + return + } + + try? await dataService.syncOfflineItemsWithServerIfNeeded() + + let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate, + cursor: nil) + + syncCursor = syncResult?.cursor + if let syncResult = syncResult, syncResult.hasMore { + dataService.syncLinkedItemsInBackground(since: lastSyncDate) { + // do nothing + } + } else { + dataService.lastItemSyncTime = syncStart + } + + // If possible start prefetching new pages in the background + if + let itemIDs = syncResult?.updatedItemIDs, + !itemIDs.isEmpty + { + Task.detached(priority: .background) { + await dataService.prefetchPages(itemIDs: itemIDs, username: "username") + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 65ddf6267..7aac80d8c 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -28,7 +28,8 @@ struct AnimatingCellHeight: AnimatableModifier { struct HomeFeedContainerView: View { @State var hasHighlightMutations = false @State var searchPresented = false - @State var addLinkPresented = false + @State var showAddLinkView = false + @State var showAddFeedView = false @State var isListScrolled = false @State var listTitle = "" @State var isEditMode: EditMode = .inactive @@ -37,6 +38,7 @@ struct AnimatingCellHeight: AnimatableModifier { @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController + @Environment(\.horizontalSizeClass) var horizontalSizeClass @AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = true @AppStorage(UserDefaultKey.openAIPrimerDisplayed.rawValue) var openAIPrimerDisplayed = false @@ -70,6 +72,7 @@ struct AnimatingCellHeight: AnimatableModifier { isListScrolled: $isListScrolled, prefersListLayout: $prefersListLayout, isEditMode: $isEditMode, + showAddFeedView: $showAddFeedView, selection: $selection, viewModel: viewModel, showFeatureCards: showFeatureCards @@ -123,20 +126,11 @@ struct AnimatingCellHeight: AnimatableModifier { LinkedItemMetadataEditView(item: item) } .sheet(item: $viewModel.itemForHighlightsView) { item in - NotebookView(itemObjectID: item.objectID, hasHighlightMutations: $hasHighlightMutations) + NotebookView(viewModel: NotebookViewModel(item: item), hasHighlightMutations: $hasHighlightMutations) } .fullScreenCover(isPresented: $showExpandedAudioPlayer) { ExpandedAudioPlayer() } - .sheet(isPresented: $showOpenAIVoices) { - OpenAIVoicesModal(audioController: audioController) - } - .onAppear { - if !openAIPrimerDisplayed, !Voices.isOpenAIVoice(self.audioController.currentVoice) { - showOpenAIVoices = true - openAIPrimerDisplayed = true - } - } .toolbar { toolbarItems } @@ -151,61 +145,54 @@ struct AnimatingCellHeight: AnimatableModifier { viewModel.selectedItem = linkedItem viewModel.linkIsActive = true } - .onOpenURL { url in - viewModel.linkRequest = nil - if let deepLink = DeepLink.make(from: url) { - switch deepLink { - case let .search(query): - viewModel.searchTerm = query - case let .savedSearch(named): - if let filter = viewModel.findFilter(dataService, named: named) { - viewModel.appliedFilter = filter - } - case let .webAppLinkRequest(requestID): - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { - withoutAnimation { - viewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID) - viewModel.presentWebContainer = true - } - } - } - } - } .fullScreenCover(isPresented: $searchPresented) { LibrarySearchView(homeFeedViewModel: self.viewModel) } - .sheet(isPresented: $addLinkPresented) { + .sheet(isPresented: $showAddLinkView) { NavigationView { LibraryAddLinkView() } } + .introspectNavigationController { nav in + nav.delegate = viewModel + } .task { - if viewModel.fetcher.items.isEmpty { - loadItems(isRefresh: false) - } await viewModel.loadFilters(dataService: dataService) } .environment(\.editMode, self.$isEditMode) + .navigationBarTitleDisplayMode(.inline) } var toolbarItems: some ToolbarContent { Group { - ToolbarItem(placement: .barLeading) { - VStack(alignment: .leading) { - let showDate = isListScrolled && !listTitle.isEmpty - if let title = viewModel.appliedFilter?.name { - Text(title) - .font(Font.system(size: showDate ? 10 : 24, weight: .semibold)) - if showDate, prefersListLayout, isListScrolled || !showFeatureCards { - Text(listTitle) - .font(Font.system(size: 15, weight: .regular)) - .foregroundColor(Color.appGrayText) + ToolbarItemGroup(placement: .barLeading) { + if UIDevice.isIPhone || horizontalSizeClass != .compact { + VStack(alignment: .leading) { + let showDate = isListScrolled && !listTitle.isEmpty + if let title = viewModel.appliedFilter?.name { + Text(title) + .font(Font.system(size: showDate ? 10 : 24, weight: .semibold)) + if showDate, prefersListLayout, isListScrolled || !showFeatureCards { + Text(listTitle) + .font(Font.system(size: 15, weight: .regular)) + .foregroundColor(Color.appGrayText) + } } } + .frame(maxWidth: .infinity, alignment: .bottomLeading) } - .frame(maxWidth: .infinity, alignment: .bottomLeading) } - ToolbarItem(placement: UIDevice.isIPhone ? .barLeading : .barTrailing) { + + ToolbarItemGroup(placement: .barTrailing) { + if prefersListLayout { + Button( + action: { isEditMode = isEditMode == .active ? .inactive : .active }, + label: { + Image.selectMultiple + .foregroundColor(Color.appGrayTextContrast) + } + ) + } if enableGrid { Button( action: { prefersListLayout.toggle() }, @@ -214,8 +201,19 @@ struct AnimatingCellHeight: AnimatableModifier { } ) } - } - ToolbarItem(placement: .barTrailing) { + Button( + action: { + if viewModel.folder == "inbox" { + showAddLinkView = true + } else if viewModel.folder == "following" { + showAddFeedView = true + } + }, + label: { + Image.addLink + .foregroundColor(Color.appGrayTextContrast) + } + ) Button( action: { searchPresented = true }, label: { @@ -224,28 +222,7 @@ struct AnimatingCellHeight: AnimatableModifier { } ) } - ToolbarItem(placement: .barTrailing) { - Button( - action: { isEditMode = isEditMode == .active ? .inactive : .active }, - label: { - Image.selectMultiple - .foregroundColor(Color.appGrayTextContrast) - } - ) - } - ToolbarItem(placement: .barTrailing) { - if viewModel.folder == "inbox" { - Button( - action: { addLinkPresented = true }, - label: { - Image.addLink - .foregroundColor(Color.appGrayTextContrast) - } - ) - } else { - EmptyView() - } - } + ToolbarItemGroup(placement: .bottomBar) { if isEditMode == .active { Button(action: { @@ -274,6 +251,7 @@ struct AnimatingCellHeight: AnimatableModifier { @Binding var isListScrolled: Bool @Binding var prefersListLayout: Bool @Binding var isEditMode: EditMode + @Binding var showAddFeedView: Bool @Binding var selection: Set @ObservedObject var viewModel: HomeFeedViewModel @@ -281,7 +259,7 @@ struct AnimatingCellHeight: AnimatableModifier { var body: some View { VStack(spacing: 0) { - if let linkRequest = viewModel.linkRequest { + if let linkRequest = viewModel.linkRequest, viewModel.listConfig.hasReadNowSection { PresentationLink( transition: PresentationLinkTransition.slide( options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing, @@ -304,6 +282,7 @@ struct AnimatingCellHeight: AnimatableModifier { isListScrolled: $isListScrolled, prefersListLayout: $prefersListLayout, isEditMode: $isEditMode, + showAddFeedView: $showAddFeedView, selection: $selection, viewModel: viewModel, showFeatureCards: showFeatureCards @@ -357,7 +336,7 @@ struct AnimatingCellHeight: AnimatableModifier { @Binding var isListScrolled: Bool @Binding var prefersListLayout: Bool @Binding var isEditMode: EditMode - @State private var showAddFeedView = false + @Binding var showAddFeedView: Bool @State private var showHideFeatureAlert = false @State private var showHideFollowingAlert = false @@ -379,21 +358,23 @@ struct AnimatingCellHeight: AnimatableModifier { viewModel.searchTerm = "" }.frame(maxWidth: reader.size.width * 0.66) } else { - Menu( - content: { - ForEach(viewModel.filters) { filter in - Button(filter.name, action: { - viewModel.appliedFilter = filter - }) + if UIDevice.isIPhone { + Menu( + content: { + ForEach(viewModel.filters) { filter in + Button(filter.name, action: { + viewModel.appliedFilter = filter + }) + } + }, + label: { + TextChipButton.makeMenuButton( + title: viewModel.appliedFilter?.name ?? "-", + color: .systemGray6 + ) } - }, - label: { - TextChipButton.makeMenuButton( - title: viewModel.appliedFilter?.name ?? "-", - color: .systemGray6 - ) - } - ) + ) + } } Menu( content: { @@ -450,17 +431,17 @@ struct AnimatingCellHeight: AnimatableModifier { HStack { Menu(content: { Button(action: { - viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .continueReading) + viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .continueReading) }, label: { Text("Continue Reading") }) Button(action: { - viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .pinned) + viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .pinned) }, label: { Text("Pinned") }) Button(action: { - viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .newsletters) + viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .newsletters) }, label: { Text("Newsletters") }) @@ -474,7 +455,7 @@ struct AnimatingCellHeight: AnimatableModifier { HStack(alignment: .center) { Image(systemName: "line.3.horizontal.decrease") .font(Font.system(size: 13, weight: .regular)) - Text((FeaturedItemFilter(rawValue: viewModel.featureFilter) ?? .continueReading).title) + Text((FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) ?? .continueReading).title) .font(Font.system(size: 13, weight: .medium)) } .tint(Color(hex: "#007AFF")) @@ -491,17 +472,17 @@ struct AnimatingCellHeight: AnimatableModifier { GeometryReader { geo in ScrollView(.horizontal, showsIndicators: false) { - if viewModel.featureItems.count > 0 { + if viewModel.fetcher.featureItems.count > 0 { HStack(alignment: .top, spacing: 15) { Spacer(minLength: 1).frame(width: 1) - ForEach(viewModel.featureItems) { item in + ForEach(viewModel.fetcher.featureItems) { item in LibraryFeatureCardNavigationLink(item: item, viewModel: viewModel) } Spacer(minLength: 1).frame(width: 1) } .padding(.top, 0) } else { - Text((FeaturedItemFilter(rawValue: viewModel.featureFilter) ?? .continueReading).emptyMessage) + Text((FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) ?? .continueReading).emptyMessage) .padding(.horizontal, UIDevice.isIPad ? 20 : 10) .font(Font.system(size: 14, weight: .regular)) .foregroundColor(Color(hex: "#898989")) @@ -578,7 +559,7 @@ struct AnimatingCellHeight: AnimatableModifier { } var redactedItems: some View { - ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.unwrappedID) { _, item in + ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.id) { _, item in let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10) LibraryItemCard(item: item, viewer: dataService.currentViewer) .listRowSeparatorTint(Color.thBorderColor) @@ -630,9 +611,8 @@ struct AnimatingCellHeight: AnimatableModifier { ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.unwrappedID) { _, item in let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10) - FeedCardNavigationLink( + LibraryItemListNavigationLink( item: item, - isInMultiSelectMode: viewModel.isInMultiSelectMode, viewModel: viewModel ) .background(GeometryReader { geometry in @@ -715,6 +695,7 @@ struct AnimatingCellHeight: AnimatableModifier { }, header: { filtersHeader }) + BottomView(viewModel: viewModel) } .padding(0) .listStyle(.plain) @@ -735,7 +716,9 @@ struct AnimatingCellHeight: AnimatableModifier { } .sheet(isPresented: $showAddFeedView) { NavigationView { - LibraryAddFeedView() + LibraryAddFeedView(dismiss: { + showAddFeedView = false + }, toastOperationHandler: nil) } } .alert("The Feature Section will be removed from your library. You can add it back from the filter settings in your profile.", @@ -912,17 +895,24 @@ struct AnimatingCellHeight: AnimatableModifier { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 325, maximum: 400), spacing: 16)], alignment: .center, spacing: 30) { if viewModel.showLoadingBar { - ForEach(fakeLibraryItems(dataService: dataService)) { item in - GridCardNavigationLink( - item: item, - actionHandler: { contextMenuActionHandler(item: item, action: $0) }, - isContextMenuOpen: $isContextMenuOpen, - viewModel: viewModel - ) + ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.id) { _, item in + GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: { _ in + + }) + .aspectRatio(1.0, contentMode: .fill) + .background( + Color.secondarySystemGroupedBackground + .onTapGesture { + if isContextMenuOpen { + isContextMenuOpen = false + } + } + ) + .cornerRadius(6) }.redacted(reason: .placeholder) } else { ForEach(viewModel.fetcher.items) { item in - GridCardNavigationLink( + LibraryItemGridCardNavigationLink( item: item, actionHandler: { contextMenuActionHandler(item: item, action: $0) }, isContextMenuOpen: $isContextMenuOpen, @@ -930,6 +920,7 @@ struct AnimatingCellHeight: AnimatableModifier { ) } } + BottomView(viewModel: viewModel) Spacer() } .frame(maxHeight: .infinity) @@ -974,7 +965,7 @@ struct ScrollViewOffsetPreferenceKey: PreferenceKey { #if os(iOS) // Allows us to present a sheet without animation // Used to configure full screen modal view coming from share extension read now button action - private extension View { + public extension View { func withoutAnimation(_ completion: @escaping () -> Void) { UIView.setAnimationsEnabled(false) completion() @@ -1009,20 +1000,71 @@ struct LinkDestination: View { } } -func fakeLibraryItems(dataService: DataService) -> [Models.LibraryItem] { - let temp = Models.LibraryItem(context: dataService.viewContext) - temp.id = UUID().uuidString - temp.wordsCount = 100 - temp.author = "the author" - temp.siteName = "omnivore dot app" - temp.title = "This is a temporary title for a fake item" - temp.highlights = [] - temp.imageURLString = "https://localhost/" - return Array( - repeatElement(temp, count: 20) - .map { item in - item.id = UUID().uuidString - return item - } - ) +func fakeLibraryItems(dataService _: DataService) -> [LibraryItemData] { + Array( + repeatElement(0, count: 20) + .map { _ in + LibraryItemData( + id: UUID().uuidString, + title: "fake title that is kind of long so it looks better", + pageURLString: "", + isArchived: false, + author: "fake author", + deepLink: nil, + hasLabels: false, + noteText: nil, + readingProgress: 10, + wordsCount: 10, + isPDF: false, + highlights: nil, + sortedLabels: [], + imageURL: nil, + publisherDisplayName: "fake publisher", + descriptionText: "This is a fake description" + ) + }) +} + +struct BottomView: View { + @ObservedObject var viewModel: HomeFeedViewModel + @EnvironmentObject var dataService: DataService + + @State var autoLoading = false + + var body: some View { + innerBody + .listRowSeparator(.hidden) + .onAppear { + Task { + autoLoading = true + await viewModel.loadMore(dataService: dataService) + autoLoading = false + } + } + } + + var innerBody: some View { + if viewModel.fetcher.items.count < 5 { + AnyView(Color.clear) + } else { + AnyView(HStack { + if !autoLoading { + Text("You are all caught up.") + } + Spacer() + if viewModel.isLoading { + ProgressView() + } else { + Button(action: { + Task { + await viewModel.loadMore(dataService: dataService) + } + }, label: { + Text("Refresh library") + }) + .foregroundColor(Color.blue) + } + }.padding(10)) + } + } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 6943fa16e..062eb00c6 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -5,7 +5,7 @@ import SwiftUI import Utils import Views -@MainActor final class HomeFeedViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate { +@MainActor final class HomeFeedViewModel: NSObject, ObservableObject, UINavigationControllerDelegate { let folder: String let fetcher: LibraryItemFetcher let listConfig: LibraryListConfig @@ -19,18 +19,12 @@ import Views @Published var itemForHighlightsView: Models.LibraryItem? @Published var linkRequest: LinkRequest? @Published var presentWebContainer = false - @Published var showLoadingBar = false - @Published var isInMultiSelectMode = false + @Published var showLoadingBar = true - @Published var selectedLinkItem: NSManagedObjectID? // used by mac app only @Published var selectedItem: Models.LibraryItem? @Published var linkIsActive = false @Published var showLabelsSheet = false - @Published var showFiltersModal = false - @Published var showCommunityModal = false - @Published var featureItems = [Models.LibraryItem]() - @Published var showSnackbar = false @Published var snackbarOperation: SnackbarOperation? @@ -41,9 +35,9 @@ import Views @Published var negatedLabels = [LinkedItemLabel]() @Published var appliedSort = LinkedItemSort.newest.rawValue - @AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false - @AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue + @State var lastMoreFetched: Date? + @AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false @AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false @Published var appliedFilter: InternalFilter? { @@ -64,26 +58,10 @@ import Views super.init() } - func updateFeatureFilter(context: NSManagedObjectContext, filter: FeaturedItemFilter?) { - if let filter = filter { - Task { - featureFilter = filter.rawValue - - featureItems = await loadFeatureItems( - context: context, - predicate: filter.predicate, - sort: filter.sortDescriptor - ) - } - } else { - featureItems = [] - } - } - func loadFilters(dataService: DataService) async { switch folder { case "following": - updateFilters(newFilters: InternalFilter.DefaultFollowingFilters, defaultName: "rss") + updateFilters(newFilters: InternalFilter.DefaultFollowingFilters, defaultName: "following") default: var hasLocalResults = false let fetchRequest: NSFetchRequest = Filter.fetchRequest() @@ -105,17 +83,20 @@ import Views } } - func itemAppeared(item: Models.LibraryItem, dataService: DataService) async { + func loadMore(dataService: DataService) async { if isLoading { return } - let itemIndex = fetcher.items.firstIndex(where: { $0.id == item.id }) - let thresholdIndex = fetcher.items.index(fetcher.items.endIndex, offsetBy: -5) - // Check if user has scrolled to the last five items in the list - // Make sure we aren't currently loading though, as this would get triggered when the first set - // of items are presented to the user. - if let itemIndex = itemIndex, itemIndex > thresholdIndex { - await loadMoreItems(dataService: dataService, filterState: filterState, isRefresh: false) + let start = Date.now + if let lastMoreFetched, lastMoreFetched.timeIntervalSinceNow > -4 { + print("skipping fetching more as last fetch was too recent: ", lastMoreFetched) + return } + + isLoading = true + await fetcher.loadMoreItems(dataService: dataService, filterState: filterState) + isLoading = false + + lastMoreFetched = start } func pushFeedItem(item _: Models.LibraryItem) { @@ -145,7 +126,7 @@ import Views filters = newFilters .filter { $0.folder == filterState.folder } .sorted(by: { $0.position < $1.position }) - + (folder == "inbox" ? [InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter] : [InternalFilter.DownloadedFilter]) + + (folder == "inbox" ? [InternalFilter.UnreadFilter, InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter] : [InternalFilter.DownloadedFilter]) if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id { appliedFilter = newFilter @@ -157,27 +138,25 @@ import Views showLoadingBar = isRefresh await fetcher.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh) - updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter)) isLoading = false showLoadingBar = false } - func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async { - isLoading = true - - await fetcher.loadMoreItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh) - - isLoading = false - } - func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [Models.LibraryItem] { let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() fetchRequest.fetchLimit = 25 fetchRequest.predicate = predicate fetchRequest.sortDescriptors = [sort] - return (try? context.fetch(fetchRequest)) ?? [] + do { + let fetched = try context.fetch(fetchRequest) + return fetched + } catch { + print("ERROR FETCHING: ", error) + } + return [] +// return (try? context.fetch(fetchRequest)) ?? [] } func snackbar(_ message: String, undoAction: SnackbarUndoAction? = nil) { @@ -187,7 +166,7 @@ import Views func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) { dataService.archiveLink(objectID: objectID, archived: archived) - snackbar(archived ? "Link archived" : "Link moved to Inbox") + snackbar(archived ? "Link archived" : "Link unarchived") } func removeLibraryItem(dataService: DataService, objectID: NSManagedObjectID) { @@ -221,7 +200,7 @@ import Views dataService.setItemLabels(itemID: item.unwrappedID, labels: InternalLinkedItemLabel.make(Set(existingLabels + [label]) as NSSet)) item.update(inContext: dataService.viewContext) - updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter)) + fetcher.refreshFeatureItems(dataService: dataService) } } @@ -235,16 +214,12 @@ import Views func pinItem(dataService: DataService, item: Models.LibraryItem) { addLabel(dataService: dataService, item: item, label: "Pinned", color: "#0A84FF") - if featureFilter == FeaturedItemFilter.pinned.rawValue { - updateFeatureFilter(context: dataService.viewContext, filter: .pinned) - } + fetcher.refreshFeatureItems(dataService: dataService) } func unpinItem(dataService: DataService, item: Models.LibraryItem) { removeLabel(dataService: dataService, item: item, named: "Pinned") - if featureFilter == FeaturedItemFilter.pinned.rawValue { - updateFeatureFilter(context: dataService.viewContext, filter: .pinned) - } + fetcher.refreshFeatureItems(dataService: dataService) } func markRead(dataService: DataService, item: Models.LibraryItem) { diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryAddFeedView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryAddFeedView.swift index 420fb4cf0..bc8834f9d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryAddFeedView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryAddFeedView.swift @@ -5,43 +5,16 @@ import Services import SwiftUI import Views -@MainActor final class LibraryAddFeedViewModel: NSObject, ObservableObject { - @Published var isLoading = false - @Published var errorMessage: String = "" - @Published var showErrorMessage: Bool = false - - @Environment(\.dismiss) private var dismiss - - func addLink(dataService: DataService, newLinkURL: String, dismiss: DismissAction) { - isLoading = true - Task { - if URL(string: newLinkURL) == nil { - error("Invalid link") - } else { - let result = try? await dataService.saveURL(id: UUID().uuidString, url: newLinkURL) - if result == nil { - error("Error adding link") - } else { - dismiss() - } - } - isLoading = false - } - } - - func error(_ msg: String) { - errorMessage = msg - showErrorMessage = true - isLoading = false - } -} - struct LibraryAddFeedView: View { - @StateObject var viewModel = LibraryAddFeedViewModel() - - @State var newLinkURL: String = "" + let dismiss: () -> Void + @State var feedURL: String = "" @EnvironmentObject var dataService: DataService - @Environment(\.dismiss) private var dismiss + + @State var prefetchContent = true + @State var folderSelection = "following" + @State var selectedLabels = [LinkedItemLabel]() + + let toastOperationHandler: ToastOperationHandler? enum FocusField: Hashable { case addLinkEditor @@ -52,11 +25,9 @@ struct LibraryAddFeedView: View { var body: some View { Group { #if os(iOS) - Form { - innerBody - .navigationTitle("Add Link") - .navigationBarTitleDisplayMode(.inline) - } + innerBody + .navigationTitle("Add Feed URL") + .navigationBarTitleDisplayMode(.inline) #else innerBody #endif @@ -67,7 +38,6 @@ struct LibraryAddFeedView: View { .onAppear { focusedField = .addLinkEditor } - .navigationTitle("Add Link") #if os(iOS) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -75,14 +45,23 @@ struct LibraryAddFeedView: View { dismissButton } ToolbarItem(placement: .navigationBarTrailing) { - viewModel.isLoading ? AnyView(ProgressView()) : AnyView(addButton) + NavigationLink( + destination: LibraryScanFeedView( + dismiss: self.dismiss, + viewModel: LibraryAddFeedViewModel( + dataService: dataService, + feedURL: feedURL, + prefetchContent: prefetchContent, + folder: folderSelection, + selectedLabels: selectedLabels, + toastOperationHandler: toastOperationHandler + ) + ), + label: { Text("Add").bold().disabled(feedURL.isEmpty) } + ) } } #endif - .alert(viewModel.errorMessage, - isPresented: $viewModel.showErrorMessage) { - Button(LocalText.genericOk, role: .cancel) { viewModel.showErrorMessage = false } - } } var cancelButton: some View { @@ -101,49 +80,36 @@ struct LibraryAddFeedView: View { } var innerBody: some View { - Group { - TextField("Add Link", text: $newLinkURL) - #if os(iOS) - .keyboardType(.URL) - #endif - .autocorrectionDisabled(true) - .textFieldStyle(StandardTextFieldStyle()) - .focused($focusedField, equals: .addLinkEditor) + List { + Section { + TextField("Feed or site URL", text: $feedURL) + #if os(iOS) + .keyboardType(.URL) + #endif + .autocorrectionDisabled(true) + .textFieldStyle(StandardTextFieldStyle()) + .focused($focusedField, equals: .addLinkEditor) - Button(action: { - if let url = pasteboardString { - newLinkURL = url - } else { - viewModel.error("No URL on pasteboard") - } - }, label: { - Text("Get from pasteboard") - }) + Button(action: { + if let url = pasteboardString { + feedURL = url + } else { + // viewModel.error("No URL on pasteboard") + } + }, label: { + Text("Get from pasteboard") + }) + } - #if os(macOS) - Spacer() - HStack { - cancelButton - Spacer() - addButton - } - .frame(maxWidth: .infinity) - #endif - } - } - - var addButton: some View { - Button( - action: { - viewModel.addLink(dataService: dataService, newLinkURL: newLinkURL, dismiss: dismiss) - }, - label: { Text("Add").bold() } - ) - .keyboardShortcut(.defaultAction) - .onSubmit { - viewModel.addLink(dataService: dataService, newLinkURL: newLinkURL, dismiss: dismiss) - } - .disabled(viewModel.isLoading) + Section { + SubscriptionSettings( + feedURL: $feedURL, + prefetchContent: $prefetchContent, + folderSelection: $folderSelection, + selectedLabels: $selectedLabels + ) + } + }.listStyle(.insetGrouped) } var dismissButton: some View { @@ -151,6 +117,53 @@ struct LibraryAddFeedView: View { action: { dismiss() }, label: { Text(LocalText.genericClose) } ) - .disabled(viewModel.isLoading) + } +} + +private struct SubscriptionSettings: View { + @Binding var feedURL: String + @Binding var prefetchContent: Bool + @Binding var folderSelection: String + @Binding var selectedLabels: [LinkedItemLabel] + + @State var showLabelsSelector = false + + var folderRow: some View { + HStack { + Picker("Destination Folder", selection: $folderSelection) { + Text("Inbox").tag("inbox") + Text("Following").tag("following") + } + .pickerStyle(MenuPickerStyle()) + } + } + + var labelRuleRow: some View { + HStack { + Text("Add Labels") + Spacer(minLength: 30) + Button(action: { showLabelsSelector = true }, label: { + if selectedLabels.count > 0 { + let labelNames = selectedLabels.map(\.unwrappedName) + Text("[\(labelNames.joined(separator: ","))]") + .lineLimit(1) + } else { + Text("Create Rule") + } + }) + } + } + + var body: some View { + Group { + // Toggle(isOn: $prefetchContent, label: { Text("Prefetch Content:") }) + folderRow + // labelRuleRow + } + .sheet(isPresented: $showLabelsSelector) { + ApplyLabelsView(mode: .list(selectedLabels), onSave: { labels in + selectedLabels = labels + }) + } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryItemMenu.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryItemMenu.swift index b91ffdd46..2b68877bf 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryItemMenu.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryItemMenu.swift @@ -17,33 +17,6 @@ import Views action: { viewModel.itemUnderLabelEdit = item }, label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") } ) -// Button(action: { -// withAnimation(.linear(duration: 0.4)) { -// viewModel.setLinkArchived( -// dataService: dataService, -// objectID: item.objectID, -// archived: !item.isArchived -// ) -// } -// }, label: { -// Label( -// item.isArchived ? "Unarchive" : "Archive", -// systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox" -// ) -// }) -// Button("Remove Item", role: .destructive) { -// viewModel.removeLink(dataService: dataService, objectID: item.objectID) -// } -// if let author = item.author { -// Button( -// action: { -// viewModel.filterState.searchTerm = "author:\"\(author)\"" -// }, -// label: { -// Label(String("More by \(author)"), systemImage: "person") -// } -// ) -// } } else { Button( action: { viewModel.recoverItem(dataService: dataService, itemID: item.unwrappedID) }, diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListConfig.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListConfig.swift index 46200aa35..47e196177 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListConfig.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListConfig.swift @@ -14,6 +14,7 @@ enum CardStyle { struct LibraryListConfig { var hasFeatureCards = false + var hasReadNowSection = false var leadingSwipeActions = [SwipeAction]() var trailingSwipeActions = [SwipeAction]() var cardStyle = CardStyle.library diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListView.swift deleted file mode 100644 index e9c051a95..000000000 --- a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// File.swift -// -// -// Created by Jackson Harper on 6/29/23. -// - -import Foundation -import Models -import SwiftUI - -struct LibraryListView: View { - @StateObject private var libraryViewModel = HomeFeedViewModel( - folder: "inbox", - fetcher: LibraryItemFetcher(), - listConfig: LibraryListConfig( - hasFeatureCards: true, - leadingSwipeActions: [.pin], - trailingSwipeActions: [.archive, .delete], - cardStyle: .library - ) - ) - - var body: some View { -// ZStack { -// NavigationLink( -// destination: LinkDestination(selectedItem: libraryViewModel.selectedItem), -// isActive: $libraryViewModel.linkIsActive -// ) { -// EmptyView() -// } - HomeView(viewModel: libraryViewModel) - .tabItem { - Label { - Text("Library") - } icon: { - Image.tabLibrary - } - } -// } - } -} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryScanFeedView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryScanFeedView.swift new file mode 100644 index 000000000..dc5455f01 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryScanFeedView.swift @@ -0,0 +1,231 @@ +import Foundation +import Models +import Services +import SwiftUI +import Utils + +@MainActor +public class LibraryAddFeedViewModel: NSObject, ObservableObject { + let dataService: DataService + let feedURL: String + let prefetchContent: Bool + let folder: String + let selectedLabels: [LinkedItemLabel] + let toastOperationHandler: ToastOperationHandler? + + @Published var isLoading = true + @Published var errorMessage: String = "" + @Published var showErrorMessage: Bool = false + + @Published var feeds: [Feed] = [] + @Published var selected: [String] = [] + + init(dataService: DataService, feedURL: String, prefetchContent: Bool, folder: String, selectedLabels: [LinkedItemLabel], toastOperationHandler: ToastOperationHandler?) { + self.dataService = dataService + self.feedURL = feedURL + self.prefetchContent = prefetchContent + self.folder = folder + self.selectedLabels = selectedLabels + self.toastOperationHandler = toastOperationHandler + } + + func scanFeed() async { + isLoading = true + if let feedURL = URL(string: feedURL) { + let result = try? await dataService.scanFeed(feedURL: feedURL) + if let feeds = result { + self.feeds = feeds + selected = feeds.map(\.url) + } else { + feeds = [] + error("Error adding feed") + } + } else { + error("invalid URL") + } + isLoading = false + } + + func addFeeds() async { + if let toastOperationHandler = toastOperationHandler { + toastOperationHandler.update(OperationStatus.isPerforming, "Subscribing...") + + let selected = self.selected + + let addTask = Task.detached(priority: .background) { + _ = await withTaskGroup(of: Bool.self) { group in + for feedURL in selected { + group.addTask { + (try? await self.dataService.subscribeToFeed(feedURL: feedURL, folder: self.folder, fetchContent: self.prefetchContent)) ?? false + } + } + + var successCount = 0 + var failureCount = 0 + for await value in group { + if value { + successCount += 1 + } else { + failureCount += 1 + } + } + + let hasFailures = failureCount + DispatchQueue.main.async { + if hasFailures > 0 { + toastOperationHandler.update(OperationStatus.failure, "Failed to subscribe to \(hasFailures) feeds") + } else { + toastOperationHandler.update(OperationStatus.success, "Subscribed") + } + } + } + } + toastOperationHandler.performOperation(addTask) + } else { + _ = await withTaskGroup(of: Bool.self) { group in + for feedURL in selected { + group.addTask { + (try? await self.dataService.subscribeToFeed(feedURL: feedURL, folder: self.folder, fetchContent: self.prefetchContent)) ?? false + } + } + + var successCount = 0 + var failureCount = 0 + for await value in group { + if value { + successCount += 1 + } else { + failureCount += 1 + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(4000)) { + if failureCount > 0 { + showInLibrarySnackbar("Failed to add \(failureCount) feeds") + } else { + showInLibrarySnackbar("Added \(successCount) feed\(successCount == 0 ? "" : "s")") + } + } + } + } + } + + func setLabelsRule(dataService _: DataService, existingRule _: Rule?, ruleName _: String, filter _: String, labelIDs _: [String]) async { +// Task { +// operationMessage = "Creating label rule..." +// operationStatus = .isPerforming +// do { +// // Make sure the labels have been created +// await loadLabels(dataService: dataService) +// let existingLabelIDs = labels?.map(\.unwrappedID) ?? [] +// if labelIDs.first(where: { !existingLabelIDs.contains($0) }) != nil { +// throw BasicError.message(messageText: "Label not created") +// } +// +// _ = try await dataService.createOrUpdateAddLabelsRule( +// existingID: existingRule?.id, +// name: ruleName, +// filter: filter, +// labelIDs: labelIDs +// ) +// if let newRules = try? await dataService.rules() { +// if !newRules.contains(where: { $0.name == ruleName }) { +// throw BasicError.message(messageText: "Rule not created") +// } +// rules = newRules +// } +// operationMessage = "Rule created" +// operationStatus = .success +// } catch { +// operationMessage = "Failed to create label rule" +// operationStatus = .failure +// } +// } + } + + func error(_ msg: String) { + errorMessage = msg + showErrorMessage = true + isLoading = false + } +} + +@MainActor +public struct LibraryScanFeedView: View { + let dismiss: () -> Void + @StateObject var viewModel: LibraryAddFeedViewModel + + func isSelected(_ url: String) -> Bool { + viewModel.selected.contains(url) + } + + var innerBody: some View { + if viewModel.isLoading { + AnyView(ProgressView().frame(maxWidth: .infinity, alignment: .center)) + } else if viewModel.feeds.count == 0 { + AnyView(Text("No feeds found for URL")) + } else { + AnyView(List { + Section("Choose the feeds to add") { + ForEach(viewModel.feeds, id: \.title) { feed in + Button(action: { + if !isSelected(feed.url) { + viewModel.selected.append(feed.url) + } else { + if let idx = viewModel.selected.firstIndex(of: feed.url) { + viewModel.selected.remove(at: idx) + } + } + }, label: { + HStack { + Text(feed.title) + Spacer() + if isSelected(feed.url) { + Image(systemName: "checkmark") + } + } + .contentShape(Rectangle()) + }) + } + } + }) + } + } + + public var body: some View { + Group { + #if os(iOS) + Form { + innerBody + .navigationTitle("Select Feeds") + .navigationBarTitleDisplayMode(.inline) + }.task { + await viewModel.scanFeed() + } + #else + innerBody + #endif + } + .toolbar { + ToolbarItem(placement: .barTrailing) { + if viewModel.selected.count > 0 { + Button(action: { + dismiss() + showInLibrarySnackbar("Adding feeds...") + Task { + await viewModel.addFeeds() + } + }, label: { + Text("Add").bold().disabled(viewModel.selected.count < 1) + }) + } else { + Button(action: { + dismiss() + }, label: { + Text("Done").bold() + }) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift index 523867779..d7e768e0a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift @@ -75,6 +75,7 @@ innerBody }.introspectViewController { controller in searchBar = Introspect.findChild(ofType: UISearchBar.self, in: controller.view) + searchBar?.smartQuotesType = .no } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift index 735e8c8cc..829fb3614 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -2,6 +2,7 @@ import Models import Services import SwiftUI +import Utils import Views @MainActor @@ -195,15 +196,21 @@ struct ApplyLabelsView: View { } } +func isSystemLabel(_ label: LinkedItemLabel) -> Bool { + label.name == "RSS" || label.name == "Newsletter" || label.name == "Pinned" +} + extension Sequence where Element == LinkedItemLabel { func applySearchFilter(_ searchFilter: String) -> [LinkedItemLabel] { + let hideSystemLabels = UserDefaults(suiteName: "group.app.omnivoreapp")?.bool(forKey: UserDefaultKey.hideSystemLabels.rawValue) ?? false + if searchFilter.isEmpty || searchFilter == ZWSP { return map { $0 } // return the identity of the sequence } if searchFilter.starts(with: ZWSP) { let index = searchFilter.index(searchFilter.startIndex, offsetBy: 1) let trimmed = searchFilter.suffix(from: index).lowercased() - return filter { ($0.name ?? "").lowercased().contains(trimmed) } + return filter { ($0.name ?? "").lowercased().contains(trimmed) && (!hideSystemLabels || !isSystemLabel($0)) } } return filter { ($0.name ?? "").lowercased().contains(searchFilter.lowercased()) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/FilterByLabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/FilterByLabelsViewModel.swift index 04567a7e9..8355db882 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/FilterByLabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/FilterByLabelsViewModel.swift @@ -2,11 +2,12 @@ import CoreData import Models import Services import SwiftUI +import Utils import Views @MainActor final class FilterByLabelsViewModel: ObservableObject { @Published var isLoading = false - @Published var errorMessage: String? = nil + @Published var errorMessage: String? @Published var labels = [LinkedItemLabel]() @Published var selectedLabels = [LinkedItemLabel]() @Published var negatedLabels = [LinkedItemLabel]() @@ -14,7 +15,9 @@ import Views @Published var labelSearchFilter = "" func setLabels(_ labels: [LinkedItemLabel]) { - self.labels = labels.sorted { left, right in + let hideSystemLabels = UserDefaults(suiteName: "group.app.omnivoreapp")?.bool(forKey: UserDefaultKey.hideSystemLabels.rawValue) ?? false + + self.labels = labels.filter { !hideSystemLabels || !isSystemLabel($0) }.sorted { left, right in let aTrimmed = left.unwrappedName.trimmingCharacters(in: .whitespaces) let bTrimmed = right.unwrappedName.trimmingCharacters(in: .whitespaces) return aTrimmed.caseInsensitiveCompare(bTrimmed) == .orderedAscending diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift index 96c859699..86c3f4c59 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift @@ -10,22 +10,31 @@ struct LabelsView: View { @State private var showDeleteConfirmation = false @State private var labelToRemove: LinkedItemLabel? + @Environment(\.dismiss) private var dismiss + + @AppStorage(UserDefaultKey.hideSystemLabels.rawValue, store: UserDefaults(suiteName: "group.app.omnivoreapp")) var hideSystemLabels = false + var body: some View { List { - ForEach(viewModel.labels, id: \.id) { label in - HStack { - TextChip(feedItemLabel: label).allowsHitTesting(false) - Spacer() - Button( - action: { - labelToRemove = label - showDeleteConfirmation = true - }, - label: { Image(systemName: "trash") } - ) + Section { + ForEach(viewModel.labels, id: \.id) { label in + HStack { + TextChip(feedItemLabel: label).allowsHitTesting(false) + Spacer() + Button( + action: { + labelToRemove = label + showDeleteConfirmation = true + }, + label: { Image(systemName: "trash") } + ) + } } + createLabelButton + } + Section("Label settings") { + Toggle("Hide system labels", isOn: $hideSystemLabels) } - createLabelButton } .navigationTitle(LocalText.labelsGeneric) .alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) { @@ -43,6 +52,14 @@ struct LabelsView: View { } Button(LocalText.cancelGeneric, role: .cancel) { self.labelToRemove = nil } } + .onChange(of: hideSystemLabels) { _ in + Task { + await viewModel.loadLabels(dataService: dataService, item: nil) + } + } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in + dismiss() + } .sheet(isPresented: $viewModel.showCreateLabelModal) { CreateLabelView(viewModel: viewModel, newLabelName: viewModel.labelSearchFilter) } @@ -57,7 +74,7 @@ struct LabelsView: View { let trimmedLabelName = viewModel.labelSearchFilter.trimmingCharacters(in: .whitespacesAndNewlines) Image(systemName: "tag").foregroundColor(.blue) Text( - viewModel.labelSearchFilter.count > 0 ? + viewModel.labelSearchFilter.count > 0 && viewModel.labelSearchFilter != ZWSP ? "Create: \"\(trimmedLabelName)\" label" : LocalText.createLabelMessage ).foregroundColor(.blue) @@ -106,16 +123,6 @@ struct CreateLabelView: View { var innerBody: some View { VStack { - HStack { - if !newLabelName.isEmpty, newLabelColor != .clear { - TextChip(text: newLabelName, color: newLabelColor) - } else { - Text(LocalText.labelsViewAssignNameColor).font(.appBody) - } - Spacer() - } - .padding(.bottom, 8) - TextField(LocalText.labelNamePlaceholder, text: $newLabelName) .textFieldStyle(StandardTextFieldStyle()) .onChange(of: newLabelName) { inputLabelName in diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index 23ad0059c..5165e16dd 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -2,6 +2,7 @@ import CoreData import Models import Services import SwiftUI +import Utils @MainActor public final class LabelsViewModel: ObservableObject { let labelNameMaxLength = 64 @@ -16,7 +17,9 @@ import SwiftUI public init() {} func setLabels(_ labels: [LinkedItemLabel]) { - self.labels = labels.sorted { left, right in + let hideSystemLabels = UserDefaults(suiteName: "group.app.omnivoreapp")?.bool(forKey: UserDefaultKey.hideSystemLabels.rawValue) ?? false + + self.labels = labels.filter { !hideSystemLabels || !isSystemLabel($0) }.sorted { left, right in let aTrimmed = left.unwrappedName.trimmingCharacters(in: .whitespaces) let bTrimmed = right.unwrappedName.trimmingCharacters(in: .whitespaces) return aTrimmed.caseInsensitiveCompare(bTrimmed) == .orderedAscending diff --git a/apple/OmnivoreKit/Sources/App/Views/LibrarySidebar.swift b/apple/OmnivoreKit/Sources/App/Views/LibrarySidebar.swift index e50b7c169..b21cc0ba7 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibrarySidebar.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibrarySidebar.swift @@ -21,36 +21,6 @@ import SwiftUI @AppStorage("inboxMenuState") var inboxMenuState = "open" @AppStorage("followingMenuState") var followingMenuState = "open" - func createInboxViewModel(_ filter: InternalFilter) -> HomeFeedViewModel { - let result = HomeFeedViewModel( - folder: "inbox", - fetcher: LibraryItemFetcher(), - listConfig: LibraryListConfig( - hasFeatureCards: true, - leadingSwipeActions: [.pin], - trailingSwipeActions: [.archive, .delete], - cardStyle: .library - ) - ) - result.appliedFilter = filter - return result - } - - func createFollowingViewModel(_ filter: InternalFilter) -> HomeFeedViewModel { - let result = HomeFeedViewModel( - folder: "following", - fetcher: LibraryItemFetcher(), - listConfig: LibraryListConfig( - hasFeatureCards: false, - leadingSwipeActions: [.moveToInbox], - trailingSwipeActions: [.archive, .delete], - cardStyle: .library - ) - ) - result.appliedFilter = filter - return result - } - var innerBody: some View { ZStack { NavigationLink("", destination: HomeView(viewModel: inboxViewModel), isActive: $inboxActive) diff --git a/apple/OmnivoreKit/Sources/App/Views/LibrarySplitView.swift b/apple/OmnivoreKit/Sources/App/Views/LibrarySplitView.swift index 90e72d7f6..c7f0b144a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibrarySplitView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibrarySplitView.swift @@ -1,16 +1,18 @@ import Foundation +import Models import Services import SwiftUI @MainActor public struct LibrarySplitView: View { - @EnvironmentObject var audioController: AudioController + @EnvironmentObject var dataService: DataService @StateObject private var inboxViewModel = HomeFeedViewModel( folder: "inbox", fetcher: LibraryItemFetcher(), listConfig: LibraryListConfig( hasFeatureCards: true, + hasReadNowSection: true, leadingSwipeActions: [.pin], trailingSwipeActions: [.archive, .delete], cardStyle: .library @@ -22,25 +24,25 @@ public struct LibrarySplitView: View { fetcher: LibraryItemFetcher(), listConfig: LibraryListConfig( hasFeatureCards: false, + hasReadNowSection: false, leadingSwipeActions: [.moveToInbox], - trailingSwipeActions: [.archive, .delete], + trailingSwipeActions: [.delete], cardStyle: .library ) ) - @State var selected = "home" + private let syncManager = LibrarySyncManager() #if os(iOS) public var body: some View { NavigationView { LibrarySidebar(inboxViewModel: inboxViewModel, followingViewModel: followingViewModel) .navigationBarTitleDisplayMode(.inline) - .tag("inbox") + .navigationTitle("") HomeFeedContainerView(viewModel: inboxViewModel) .navigationViewStyle(.stack) .navigationBarTitleDisplayMode(.inline) - .tag("following") } .navigationBarTitleDisplayMode(.inline) .accentColor(.appGrayTextContrast) @@ -48,6 +50,32 @@ public struct LibrarySplitView: View { $0.preferredPrimaryColumnWidth = 230 $0.displayModeButtonVisibility = .always } + .onOpenURL { url in + inboxViewModel.linkRequest = nil + if let deepLink = DeepLink.make(from: url) { + switch deepLink { + case let .search(query): + inboxViewModel.searchTerm = query + case let .savedSearch(named): + if let filter = inboxViewModel.findFilter(dataService, named: named) { + inboxViewModel.appliedFilter = filter + } + case let .webAppLinkRequest(requestID): + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { + withoutAnimation { + inboxViewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID) + inboxViewModel.presentWebContainer = true + } + } + } + } + // selectedTab = "inbox" + } + .onReceive(NSNotification.performSyncPublisher) { _ in + Task { + await syncManager.syncItems(dataService: dataService) + } + } } #endif diff --git a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift index ecf6bd394..3b63712c3 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift @@ -29,28 +29,32 @@ struct LibraryTabView: View { UITabBar.appearance().isHidden = true } - @StateObject private var followingViewModel = HomeFeedViewModel( - folder: "following", - fetcher: LibraryItemFetcher(), - listConfig: LibraryListConfig( - hasFeatureCards: false, - leadingSwipeActions: [.moveToInbox], - trailingSwipeActions: [.archive, .delete], - cardStyle: .library - ) - ) - - @StateObject private var libraryViewModel = HomeFeedViewModel( + @StateObject private var inboxViewModel = HomeFeedViewModel( folder: "inbox", fetcher: LibraryItemFetcher(), listConfig: LibraryListConfig( hasFeatureCards: true, + hasReadNowSection: true, leadingSwipeActions: [.pin], trailingSwipeActions: [.archive, .delete], cardStyle: .library ) ) + @StateObject private var followingViewModel = HomeFeedViewModel( + folder: "following", + fetcher: LibraryItemFetcher(), + listConfig: LibraryListConfig( + hasFeatureCards: false, + hasReadNowSection: false, + leadingSwipeActions: [.moveToInbox], + trailingSwipeActions: [.delete], + cardStyle: .library + ) + ) + + private let syncManager = LibrarySyncManager() + var body: some View { VStack(spacing: 0) { TabView(selection: $selectedTab) { @@ -63,7 +67,7 @@ struct LibraryTabView: View { } NavigationView { - HomeFeedContainerView(viewModel: libraryViewModel) + HomeFeedContainerView(viewModel: inboxViewModel) .navigationBarTitleDisplayMode(.inline) .navigationViewStyle(.stack) }.tag("inbox") @@ -90,5 +94,31 @@ struct LibraryTabView: View { ExpandedAudioPlayer() } .navigationBarHidden(true) + .onReceive(NSNotification.performSyncPublisher) { _ in + Task { + await syncManager.syncItems(dataService: dataService) + } + } + .onOpenURL { url in + inboxViewModel.linkRequest = nil + if let deepLink = DeepLink.make(from: url) { + switch deepLink { + case let .search(query): + inboxViewModel.searchTerm = query + case let .savedSearch(named): + if let filter = inboxViewModel.findFilter(dataService, named: named) { + inboxViewModel.appliedFilter = filter + } + case let .webAppLinkRequest(requestID): + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { + withoutAnimation { + inboxViewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID) + inboxViewModel.presentWebContainer = true + } + } + } + } + selectedTab = "inbox" + } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift index d3b66c479..90dfc2612 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift @@ -22,28 +22,6 @@ import Views trackReadEvent() } - func handleArchiveAction(dataService: DataService) { - guard let objectID = item?.objectID ?? pdfItem?.objectID else { return } - dataService.archiveLink(objectID: objectID, archived: !isItemArchived) - showInLibrarySnackbar(!isItemArchived ? "Link archived" : "Link moved to Inbox") - } - - func handleDeleteAction(dataService: DataService) { - guard let objectID = item?.objectID ?? pdfItem?.objectID else { return } - removeLibraryItemAction(dataService: dataService, objectID: objectID) - } - - func updateItemReadStatus(dataService: DataService) { - guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return } - - dataService.updateLinkReadingProgress( - itemID: itemID, - readingProgress: isItemRead ? 0 : 100, - anchorIndex: 0, - force: false - ) - } - private func trackReadEvent() { guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return } guard let slug = item?.unwrappedSlug ?? pdfItem?.slug else { return } diff --git a/apple/OmnivoreKit/Sources/App/Views/LogoutView.swift b/apple/OmnivoreKit/Sources/App/Views/LogoutView.swift new file mode 100644 index 000000000..1ebd97ee1 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/LogoutView.swift @@ -0,0 +1,33 @@ +import Models +import Services +import SwiftUI +import Utils +import Views + +struct LogoutView: View { + @EnvironmentObject var dataService: DataService + @EnvironmentObject var authenticator: Authenticator + @Environment(\.openURL) var openURL + + let deletedAccountConfirmationMessage = "Your account has been deleted. Additional steps may be needed if Sign in with Apple was used to register." + + public var body: some View { + VStack(alignment: .center) { + Text("Logging out...") + ProgressView() + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { + authenticator.logout(dataService: dataService) + } + } + .alert(deletedAccountConfirmationMessage, isPresented: $authenticator.showAppleRevokeTokenAlert) { + Button("View Details") { + openURL(URL(string: "https://support.apple.com/en-us/HT210426")!) + } + Button(LocalText.dismissButton) { self.authenticator.showAppleRevokeTokenAlert = false } + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/OperationToast.swift b/apple/OmnivoreKit/Sources/App/Views/OperationToast.swift new file mode 100644 index 000000000..c0d8845ea --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/OperationToast.swift @@ -0,0 +1,46 @@ + +import SwiftUI + +enum OperationStatus { + case none + case isPerforming + case success + case failure +} + +struct OperationToast: View { + @Binding var operationMessage: String? + @Binding var showOperationToast: Bool + @Binding var operationStatus: OperationStatus + + var body: some View { + VStack { + HStack { + if operationStatus == .isPerforming { + Text(operationMessage ?? "Performing...") + Spacer() + ProgressView() + } else if operationStatus == .success { + Text(operationMessage ?? "Success") + Spacer() + } else if operationStatus == .failure { + Text(operationMessage ?? "Failure") + Spacer() + Button(action: { showOperationToast = false }, label: { + Text("Done").bold() + }) + } + } + .padding(10) + .frame(minHeight: 50) + .frame(maxWidth: 380) + .background(Color(hex: "2A2A2A")) + .foregroundColor(Color(hex: "EBEBEB")) + .cornerRadius(4.0) + .tint(Color.green) + } + .padding(.bottom, 60) + .padding(.horizontal, 10) + .ignoresSafeArea(.all, edges: .bottom) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ClubsView.swift similarity index 95% rename from apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift rename to apple/OmnivoreKit/Sources/App/Views/Profile/ClubsView.swift index fb741b32d..a36567585 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ClubsView.swift @@ -49,9 +49,11 @@ } } - struct CreateRecommendationGroupView: View { + struct ClubsView: View { @State var name = "" @EnvironmentObject var dataService: DataService + @Environment(\.dismiss) private var dismiss + @StateObject var viewModel = RecommendationsGroupsViewModel() var nextButton: some View { @@ -99,6 +101,9 @@ ) } } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in + dismiss() + } #if os(iOS) .navigationViewStyle(.stack) .navigationBarTitleDisplayMode(.inline) @@ -131,7 +136,7 @@ } .sheet(isPresented: $viewModel.showCreateSheet) { NavigationView { - CreateRecommendationGroupView(viewModel: self.viewModel) + ClubsView(viewModel: self.viewModel) } } .task { await viewModel.loadGroups(dataService: dataService) } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/FiltersView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/FiltersView.swift index 66214abf9..89086bef6 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/FiltersView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/FiltersView.swift @@ -1,4 +1,3 @@ - import Models import Services import SwiftUI @@ -9,8 +8,16 @@ import Views @Published var isLoading = false @Published var isCreating = false @Published var networkError = false + + @Published var hasBadgePermission = false @Published var libraryFilters = [InternalFilter]() + @Published var badgeFilter = BadgeCountHandler.badgeFilter { + didSet { + BadgeCountHandler.badgeFilter = badgeFilter + } + } + @AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false @AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false @@ -25,10 +32,35 @@ import Views isLoading = false } + + func loadBadgePermission() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + if settings.badgeSetting == .enabled { + self.hasBadgePermission = true + } else { + self.hasBadgePermission = false + } + + print("notification settings: ", settings.badgeSetting.rawValue) + print("got the notification settings") + } + } + } + + func requestBadgePermission() { + UNUserNotificationCenter.current().requestAuthorization(options: UNAuthorizationOptions.badge) { success, error in + DispatchQueue.main.async { + print("requested badge permission: ", success, error) + } + } + } } struct FiltersView: View { @EnvironmentObject var dataService: DataService + @Environment(\.dismiss) private var dismiss + @StateObject var viewModel = FiltersViewModel() var body: some View { @@ -45,12 +77,18 @@ struct FiltersView: View { #endif } .navigationTitle(LocalText.filtersGeneric) - .task { await viewModel.loadFilters(dataService: dataService) } + .task { + viewModel.loadBadgePermission() + await viewModel.loadFilters(dataService: dataService) + } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in + dismiss() + } } private var innerBody: some View { List { - Section { + Section(header: Text("User Interface")) { Toggle("Hide following tab", isOn: $viewModel.hideFollowingTab) Toggle("Hide feature section", isOn: $viewModel.hideFeatureSection) } @@ -60,6 +98,25 @@ struct FiltersView: View { Text(filter.name) } } + +// Section(header: Text("Application Badge")) { +// Toggle("Display Badge Count", isOn: $viewModel.hasBadgePermission) +// .onChange(of: viewModel.hasBadgePermission) { _ in +// if viewModel.hasBadgePermission { +// viewModel.requestBadgePermission() +// } else { +// UIApplication.shared.applicationIconBadgeNumber = 0 +// } +// } +// +// if viewModel.hasBadgePermission { +// NavigationLink(destination: { +// SelectBadgeFilterView(viewModel: viewModel) +// }, label: { +// Text(viewModel.badgeFilter) +// }) +// } +// } } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/NewsletterEmailsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/NewsletterEmailsView.swift index cdbff89f0..2ab0b17c9 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/NewsletterEmailsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/NewsletterEmailsView.swift @@ -2,19 +2,28 @@ import Models import PopupView import Services import SwiftUI +import Transmission import Views @MainActor final class NewsletterEmailsViewModel: ObservableObject { @Published var isLoading = false + @Published var showAddressCopied = false @Published var emails = [NewsletterEmail]() + @Published var showOperationToast = false + @Published var operationStatus: OperationStatus = .none + @Published var operationMessage: String? + func loadEmails(dataService: DataService) async { isLoading = true - if let objectIDs = try? await dataService.newsletterEmails() { + do { + let objectIDs = try await dataService.newsletterEmails() await dataService.viewContext.perform { [weak self] in self?.emails = objectIDs.compactMap { dataService.viewContext.object(with: $0) as? NewsletterEmail } } + } catch { + print("ERROR LOADING EMAILS: ", error) } isLoading = false @@ -33,22 +42,47 @@ import Views isLoading = false } + + func updateEmail(dataService: DataService, email: NewsletterEmail, folder: String? = nil, description: String? = nil) async { + operationMessage = "Updating email..." + operationStatus = .isPerforming + do { + _ = try await dataService.updateNewsletterEmail( + emailID: email.unwrappedEmailId, + folder: folder, + description: description + ) + await loadEmails(dataService: dataService) + operationMessage = "Email updated" + operationStatus = .success + } catch { + operationMessage = "Failed to update email" + operationStatus = .failure + } + } } struct NewsletterEmailsView: View { @EnvironmentObject var dataService: DataService + @Environment(\.dismiss) private var dismiss + @StateObject var viewModel = NewsletterEmailsViewModel() - @State var showSnackbar = false @State var snackbarOperation: SnackbarOperation? - func snackbar(message: String) { - snackbarOperation = SnackbarOperation(message: message, undoAction: nil) - showSnackbar = true - } - var body: some View { Group { + WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showOperationToast) { + OperationToast(operationMessage: $viewModel.operationMessage, showOperationToast: $viewModel.showOperationToast, operationStatus: $viewModel.operationStatus) + } label: { + EmptyView() + } + WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showAddressCopied) { + MessageToast() + } label: { + EmptyView() + } + #if os(iOS) Form { innerBody @@ -60,65 +94,118 @@ struct NewsletterEmailsView: View { .listStyle(InsetListStyle()) #endif } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in + dismiss() + } .task { await viewModel.loadEmails(dataService: dataService) } - .popup(isPresented: $showSnackbar) { - if let operation = snackbarOperation { - Snackbar(isShowing: $showSnackbar, operation: operation) - } else { - EmptyView() + .refreshable { + Task { + await viewModel.loadEmails(dataService: dataService) } - } customize: { - $0 - .type(.toast) - .autohideIn(2) - .position(.bottom) - .animation(.spring()) - .closeOnTapOutside(true) } } private var innerBody: some View { Group { - Section(footer: Text(LocalText.newslettersDescription)) { + if !viewModel.emails.isEmpty { + ForEach(viewModel.emails) { email in + Section { + NewsletterEmailRow(viewModel: viewModel, email: email, folderSelection: email.folder ?? "inbox") + } + } + } + + Section { + Text(LocalText.newslettersDescription) Button( action: { Task { await viewModel.createEmail(dataService: dataService) } }, label: { - HStack { - Image(systemName: "plus.circle.fill").foregroundColor(.green) + Label(title: { Text(LocalText.createNewEmailMessage) - Spacer() - } + }, icon: { + Image.addLink + }) } ) .disabled(viewModel.isLoading) } + } + .navigationTitle(LocalText.emailsGeneric) + } +} - if !viewModel.emails.isEmpty { - Section(header: Text(LocalText.newsletterEmailsExisting)) { - ForEach(viewModel.emails) { newsletterEmail in - Button( - action: { - #if os(iOS) - UIPasteboard.general.string = newsletterEmail.email - #endif +struct NewsletterEmailRow: View { + @StateObject var viewModel: NewsletterEmailsViewModel + @EnvironmentObject var dataService: DataService - #if os(macOS) - let pasteBoard = NSPasteboard.general - pasteBoard.clearContents() - pasteBoard.writeObjects([newsletterEmail.unwrappedEmail as NSString]) - #endif + @State var email: NewsletterEmail + @State var folderSelection: String - snackbar(message: "Email copied") - }, - label: { Text(newsletterEmail.unwrappedEmail) } - ) + var body: some View { + VStack { + HStack { + Text(email.unwrappedEmail).bold() + Spacer() + + Button( + action: { + #if os(iOS) + UIPasteboard.general.string = email.email + #endif + + #if os(macOS) + let pasteBoard = NSPasteboard.general + pasteBoard.clearContents() + pasteBoard.writeObjects([newsletterEmail.unwrappedEmail as NSString]) + #endif + + viewModel.showAddressCopied = true + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) { + viewModel.showAddressCopied = false + } + }, + label: { + Text("Copy") + } + ) + } + Divider() + Picker("Destination Folder", selection: $folderSelection) { + Text("Inbox").tag("inbox") + Text("Following").tag("following") + } + .pickerStyle(MenuPickerStyle()) + .onChange(of: folderSelection) { newValue in + Task { + viewModel.showOperationToast = true + await viewModel.updateEmail(dataService: dataService, email: email, folder: newValue) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) { + viewModel.showOperationToast = false } } } } - - .navigationTitle(LocalText.emailsGeneric) + } +} + +struct MessageToast: View { + var body: some View { + VStack { + HStack { + Text("Address copied") + Spacer() + } + .padding(10) + .frame(minHeight: 50) + .frame(maxWidth: 380) + .background(Color(hex: "2A2A2A")) + .cornerRadius(4.0) + .tint(Color.green) + } + .padding(.bottom, 70) + .padding(.horizontal, 10) + .ignoresSafeArea(.all, edges: .bottom) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index b52dd980c..db8b0f8c5 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -25,8 +25,10 @@ import Views loadProfileCardData(name: name, username: username, profileImageURL: currentViewer.profileImageURL) } - if let viewer = try? await dataService.fetchViewer() { - loadProfileCardData(name: viewer.name, username: viewer.username, profileImageURL: viewer.profileImageURL) + if profileCardData.name.isEmpty { + if let viewer = try? await dataService.fetchViewer() { + loadProfileCardData(name: viewer.name, username: viewer.username, profileImageURL: viewer.profileImageURL) + } } } @@ -62,12 +64,13 @@ struct ProfileView: View { @StateObject private var viewModel = ProfileContainerViewModel() + @State var shouldScrollToTop = false @State private var showLogoutConfirmation = false var body: some View { #if os(iOS) - Form { - innerBody + List { + innerBody.tag("TOP") } .toolbar { toolbarItems @@ -123,6 +126,7 @@ struct ProfileView: View { Group { Section { ProfileCard(data: viewModel.profileCardData) + .tag("PROFILE") .task { await viewModel.loadProfileData(dataService: dataService) } @@ -194,7 +198,7 @@ struct ProfileView: View { primaryButton: .destructive(Text(LocalText.genericConfirm)) { dismiss() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { - authenticator.logout(dataService: dataService) + authenticator.beginLogout() } }, secondaryButton: .cancel() diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationDevicesView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationDevicesView.swift index 2e3346a00..0d2a1d485 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationDevicesView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationDevicesView.swift @@ -28,6 +28,8 @@ import Views struct PushNotificationDevicesView: View { @EnvironmentObject var dataService: DataService + @Environment(\.dismiss) private var dismiss + @StateObject var viewModel = PushNotificationDevicesViewModel() var body: some View { @@ -43,6 +45,9 @@ struct PushNotificationDevicesView: View { .listStyle(InsetListStyle()) #endif } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in + dismiss() + } .task { viewModel.loadDevices(dataService: dataService) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationSettingsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationSettingsView.swift index 60fb512f9..df3be30d7 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationSettingsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationSettingsView.swift @@ -47,6 +47,8 @@ struct PushNotificationSettingsView: View { @EnvironmentObject var dataService: DataService + @Environment(\.dismiss) private var dismiss + @StateObject var viewModel = PushNotificationSettingsViewModel() @State var desiredNotificationsEnabled: Bool = false @@ -63,6 +65,9 @@ .listStyle(InsetListStyle()) #endif } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in + dismiss() + } .task { viewModel.checkPushNotificationsStatus() } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/SelectBadgeFilterView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/SelectBadgeFilterView.swift new file mode 100644 index 000000000..c6ccaf34a --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/SelectBadgeFilterView.swift @@ -0,0 +1,36 @@ +import Foundation +import Models +import Services +import SwiftUI + +@MainActor +public struct SelectBadgeFilterView: View { + @ObservedObject var viewModel: FiltersViewModel + + public var body: some View { + List { + Section(header: Text("Filter")) { + ForEach(viewModel.libraryFilters) { filter in + Button { + viewModel.badgeFilter = filter.filter + } label: { + HStack { + Text(filter.name) + Spacer() + if isSelected(filter) { + Image(systemName: "checkmark") + } + }.onTapGesture {} + }.contentShape(Rectangle()) + } + } + Section { + Text("Your selected filter will be used to display a badge value on the application icon.") + } + }.navigationTitle("Badge Filter") + } + + func isSelected(_ filter: InternalFilter) -> Bool { + viewModel.badgeFilter == filter.filter + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift deleted file mode 100644 index 00adc5c4a..000000000 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift +++ /dev/null @@ -1,173 +0,0 @@ -import Models -import Services -import SwiftUI -import Views - -@MainActor final class SubscriptionsViewModel: ObservableObject { - @Published var isLoading = true - @Published var subscriptions = [Subscription]() - @Published var popularSubscriptions = [Subscription]() - @Published var hasNetworkError = false - @Published var subscriptionNameToCancel: String? - - func loadSubscriptions(dataService: DataService) async { - isLoading = true - - do { - subscriptions = try await dataService.subscriptions().filter { $0.status == SubscriptionStatus.active } - } catch { - hasNetworkError = true - } - - isLoading = false - } - - func cancelSubscription(dataService: DataService) async -> Bool { - guard let subscriptionName = subscriptionNameToCancel else { return false } - - do { - try await dataService.deleteSubscription(subscriptionName: subscriptionName) - let index = subscriptions.firstIndex { $0.name == subscriptionName } - if let index = index { - subscriptions.remove(at: index) - } - return true - } catch { - appLogger.debug("failed to remove subscription") - return false - } - } -} - -struct SubscriptionsView: View { - @EnvironmentObject var dataService: DataService - @StateObject var viewModel = SubscriptionsViewModel() - @State private var deleteConfirmationShown = false - @State private var progressViewOpacity = 0.0 - - var body: some View { - Group { - if viewModel.isLoading { - ProgressView() - .opacity(progressViewOpacity) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) { - progressViewOpacity = 1 - } - } - .task { await viewModel.loadSubscriptions(dataService: dataService) } - } else if viewModel.hasNetworkError { - VStack { - Text(LocalText.subscriptionsErrorRetrieving).multilineTextAlignment(.center) - Button( - action: { Task { await viewModel.loadSubscriptions(dataService: dataService) } }, - label: { Text(LocalText.genericRetry) } - ) - .buttonStyle(RoundedRectButtonStyle()) - } - } else if viewModel.subscriptions.isEmpty { - VStack(alignment: .center) { - Spacer() - Text(LocalText.subscriptionsNone) - Spacer() - } - } else { - Group { - #if os(iOS) - Form { - innerBody - } - #elseif os(macOS) - List { - innerBody - } - .listStyle(InsetListStyle()) - #endif - } - } - } - .navigationTitle("Subscriptions") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - } - - private var innerBody: some View { - Group { - ForEach(viewModel.subscriptions, id: \.subscriptionID) { subscription in - SubscriptionCell(subscription: subscription) - .swipeActions(edge: .trailing) { - Button( - role: .destructive, - action: { - deleteConfirmationShown = true - viewModel.subscriptionNameToCancel = subscription.name - }, - label: { - Image(systemName: "trash") - } - ) - } - } - } - .alert("Are you sure you want to cancel this subscription?", isPresented: $deleteConfirmationShown) { - Button("Yes", role: .destructive) { - Task { - let unsubscribed = await viewModel.cancelSubscription(dataService: dataService) - // Snackbar.show(message: unsubscribed ? "Subscription cancelled." : "Could not unsubscribe.") - } - } - Button("No", role: .cancel) { - viewModel.subscriptionNameToCancel = nil - } - } - .navigationTitle(LocalText.subscriptionsGeneric) - } -} - -struct SubscriptionCell: View { - let subscription: Subscription - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 6) { - Text(subscription.name) - .font(.appCallout) - .lineSpacing(1.25) - .foregroundColor(.appGrayTextContrast) - .fixedSize(horizontal: false, vertical: true) - - if let updatedDate = subscription.updatedAt { - Text("Last received: \(updatedDate.formatted())") - .font(.appCaption) - .foregroundColor(.appGrayText) - .fixedSize(horizontal: false, vertical: true) - } - } - .multilineTextAlignment(.leading) - .padding(.vertical, 8) - - Spacer() - - Group { - if let icon = subscription.icon, let imageURL = URL(string: icon) { - AsyncImage(url: imageURL) { phase in - if let image = phase.image { - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 40, height: 40) - .cornerRadius(6) - } else if phase.error != nil { - EmptyView().frame(width: 40, height: 40, alignment: .top) - } else { - Color.appButtonBackground - .frame(width: 40, height: 40) - .cornerRadius(2) - } - } - } - }.frame(minHeight: 50) - } - } -} diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/SubscriptionsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/SubscriptionsView.swift new file mode 100644 index 000000000..371b16b77 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/SubscriptionsView.swift @@ -0,0 +1,550 @@ +import CoreData +import Models +import Services +import SwiftUI +import Transmission +import Views + +@MainActor +struct ToastOperationHandler { + let performOperation: (_: Sendable?) -> Void + let update: (_: OperationStatus, _: String) -> Void +} + +typealias OperationStatusHandler = (_: OperationStatus) -> Void + +@MainActor final class SubscriptionsViewModel: ObservableObject { + @Published var isLoading = true + @Published var feeds = [Subscription]() + @Published var newsletters = [Subscription]() + @Published var rules: [Rule]? + @Published var labels: [LinkedItemLabel]? + + @Published var hasNetworkError = false + @Published var subscriptionNameToCancel: String? + @Published var presentingSubscription: Subscription? + + @Published var showOperationToast = false + @Published var operationStatus: OperationStatus = .none + @Published var operationMessage: String? + + func loadSubscriptions(dataService: DataService) async { + isLoading = true + + do { + let subscriptions = try await dataService.subscriptions().filter { $0.status == SubscriptionStatus.active } + feeds = subscriptions.filter { $0.type == .feed } + newsletters = subscriptions.filter { $0.type == .newsletter } + hasNetworkError = false + } catch { + print("error fetching subscriptions: ", error) + hasNetworkError = true + } + + do { + // Also try to get the rules for auto labeling + rules = try await dataService.rules() + } catch { + print("error fetching rules and labels", error) + rules = [] + } + + await loadLabels(dataService: dataService) + + isLoading = false + } + + func loadLabels(dataService: DataService) async { + _ = try? await dataService.labels() + await loadLabelsFromStore(dataService: dataService) + } + + func loadLabelsFromStore(dataService: DataService) async { + let fetchRequest: NSFetchRequest = LinkedItemLabel.fetchRequest() + + let fetchedLabels = await dataService.viewContext.perform { + try? fetchRequest.execute() + } + + labels = fetchedLabels ?? [] + } + + func cancelSubscription(dataService: DataService, subscription: Subscription) async { + operationMessage = "Unsubscribing..." + operationStatus = .isPerforming + + do { + try await dataService.deleteSubscription(subscriptionName: subscription.name, subscriptionId: subscription.subscriptionID) + var list = subscription.type == .feed ? feeds : newsletters + let index = list.firstIndex { $0.subscriptionID == subscription.subscriptionID } + if let index = index { + list.remove(at: index) + switch subscription.type { + case .feed: + feeds = list + case .newsletter: + newsletters = list + } + } + operationMessage = "Unsubscribed" + operationStatus = .success + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) { + self.showOperationToast = false + } + } catch { + appLogger.debug("failed to remove subscription") + operationMessage = "Failed to unsubscribe" + operationStatus = .failure + } + } + + func updateSubscription(dataService: DataService, subscription: Subscription, folder: String? = nil, fetchContent: Bool? = nil) async { + operationMessage = "Updating subscription..." + operationStatus = .isPerforming + do { + try await dataService.updateSubscription(subscription.subscriptionID, folder: folder, fetchContent: fetchContent) + operationMessage = "Subscription updated" + operationStatus = .success + } catch { + operationMessage = "Failed to update subscription" + operationStatus = .failure + } + } + + func setLabelsRule(dataService: DataService, existingRule: Rule?, ruleName: String, filter: String, labelIDs: [String]) async { + Task { + operationMessage = "Creating label rule..." + operationStatus = .isPerforming + do { + // Make sure the labels have been created + await loadLabels(dataService: dataService) + let existingLabelIDs = labels?.map(\.unwrappedID) ?? [] + if labelIDs.first(where: { !existingLabelIDs.contains($0) }) != nil { + throw BasicError.message(messageText: "Label not created") + } + + _ = try await dataService.createOrUpdateAddLabelsRule( + existingID: existingRule?.id, + name: ruleName, + filter: filter, + labelIDs: labelIDs + ) + if let newRules = try? await dataService.rules() { + if !newRules.contains(where: { $0.name == ruleName }) { + throw BasicError.message(messageText: "Rule not created") + } + rules = newRules + } + operationMessage = "Rule created" + operationStatus = .success + } catch { + operationMessage = "Failed to create label rule" + operationStatus = .failure + } + } + } +} + +struct SubscriptionsView: View { + @EnvironmentObject var dataService: DataService + @Environment(\.dismiss) private var dismiss + + @StateObject var viewModel = SubscriptionsViewModel() + @State private var deleteConfirmationShown = false + @State private var showDeleteCompleted = false + + @State private var showAddFeedView = false + + var body: some View { + Group { + WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showOperationToast) { + OperationToast(operationMessage: $viewModel.operationMessage, showOperationToast: $viewModel.showOperationToast, operationStatus: $viewModel.operationStatus) + } label: { + EmptyView() + } + if viewModel.feeds.isEmpty, viewModel.newsletters.isEmpty, viewModel.isLoading { + ProgressView() + } else if viewModel.hasNetworkError { + VStack { + Text(LocalText.subscriptionsErrorRetrieving).multilineTextAlignment(.center) + Button( + action: { Task { await viewModel.loadSubscriptions(dataService: dataService) } }, + label: { Text(LocalText.genericRetry) } + ) + .buttonStyle(RoundedRectButtonStyle()) + } + } else if viewModel.feeds.isEmpty, viewModel.newsletters.isEmpty { + VStack(alignment: .center) { + Spacer() + Text(LocalText.subscriptionsNone) + Spacer() + } + } else { + #if os(iOS) + Form { + innerBody + } + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in + dismiss() + } + .sheet(isPresented: $showAddFeedView) { + let handler = ToastOperationHandler(performOperation: { sendable in + self.viewModel.showOperationToast = true + + Task { + _ = await sendable + viewModel.isLoading = true + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) { + Task { + await self.viewModel.loadSubscriptions(dataService: dataService) + self.viewModel.showOperationToast = false + } + } + } + }, update: { state, text in + viewModel.operationStatus = state + viewModel.operationMessage = text + }) + + NavigationView { + LibraryAddFeedView(dismiss: { + showAddFeedView = false + }, toastOperationHandler: handler) + .navigationViewStyle(.stack) + } + .navigationViewStyle(.stack) + } + .task { + await viewModel.loadSubscriptions(dataService: dataService) + } + .refreshable { + Task { + await viewModel.loadSubscriptions(dataService: dataService) + } + } + .onDisappear { + viewModel.showOperationToast = false + } + .navigationTitle("Subscriptions") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } + + private var innerBody: some View { + Group { + Section("Feeds") { + if viewModel.feeds.count <= 0, !viewModel.isLoading { + VStack(alignment: .center, spacing: 20) { + Text("You don't have any Feed items.") + .font(Font.system(size: 18, weight: .bold)) + + Text("Add an RSS/Atom feed") + .foregroundColor(Color.blue) + .onTapGesture { + showAddFeedView = true + } + } + .frame(minHeight: 80) + .frame(maxWidth: .infinity) + .padding() + } else { + ForEach(viewModel.feeds, id: \.subscriptionID) { subscription in + PresentationLink(transition: UIDevice.isIPad ? .popover : .sheet(detents: [.medium])) { + SubscriptionSettingsView( + subscription: subscription, + viewModel: viewModel, + dataService: dataService, + prefetchContent: subscription.fetchContent, + folderSelection: subscription.folder, + unsubscribe: { _ in + viewModel.operationStatus = .isPerforming + viewModel.showOperationToast = true + Task { + await viewModel.cancelSubscription(dataService: dataService, subscription: subscription) + } + } + ) + } label: { + SubscriptionCell(subscription: subscription) + } + } + Button(action: { showAddFeedView = true }, label: { + Label(title: { + Text("Add a feed") + }, icon: { + Image.addLink + }) + }) + } + } + + if viewModel.newsletters.count > 0, !viewModel.isLoading { + Section("Newsletters") { + ForEach(viewModel.newsletters, id: \.subscriptionID) { subscription in + PresentationLink(transition: UIDevice.isIPad ? .popover : .sheet(detents: [.medium])) { + SubscriptionSettingsView( + subscription: subscription, + viewModel: viewModel, + dataService: dataService, + prefetchContent: subscription.fetchContent, + folderSelection: subscription.folder, + unsubscribe: { _ in + viewModel.operationStatus = .isPerforming + viewModel.showOperationToast = true + Task { + await viewModel.cancelSubscription(dataService: dataService, subscription: subscription) + } + } + ) + } label: { + SubscriptionCell(subscription: subscription) + } + } + } + } + } + .navigationTitle(LocalText.subscriptionsGeneric) + } +} + +struct SubscriptionRow: View { + let subscription: Subscription + let useImageSpacer: Bool + + @ViewBuilder let trailingButton: Content + + var body: some View { + HStack { + Group { + if let icon = subscription.icon, let imageURL = URL(string: icon) { + AsyncImage(url: imageURL) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 40, height: 40) + .cornerRadius(6) + } else if phase.error != nil { + Color.clear.frame(width: 40, height: 40, alignment: .top) + } else { + Color.clear + .frame(width: 40, height: 40) + .cornerRadius(2) + } + } + } else if useImageSpacer { + Color.clear + .frame(width: 40, height: 40) + .cornerRadius(2) + } + }.padding(.trailing, 10) + + VStack(alignment: .leading, spacing: 6) { + Text(subscription.name) + .font(.appCallout) + .lineSpacing(1.25) + .foregroundColor(.appGrayTextContrast) + .fixedSize(horizontal: false, vertical: true) + + if let updatedDate = subscription.updatedAt { + Text("Last received: \(updatedDate.formatted())") + .font(.appCaption) + .foregroundColor(.appGrayText) + .fixedSize(horizontal: false, vertical: true) + } + } + .multilineTextAlignment(.leading) + .padding(.vertical, 8) + + Spacer() + + trailingButton + }.frame(minHeight: 50) + } +} + +struct SubscriptionCell: View { + let subscription: Subscription + + var body: some View { + SubscriptionRow(subscription: subscription, useImageSpacer: true, trailingButton: { + Image(systemName: "ellipsis") + }) + } +} + +@MainActor +struct SubscriptionSettingsView: View { + let subscription: Subscription + let viewModel: SubscriptionsViewModel + let dataService: DataService + + @State var prefetchContent = false + @State var deleteConfirmationShown = false + @State var showDeleteCompleted = false + @State var folderSelection: String = "" + @State var showLabelsSelector = false + + @State var isLoadingRule = false + + let unsubscribe: (_: Subscription) -> Void + + @Environment(\.dismiss) private var dismiss + + var existingRule: Rule? { + viewModel.rules?.first { $0.name == ruleName } + } + + var ruleName: String { + if let url = subscription.url, subscription.type == .newsletter { + return "system.autoLabel.(\(url))" + } + return "system.autoLabel.(\(subscription.name))" + } + + var ruleFilter: String { + if let url = subscription.url, subscription.type == .newsletter { + return "rss:\"\(url)\"" + } + return "subscription:\"\(subscription.name)\"" + } + + var ruleLabels: [LinkedItemLabel]? { + if let labelIDs = existingRule?.actions.flatMap(\.params) { + return Array(labelIDs.compactMap { labelID in + viewModel.labels?.first(where: { $0.unwrappedID == labelID }) + }) + } + return nil + } + + var folderRow: some View { + HStack { + Picker("Destination Folder", selection: $folderSelection) { + Text("Inbox").tag("inbox") + Text("Following").tag("following") + } + .pickerStyle(MenuPickerStyle()) + .onChange(of: folderSelection) { newValue in + Task { + viewModel.showOperationToast = true + await viewModel.updateSubscription(dataService: dataService, subscription: subscription, folder: newValue) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) { + viewModel.showOperationToast = false + } + } + } + .onChange(of: prefetchContent) { newValue in + Task { + viewModel.showOperationToast = true + await viewModel.updateSubscription( + dataService: dataService, + subscription: subscription, + fetchContent: newValue + ) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) { + viewModel.showOperationToast = false + } + } + } + } + } + + var labelRuleRow: some View { + HStack { + Text("Add Labels") + Spacer() + if isLoadingRule || viewModel.rules != nil { + Button(action: { showLabelsSelector = true }, label: { + if let ruleLabels = ruleLabels { + let labelNames = ruleLabels.map(\.unwrappedName) + Text("[\(labelNames.joined(separator: ","))]") + } else { + Text("Create Rule") + } + }) + } else { + ProgressView() + } + } + } + + var body: some View { + VStack { + SubscriptionRow(subscription: subscription, useImageSpacer: false, trailingButton: { + Button(action: { + dismiss() + }, label: { + ZStack { + Circle() + .foregroundColor(Color.circleButtonBackground) + .frame(width: 30, height: 30) + + Image(systemName: "xmark") + .resizable(resizingMode: Image.ResizingMode.stretch) + .foregroundColor(Color.circleButtonForeground) + .aspectRatio(contentMode: .fit) + .font(Font.title.weight(.bold)) + .frame(width: 12, height: 12) + } + }) + }) + .padding(.top, 15) + .padding(.horizontal, 15) + + List { +// if subscription.type != .newsletter { +// Toggle(isOn: $prefetchContent, label: { Text("Prefetch Content:") }) +// .onAppear { +// prefetchContent = subscription.fetchContent +// } +// } + folderRow + labelRuleRow + }.listStyle(.insetGrouped) + + Spacer() + Button("Unsubscribe", role: .destructive) { deleteConfirmationShown = true } + .frame(maxWidth: .infinity) + .buttonStyle(RoundedRectButtonStyle(color: Color.red, textColor: Color.white)) + } + .frame(width: UIDevice.isIPad ? 400 : nil, height: UIDevice.isIPad ? 300 : nil) + .alert("Are you sure you want to cancel this subscription?", isPresented: $deleteConfirmationShown) { + Button("Yes", role: .destructive) { + dismiss() + unsubscribe(subscription) + } + Button("No", role: .cancel) { + viewModel.subscriptionNameToCancel = nil + } + } + .sheet(isPresented: $showLabelsSelector) { + ApplyLabelsView(mode: .list(ruleLabels ?? []), onSave: { labels in + Task { + isLoadingRule = true + viewModel.showOperationToast = true + await viewModel.setLabelsRule( + dataService: dataService, + existingRule: existingRule, + ruleName: ruleName, + filter: ruleFilter, + labelIDs: labels.map(\.unwrappedID) + ) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) { + viewModel.showOperationToast = false + isLoadingRule = true + } + } + }) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift index 5469618b3..a6e4e21bf 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechLanguageView.swift @@ -6,6 +6,7 @@ struct TextToSpeechLanguageView: View { @EnvironmentObject var audioController: AudioController + @Environment(\.dismiss) private var dismiss var body: some View { Group { @@ -20,6 +21,9 @@ .listStyle(InsetListStyle()) #endif } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in + dismiss() + } } private var innerBody: some View { diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift index 4fe368349..cd63fba3f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechView.swift @@ -7,6 +7,7 @@ // swiftlint:disable line_length struct TextToSpeechView: View { @EnvironmentObject var audioController: AudioController + @Environment(\.dismiss) private var dismiss var body: some View { Group { @@ -20,6 +21,9 @@ innerBody } }.navigationTitle(LocalText.textToSpeechGeneric) + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in + dismiss() + } } private var innerBody: some View { diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionView.swift index 2f1bb8c6f..46389bfbe 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/TextToSpeechVoiceSelectionView.swift @@ -8,6 +8,7 @@ struct TextToSpeechVoiceSelectionView: View { @EnvironmentObject var audioController: AudioController @EnvironmentObject var dataService: DataService + @Environment(\.dismiss) private var dismiss @StateObject var viewModel = TextToSpeechVoiceSelectionViewModel() @@ -87,6 +88,8 @@ } else if !value { audioController.useUltraRealisticVoices = false } + }.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in + dismiss() } } diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift index 144c374e0..7f2c9c461 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -52,9 +52,13 @@ struct InnerRootView: View { if authenticator.isLoggedIn { PrimaryContentView() } else { - WelcomeView() - .accessibilityElement() - .accessibilityIdentifier("welcomeView") + if authenticator.isLoggingOut { + LogoutView() + } else { + WelcomeView() + .accessibilityElement() + .accessibilityIdentifier("welcomeView") + } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift index a058c71b7..bef3729c5 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift @@ -11,6 +11,7 @@ import Views let isMacApp = true #endif +@MainActor public final class RootViewModel: ObservableObject { let services = Services() diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift index b45b610da..606d8a609 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift @@ -20,7 +20,6 @@ struct WebReader: PlatformViewRepresentable { @Binding var showNavBarActionID: UUID? @Binding var shareActionID: UUID? @Binding var annotation: String - @Binding var showBottomBar: Bool @Binding var showHighlightAnnotationModal: Bool func makeCoordinator() -> WebReaderCoordinator { @@ -91,9 +90,6 @@ struct WebReader: PlatformViewRepresentable { context.coordinator.webViewActionHandler = webViewActionHandler context.coordinator.updateNavBarVisibility = navBarVisibilityUpdater context.coordinator.scrollPercentHandler = scrollPercentHandler - context.coordinator.updateShowBottomBar = { newValue in - self.showBottomBar = newValue - } context.coordinator.articleContentID = articleContent.id loadContent(webView: webView) diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 31846163a..c4ae5923f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -3,6 +3,7 @@ import Models import PopupView import Services import SwiftUI +import Transmission import Utils import Views import WebKit @@ -28,7 +29,6 @@ struct WebReaderContainerView: View { @State var showExpandedAudioPlayer = false @State var shareActionID: UUID? @State var annotation = String() - @State var showBottomBar = false @State private var bottomBarOpacity = 0.0 @State private var errorAlertMessage: String? @State private var showErrorAlertMessage = false @@ -88,7 +88,6 @@ struct WebReaderContainerView: View { private func tapHandler() { withAnimation(.easeIn(duration: 0.08)) { navBarVisible = !navBarVisible - showBottomBar = navBarVisible showNavBarActionID = UUID() } } @@ -112,13 +111,11 @@ struct WebReaderContainerView: View { case "pageTapped": withAnimation { navBarVisible = !navBarVisible - showBottomBar = navBarVisible showNavBarActionID = UUID() } case "dismissNavBars": withAnimation { navBarVisible = false - showBottomBar = false showNavBarActionID = UUID() } default: @@ -160,45 +157,15 @@ struct WebReaderContainerView: View { var textToSpeechButtonImage: some View { if audioController.playbackError || audioController.state == .stopped || audioController.itemAudioProperties?.itemID != self.item.id { - return AnyView(Image.headphones) + return AnyView(Image.audioPlay.frame(width: 48, height: 48)) } - let name = audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle" - return AnyView(Image(systemName: name).font(.appNavbarIcon)) + if audioController.isPlayingItem(itemID: item.unwrappedID) { + return AnyView(Image.audioPause.frame(width: 48, height: 48)) + } + return AnyView(Image.audioPlay.frame(width: 48, height: 48)) } #endif - var bottomButtons: some View { - HStack(alignment: .center) { - Button(action: archive, label: { - item.isArchived ? Image.unarchive : Image.archive - }).frame(width: 48, height: 48) - .padding(.leading, 8) - Divider().opacity(0.8) - - Button(action: delete, label: { - Image.remove - }).frame(width: 48, height: 48) - Divider().opacity(0.8) - - Button(action: editLabels, label: { - Image.label - }).frame(width: 48, height: 48) - Divider().opacity(0.8) - - Button(action: recommend, label: { - Image(systemName: "sparkles") - }).frame(width: 48, height: 48) - - // We don't have a single note function yet -// Divider() -// -// Button(action: addNote, label: { -// Image(systemName: "note") -// }).frame(width: 48, height: 48) - .padding(.trailing, 8) - }.foregroundColor(.appGrayTextContrast) - } - func audioMenuItem() -> some View { Button( action: { @@ -361,7 +328,7 @@ struct WebReaderContainerView: View { .tint(Color(hex: "#2A2A2A")) .frame(height: readerViewNavBarHeight) .frame(maxWidth: .infinity) - .foregroundColor(ThemeManager.currentTheme.isDark ? .white : .black) + .foregroundColor(ThemeManager.currentTheme.toolbarColor) .background(ThemeManager.currentBgColor) .sheet(isPresented: $showLabelsModal) { ApplyLabelsView(mode: .item(item), onSave: { labels in @@ -380,7 +347,7 @@ struct WebReaderContainerView: View { #if os(iOS) .sheet(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) { NotebookView( - itemObjectID: item.objectID, + viewModel: NotebookViewModel(item: item), hasHighlightMutations: $hasPerformedHighlightMutations ) } @@ -401,6 +368,11 @@ struct WebReaderContainerView: View { var body: some View { ZStack { + WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showOperationToast) { + OperationToast(operationMessage: $viewModel.operationMessage, showOperationToast: $viewModel.showOperationToast, operationStatus: $viewModel.operationStatus) + } label: { + EmptyView() + } if let articleContent = viewModel.articleContent { WebReader( item: item, @@ -431,7 +403,6 @@ struct WebReaderContainerView: View { showNavBarActionID: $showNavBarActionID, shareActionID: $shareActionID, annotation: $annotation, - showBottomBar: $showBottomBar, showHighlightAnnotationModal: $showHighlightAnnotationModal ) .background(ThemeManager.currentBgColor) @@ -592,30 +563,26 @@ struct WebReaderContainerView: View { .offset(y: navBarVisible ? 0 : -150) Spacer() - if showBottomBar { - bottomButtons - .frame(height: 48) - .background(Color.webControlButtonBackground) - .cornerRadius(6) - .padding(.bottom, 34) - .shadow(color: .gray.opacity(0.13), radius: 8, x: 0, y: 4) - .opacity(bottomBarOpacity) - .onAppear { - withAnimation(Animation.linear(duration: 0.25)) { self.bottomBarOpacity = 1 } - } - .onDisappear { - self.bottomBarOpacity = 0 - } - } if let audioProperties = audioController.itemAudioProperties { MiniPlayerViewer(itemAudioProperties: audioProperties) .padding(.top, 10) - .padding(.bottom, 40) + .padding(.bottom, navBarVisible ? 10 : 40) .background(Color.themeTabBarColor) .onTapGesture { showExpandedAudioPlayer = true } } + if navBarVisible { + CustomToolBar( + isFollowing: item.folder == "following", + isArchived: item.isArchived, + moveToInboxAction: moveToInbox, + archiveAction: archive, + unarchiveAction: archive, + shareAction: share, + deleteAction: delete + ) + } } #endif @@ -656,8 +623,28 @@ struct WebReaderContainerView: View { } } + func moveToInbox() { + Task { + viewModel.showOperationToast = true + viewModel.operationMessage = "Moving to library..." + viewModel.operationStatus = .isPerforming + do { + try await dataService.moveItem(itemID: item.unwrappedID, folder: "inbox") + viewModel.operationMessage = "Moved to library" + viewModel.operationStatus = .success + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) { + viewModel.showOperationToast = false + } + } catch { + viewModel.operationMessage = "Error moving" + viewModel.operationStatus = .failure + } + } + } + func archive() { - dataService.archiveLink(objectID: item.objectID, archived: !item.isArchived) + let isArchived = item.isArchived + dataService.archiveLink(objectID: item.objectID, archived: !isArchived) #if os(iOS) pop() #endif @@ -687,10 +674,10 @@ struct WebReaderContainerView: View { } func delete() { - removeLibraryItemAction(dataService: dataService, objectID: item.objectID) + pop() #if os(iOS) DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { - pop() + removeLibraryItemAction(dataService: dataService, objectID: item.objectID) } #endif } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift index 98723f486..213a2218c 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift @@ -20,7 +20,6 @@ final class WebReaderCoordinator: NSObject { var previousShowNavBarActionID: UUID? var previousShareActionID: UUID? var updateNavBarVisibility: (Bool) -> Void = { _ in } - var updateShowBottomBar: (Bool) -> Void = { _ in } var articleContentID = UUID() private var yOffsetAtStartOfDrag: Double? private var lastYOffset: Double = 0 @@ -122,10 +121,10 @@ extension WebReaderCoordinator: WKNavigationDelegate { scrollView.contentInset.top = navBarVisible ? readerViewNavBarHeight : 0 } + // if at bottom show the controls if yOffset + scrollView.visibleSize.height > scrollView.contentSize.height - 140 { - updateShowBottomBar(true) - } else { - updateShowBottomBar(false) + navBarVisible = true + scrollView.contentInset.top = navBarVisible ? readerViewNavBarHeight : 0 } let percent = Int(((yOffset + scrollView.visibleSize.height) / scrollView.contentSize.height) * 100) diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift index 2509cb9a6..48e5f7631 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift @@ -54,6 +54,8 @@ public struct WebReaderLoadingContainer: View { PDFWrapperView(pdfURL: pdfURL) } #endif + } else if item.state == "CONTENT_NOT_FETCHED" { + ProgressView() } else { WebReaderContainerView(item: item, pop: { dismiss() }) #if os(iOS) diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift index 8301b90c0..968594f04 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift @@ -16,6 +16,10 @@ struct SafariWebLink: Identifiable { @Published var isDownloadingAudio: Bool = false @Published var audioDownloadTask: Task? + @Published var operationMessage: String? + @Published var showOperationToast: Bool = false + @Published var operationStatus: OperationStatus = .none + @Published var showSnackbar: Bool = false var snackbarOperation: SnackbarOperation? diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index d8adbc203..07612b47e 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -96,8 +96,10 @@ + + diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/PDFItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/PDFItem.swift index 4b5601d9e..3b6fcb7bc 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/PDFItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/PDFItem.swift @@ -1,8 +1,10 @@ import CoreData import Foundation +import Models import Utils public struct PDFItem { + public let item: Models.LibraryItem public let objectID: NSManagedObjectID public let itemID: String public let pdfURL: URL? @@ -21,6 +23,7 @@ public struct PDFItem { guard item.isPDF else { return nil } return PDFItem( + item: item, objectID: item.objectID, itemID: item.unwrappedID, pdfURL: URL(string: item.unwrappedPageURLString), diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift b/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift index 784bf8000..2a8ff19df 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift @@ -1,10 +1,14 @@ import Foundation +import SwiftGraphQL public struct Subscription { public let createdAt: Date? public let description: String? public let subscriptionID: String public let name: String + public let type: SubscriptionType + public let folder: String + public let fetchContent: Bool public let newsletterEmailAddress: String? public let status: SubscriptionStatus public let unsubscribeHttpUrl: String? @@ -18,6 +22,9 @@ public struct Subscription { description: String?, subscriptionID: String, name: String, + type: SubscriptionType, + folder: String, + fetchContent: Bool, newsletterEmailAddress: String?, status: SubscriptionStatus, unsubscribeHttpUrl: String?, @@ -30,6 +37,9 @@ public struct Subscription { self.description = description self.subscriptionID = subscriptionID self.name = name + self.type = type + self.folder = folder + self.fetchContent = fetchContent self.newsletterEmailAddress = newsletterEmailAddress self.status = status self.unsubscribeHttpUrl = unsubscribeHttpUrl @@ -45,3 +55,8 @@ public enum SubscriptionStatus { case deleted case unsubscribed } + +public enum SubscriptionType { + case newsletter + case feed +} diff --git a/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift b/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift index 0ba7c82dc..1414dcd76 100644 --- a/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift +++ b/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift @@ -172,7 +172,9 @@ public extension FeaturedItemFilter { var predicate: NSPredicate { let undeletedPredicate = NSPredicate( - format: "%K != %i", #keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) + format: "%K != %i AND %K != \"DELETED\"", + #keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue), + #keyPath(LibraryItem.state) ) let notInArchivePredicate = NSPredicate( format: "%K == %@", #keyPath(LibraryItem.isArchived), Int(truncating: false) as NSNumber diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/Voices.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/Voices.swift index 56750302f..c6a0da2a1 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/Voices.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/Voices.swift @@ -35,6 +35,7 @@ public enum VoiceCategory: String, CaseIterable { case itIT = "Italian (Italy)" case esES = "Spanish (Spain)" case jaJP = "Japanese (Japan)" + case nlNL = "Dutch (Netherlands)" case ptBR = "Portuguese (Brazil)" case taIN = "Tamil (India)" case taLK = "Tamil (Sri Lanka)" @@ -80,6 +81,7 @@ public enum Voices { VoiceLanguage(key: "it", name: "Italian", defaultVoice: "it-IT-BenignoNeural", categories: [.itIT]), VoiceLanguage(key: "ja", name: "Japanese", defaultVoice: "ja-JP-NanamiNeural", categories: [.jaJP]), VoiceLanguage(key: "es", name: "Spanish", defaultVoice: "es-ES-AlvaroNeural", categories: [.esES]), + VoiceLanguage(key: "nl", name: "Dutch", defaultVoice: "nl-NL-XiaochenNeural", categories: [.nlNL]), VoiceLanguage(key: "pt", name: "Portuguese", defaultVoice: "pt-BR-AntonioNeural", categories: [.ptBR]), VoiceLanguage(key: "ta", name: "Tamil", defaultVoice: "ta-IN-PallaviNeural", categories: [.taIN, .taLK, .taMY, .taSG]) ] @@ -113,7 +115,8 @@ public enum Voices { VoicePair(firstKey: "ta-LK-KumarNeural", secondKey: "ta-LK-SaranyaNeural", firstName: "Kumar", secondName: "Saranya", language: "ta-LK", category: .taLK), VoicePair(firstKey: "ta-MY-KaniNeural", secondKey: "ta-MY-SuryaNeural", firstName: "Kani", secondName: "Surya", language: "ta-MY", category: .taMY), VoicePair(firstKey: "ta-SG-AnbuNeural", secondKey: "ta-SG-VenbaNeural", firstName: "Anbu", secondName: "Venba", language: "ta-SG", category: .taSG), - VoicePair(firstKey: "it-IT-BenignoNeural", secondKey: "it-IT-IsabellaNeural", firstName: "Benigno", secondName: "Isabella", language: "it-IT", category: .itIT) + VoicePair(firstKey: "it-IT-BenignoNeural", secondKey: "it-IT-IsabellaNeural", firstName: "Benigno", secondName: "Isabella", language: "it-IT", category: .itIT), + VoicePair(firstKey: "nl-NL-MaartenNeural", secondKey: "nl-NL-FennaNeural", firstName: "Maarten", secondName: "Fenna", language: "nl-NL", category: .nlNL) ] public static let UltraPairs = [ diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift b/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift index 7ac953c79..c639057d9 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift @@ -1,3 +1,4 @@ +import CoreData import Foundation import GoogleSignIn import Models @@ -18,6 +19,7 @@ public final class Authenticator: ObservableObject { } @Published public internal(set) var isLoggedIn: Bool + @Published public internal(set) var isLoggingOut = false @Published public var showAppleRevokeTokenAlert = false let networker: Networker @@ -37,13 +39,21 @@ public final class Authenticator: ObservableObject { ValetKey.authToken.value() } + public func beginLogout() { + isLoggingOut = true + isLoggedIn = false + } + public func logout(dataService: DataService, isAccountDeletion: Bool = false) { dataService.resetLocalStorage() + clearCreds() Authenticator.unregisterIntercomUser?() - isLoggedIn = false showAppleRevokeTokenAlert = isAccountDeletion EventTracker.reset() + + isLoggedIn = false + isLoggingOut = false } public func clearCreds() { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index dc8e30de7..fb0e9bc69 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -128,6 +128,17 @@ public final class DataService: ObservableObject { } } + func deleteAllEntities(entityName: String, inContext context: NSManagedObjectContext) { + let deleteFetch = NSFetchRequest(entityName: entityName) + let deleteRequest = NSBatchDeleteRequest(fetchRequest: deleteFetch) + do { + try context.execute(deleteRequest) + try context.save() + } catch { + print("Error deleting all \(entityName) items.", error) + } + } + private func clearDownloadedFiles() { let relevantTypes = ["pdf", "mp3", "speechMarks"] let fileMgr = FileManager() @@ -176,6 +187,17 @@ public final class DataService: ObservableObject { } public func resetLocalStorage() { + viewContext.perform { + // We want to specify the order of deleting items to better handle relationships + let entities = ["LibraryItem", "Viewer", "Filter", "Highlight", "NewsletterEmail", + "LinkedItemLabel", "RecentSearchItem", "Recommendation", "RecommendationGroup", "UserProfile"] + entities.forEach { entityName in + self.deleteAllEntities(entityName: entityName, inContext: self.viewContext) + } + } + + UserDefaults.standard.set(nil, forKey: UserDefaultKey.lastSelectedTabItem.rawValue) + lastItemSyncTime = Date(timeIntervalSinceReferenceDate: 0) clearCoreData() diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index d7f6117ab..0fb0c9656 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -6652,9 +6652,140 @@ extension Selection where TypeLock == Never, Type == Never { typealias FeedsSuccess = Selection } +extension Objects { + struct FetchContentError { + let __typename: TypeName = .fetchContentError + let errorCodes: [String: [Enums.FetchContentErrorCode]] + + enum TypeName: String, Codable { + case fetchContentError = "FetchContentError" + } + } +} + +extension Objects.FetchContentError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.FetchContentErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.FetchContentError { + func errorCodes() throws -> [Enums.FetchContentErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias FetchContentError = Selection +} + +extension Objects { + struct FetchContentSuccess { + let __typename: TypeName = .fetchContentSuccess + let success: [String: Bool] + + enum TypeName: String, Codable { + case fetchContentSuccess = "FetchContentSuccess" + } + } +} + +extension Objects.FetchContentSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "success": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + success = map["success"] + } +} + +extension Fields where TypeLock == Objects.FetchContentSuccess { + func success() throws -> Bool { + let field = GraphQLField.leaf( + name: "success", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.success[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Bool.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias FetchContentSuccess = Selection +} + extension Objects { struct Filter { let __typename: TypeName = .filter + let category: [String: String] let createdAt: [String: DateTime] let defaultFilter: [String: Bool] let description: [String: String] @@ -6684,6 +6815,10 @@ extension Objects.Filter: Decodable { let field = GraphQLField.getFieldNameFromAlias(alias) switch field { + case "category": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "createdAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -6734,6 +6869,7 @@ extension Objects.Filter: Decodable { } } + category = map["category"] createdAt = map["createdAt"] defaultFilter = map["defaultFilter"] description = map["description"] @@ -6748,6 +6884,21 @@ extension Objects.Filter: Decodable { } extension Fields where TypeLock == Objects.Filter { + func category() throws -> String? { + let field = GraphQLField.leaf( + name: "category", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.category[field.alias!] + case .mocking: + return nil + } + } + func createdAt() throws -> DateTime { let field = GraphQLField.leaf( name: "createdAt", @@ -6814,7 +6965,7 @@ extension Fields where TypeLock == Objects.Filter { } } - func folder() throws -> String { + func folder() throws -> String? { let field = GraphQLField.leaf( name: "folder", arguments: [] @@ -6823,12 +6974,9 @@ extension Fields where TypeLock == Objects.Filter { switch response { case let .decoding(data): - if let data = data.folder[field.alias!] { - return data - } - throw HttpError.badpayload + return data.folder[field.alias!] case .mocking: - return String.mockValue + return nil } } @@ -9202,6 +9350,7 @@ extension Objects { let `internal`: [String: Bool] let name: [String: String] let position: [String: Int] + let source: [String: String] enum TypeName: String, Codable { case label = "Label" @@ -9249,6 +9398,10 @@ extension Objects.Label: Decodable { if let value = try container.decode(Int?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "source": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } default: throw DecodingError.dataCorrupted( DecodingError.Context( @@ -9266,6 +9419,7 @@ extension Objects.Label: Decodable { self.internal = map["internal"] name = map["name"] position = map["position"] + source = map["source"] } } @@ -9383,6 +9537,21 @@ extension Fields where TypeLock == Objects.Label { return nil } } + + func source() throws -> String? { + let field = GraphQLField.leaf( + name: "source", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.source[field.alias!] + case .mocking: + return nil + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -11073,6 +11242,7 @@ extension Objects { let deleteNewsletterEmail: [String: Unions.DeleteNewsletterEmailResult] let deleteRule: [String: Unions.DeleteRuleResult] let deleteWebhook: [String: Unions.DeleteWebhookResult] + let fetchContent: [String: Unions.FetchContentResult] let generateApiKey: [String: Unions.GenerateApiKeyResult] let googleLogin: [String: Unions.LoginResult] let googleSignup: [String: Unions.GoogleSignupResult] @@ -11111,6 +11281,7 @@ extension Objects { let updateFilter: [String: Unions.UpdateFilterResult] let updateHighlight: [String: Unions.UpdateHighlightResult] let updateLabel: [String: Unions.UpdateLabelResult] + let updateNewsletterEmail: [String: Unions.UpdateNewsletterEmailResult] let updatePage: [String: Unions.UpdatePageResult] let updateSubscription: [String: Unions.UpdateSubscriptionResult] let updateUser: [String: Unions.UpdateUserResult] @@ -11200,6 +11371,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.DeleteWebhookResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "fetchContent": + if let value = try container.decode(Unions.FetchContentResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "generateApiKey": if let value = try container.decode(Unions.GenerateApiKeyResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11352,6 +11527,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.UpdateLabelResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "updateNewsletterEmail": + if let value = try container.decode(Unions.UpdateNewsletterEmailResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "updatePage": if let value = try container.decode(Unions.UpdatePageResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11402,6 +11581,7 @@ extension Objects.Mutation: Decodable { deleteNewsletterEmail = map["deleteNewsletterEmail"] deleteRule = map["deleteRule"] deleteWebhook = map["deleteWebhook"] + fetchContent = map["fetchContent"] generateApiKey = map["generateApiKey"] googleLogin = map["googleLogin"] googleSignup = map["googleSignup"] @@ -11440,6 +11620,7 @@ extension Objects.Mutation: Decodable { updateFilter = map["updateFilter"] updateHighlight = map["updateHighlight"] updateLabel = map["updateLabel"] + updateNewsletterEmail = map["updateNewsletterEmail"] updatePage = map["updatePage"] updateSubscription = map["updateSubscription"] updateUser = map["updateUser"] @@ -11469,10 +11650,10 @@ extension Fields where TypeLock == Objects.Mutation { } } - func bulkAction(action: Enums.BulkActionType, async: OptionalArgument = .absent(), expectedCount: OptionalArgument = .absent(), labelIds: OptionalArgument<[String]> = .absent(), query: String, selection: Selection) throws -> Type { + func bulkAction(action: Enums.BulkActionType, arguments: OptionalArgument = .absent(), async: OptionalArgument = .absent(), expectedCount: OptionalArgument = .absent(), labelIds: OptionalArgument<[String]> = .absent(), query: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "bulkAction", - arguments: [Argument(name: "action", type: "BulkActionType!", value: action), Argument(name: "async", type: "Boolean", value: async), Argument(name: "expectedCount", type: "Int", value: expectedCount), Argument(name: "labelIds", type: "[ID!]", value: labelIds), Argument(name: "query", type: "String!", value: query)], + arguments: [Argument(name: "action", type: "BulkActionType!", value: action), Argument(name: "arguments", type: "JSON", value: arguments), Argument(name: "async", type: "Boolean", value: async), Argument(name: "expectedCount", type: "Int", value: expectedCount), Argument(name: "labelIds", type: "[ID!]", value: labelIds), Argument(name: "query", type: "String!", value: query)], selection: selection.selection ) select(field) @@ -11583,10 +11764,10 @@ extension Fields where TypeLock == Objects.Mutation { } } - func createNewsletterEmail(selection: Selection) throws -> Type { + func createNewsletterEmail(input: OptionalArgument = .absent(), selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "createNewsletterEmail", - arguments: [], + arguments: [Argument(name: "input", type: "CreateNewsletterEmailInput", value: input)], selection: selection.selection ) select(field) @@ -11754,6 +11935,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func fetchContent(id: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "fetchContent", + arguments: [Argument(name: "id", type: "ID!", value: id)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.fetchContent[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func generateApiKey(input: InputObjects.GenerateApiKeyInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "generateApiKey", @@ -12476,6 +12676,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func updateNewsletterEmail(input: InputObjects.UpdateNewsletterEmailInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "updateNewsletterEmail", + arguments: [Argument(name: "input", type: "UpdateNewsletterEmailInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updateNewsletterEmail[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func updatePage(input: InputObjects.UpdatePageInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "updatePage", @@ -12601,7 +12820,10 @@ extension Objects { let address: [String: String] let confirmationCode: [String: String] let createdAt: [String: DateTime] + let description: [String: String] + let folder: [String: String] let id: [String: String] + let name: [String: String] let subscriptionCount: [String: Int] enum TypeName: String, Codable { @@ -12634,10 +12856,22 @@ extension Objects.NewsletterEmail: Decodable { if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "folder": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "id": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "name": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "subscriptionCount": if let value = try container.decode(Int?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -12655,7 +12889,10 @@ extension Objects.NewsletterEmail: Decodable { address = map["address"] confirmationCode = map["confirmationCode"] createdAt = map["createdAt"] + description = map["description"] + folder = map["folder"] id = map["id"] + name = map["name"] subscriptionCount = map["subscriptionCount"] } } @@ -12712,6 +12949,39 @@ extension Fields where TypeLock == Objects.NewsletterEmail { } } + func description() throws -> String? { + let field = GraphQLField.leaf( + name: "description", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.description[field.alias!] + case .mocking: + return nil + } + } + + func folder() throws -> String { + let field = GraphQLField.leaf( + name: "folder", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.folder[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + func id() throws -> String { let field = GraphQLField.leaf( name: "id", @@ -12730,6 +13000,21 @@ extension Fields where TypeLock == Objects.NewsletterEmail { } } + func name() throws -> String? { + let field = GraphQLField.leaf( + name: "name", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.name[field.alias!] + case .mocking: + return nil + } + } + func subscriptionCount() throws -> Int { let field = GraphQLField.leaf( name: "subscriptionCount", @@ -20845,6 +21130,8 @@ extension Objects { let count: [String: Int] let createdAt: [String: DateTime] let description: [String: String] + let fetchContent: [String: Bool] + let folder: [String: String] let icon: [String: String] let id: [String: String] let isPrivate: [String: Bool] @@ -20892,6 +21179,14 @@ extension Objects.Subscription: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "fetchContent": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "folder": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "icon": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -20954,6 +21249,8 @@ extension Objects.Subscription: Decodable { count = map["count"] createdAt = map["createdAt"] description = map["description"] + fetchContent = map["fetchContent"] + folder = map["folder"] icon = map["icon"] id = map["id"] isPrivate = map["isPrivate"] @@ -21036,6 +21333,42 @@ extension Fields where TypeLock == Objects.Subscription { } } + func fetchContent() throws -> Bool { + let field = GraphQLField.leaf( + name: "fetchContent", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.fetchContent[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Bool.mockValue + } + } + + func folder() throws -> String { + let field = GraphQLField.leaf( + name: "folder", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.folder[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + func icon() throws -> String? { let field = GraphQLField.leaf( name: "icon", @@ -22724,6 +23057,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias UpdateLinkShareInfoSuccess = Selection } +extension Objects { + struct UpdateNewsletterEmailError { + let __typename: TypeName = .updateNewsletterEmailError + let errorCodes: [String: [Enums.UpdateNewsletterEmailErrorCode]] + + enum TypeName: String, Codable { + case updateNewsletterEmailError = "UpdateNewsletterEmailError" + } + } +} + +extension Objects.UpdateNewsletterEmailError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.UpdateNewsletterEmailErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.UpdateNewsletterEmailError { + func errorCodes() throws -> [Enums.UpdateNewsletterEmailErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdateNewsletterEmailError = Selection +} + +extension Objects { + struct UpdateNewsletterEmailSuccess { + let __typename: TypeName = .updateNewsletterEmailSuccess + let newsletterEmail: [String: Objects.NewsletterEmail] + + enum TypeName: String, Codable { + case updateNewsletterEmailSuccess = "UpdateNewsletterEmailSuccess" + } + } +} + +extension Objects.UpdateNewsletterEmailSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "newsletterEmail": + if let value = try container.decode(Objects.NewsletterEmail?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + newsletterEmail = map["newsletterEmail"] + } +} + +extension Fields where TypeLock == Objects.UpdateNewsletterEmailSuccess { + func newsletterEmail(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "newsletterEmail", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.newsletterEmail[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdateNewsletterEmailSuccess = Selection +} + extension Objects { struct UpdatePageError { let __typename: TypeName = .updatePageError @@ -27711,6 +28175,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias FeedsResult = Selection } +extension Unions { + struct FetchContentResult { + let __typename: TypeName + let errorCodes: [String: [Enums.FetchContentErrorCode]] + let success: [String: Bool] + + enum TypeName: String, Codable { + case fetchContentError = "FetchContentError" + case fetchContentSuccess = "FetchContentSuccess" + } + } +} + +extension Unions.FetchContentResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.FetchContentErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "success": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + success = map["success"] + } +} + +extension Fields where TypeLock == Unions.FetchContentResult { + func on(fetchContentError: Selection, fetchContentSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "FetchContentError", selection: fetchContentError.selection), GraphQLField.fragment(type: "FetchContentSuccess", selection: fetchContentSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .fetchContentError: + let data = Objects.FetchContentError(errorCodes: data.errorCodes) + return try fetchContentError.decode(data: data) + case .fetchContentSuccess: + let data = Objects.FetchContentSuccess(success: data.success) + return try fetchContentSuccess.decode(data: data) + } + case .mocking: + return fetchContentError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias FetchContentResult = Selection +} + extension Unions { struct FiltersResult { let __typename: TypeName @@ -31897,6 +32435,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias UpdateLinkShareInfoResult = Selection } +extension Unions { + struct UpdateNewsletterEmailResult { + let __typename: TypeName + let errorCodes: [String: [Enums.UpdateNewsletterEmailErrorCode]] + let newsletterEmail: [String: Objects.NewsletterEmail] + + enum TypeName: String, Codable { + case updateNewsletterEmailError = "UpdateNewsletterEmailError" + case updateNewsletterEmailSuccess = "UpdateNewsletterEmailSuccess" + } + } +} + +extension Unions.UpdateNewsletterEmailResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.UpdateNewsletterEmailErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "newsletterEmail": + if let value = try container.decode(Objects.NewsletterEmail?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + newsletterEmail = map["newsletterEmail"] + } +} + +extension Fields where TypeLock == Unions.UpdateNewsletterEmailResult { + func on(updateNewsletterEmailError: Selection, updateNewsletterEmailSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "UpdateNewsletterEmailError", selection: updateNewsletterEmailError.selection), GraphQLField.fragment(type: "UpdateNewsletterEmailSuccess", selection: updateNewsletterEmailSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .updateNewsletterEmailError: + let data = Objects.UpdateNewsletterEmailError(errorCodes: data.errorCodes) + return try updateNewsletterEmailError.decode(data: data) + case .updateNewsletterEmailSuccess: + let data = Objects.UpdateNewsletterEmailSuccess(newsletterEmail: data.newsletterEmail) + return try updateNewsletterEmailSuccess.decode(data: data) + } + case .mocking: + return updateNewsletterEmailError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdateNewsletterEmailResult = Selection +} + extension Unions { struct UpdatePageResult { let __typename: TypeName @@ -32948,6 +33560,8 @@ extension Enums { enum ArticleSavingRequestStatus: String, CaseIterable, Codable { case archived = "ARCHIVED" + case contentNotFetched = "CONTENT_NOT_FETCHED" + case deleted = "DELETED" case failed = "FAILED" @@ -32984,6 +33598,8 @@ extension Enums { case delete = "DELETE" case markAsRead = "MARK_AS_READ" + + case moveToFolder = "MOVE_TO_FOLDER" } } @@ -33259,6 +33875,15 @@ extension Enums { } } +extension Enums { + /// FetchContentErrorCode + enum FetchContentErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// FiltersErrorCode enum FiltersErrorCode: String, CaseIterable, Codable { @@ -33682,15 +34307,6 @@ extension Enums { } } -extension Enums { - /// ScanFeedsType - enum ScanFeedsType: String, CaseIterable, Codable { - case html = "HTML" - - case opml = "OPML" - } -} - extension Enums { /// SearchErrorCode enum SearchErrorCode: String, CaseIterable, Codable { @@ -34018,6 +34634,15 @@ extension Enums { } } +extension Enums { + /// UpdateNewsletterEmailErrorCode + enum UpdateNewsletterEmailErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// UpdatePageErrorCode enum UpdatePageErrorCode: String, CaseIterable, Codable { @@ -34260,6 +34885,12 @@ extension InputObjects { var preparedDocument: OptionalArgument = .absent() + var publishedAt: OptionalArgument = .absent() + + var rssFeedUrl: OptionalArgument = .absent() + + var savedAt: OptionalArgument = .absent() + var skipParsing: OptionalArgument = .absent() var source: OptionalArgument = .absent() @@ -34276,6 +34907,9 @@ extension InputObjects { if folder.hasValue { try container.encode(folder, forKey: .folder) } if labels.hasValue { try container.encode(labels, forKey: .labels) } if preparedDocument.hasValue { try container.encode(preparedDocument, forKey: .preparedDocument) } + if publishedAt.hasValue { try container.encode(publishedAt, forKey: .publishedAt) } + if rssFeedUrl.hasValue { try container.encode(rssFeedUrl, forKey: .rssFeedUrl) } + if savedAt.hasValue { try container.encode(savedAt, forKey: .savedAt) } if skipParsing.hasValue { try container.encode(skipParsing, forKey: .skipParsing) } if source.hasValue { try container.encode(source, forKey: .source) } if state.hasValue { try container.encode(state, forKey: .state) } @@ -34288,6 +34922,9 @@ extension InputObjects { case folder case labels case preparedDocument + case publishedAt + case rssFeedUrl + case savedAt case skipParsing case source case state @@ -34460,6 +35097,29 @@ extension InputObjects { } } +extension InputObjects { + struct CreateNewsletterEmailInput: Encodable, Hashable { + var description: OptionalArgument = .absent() + + var folder: OptionalArgument = .absent() + + var name: OptionalArgument = .absent() + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if description.hasValue { try container.encode(description, forKey: .description) } + if folder.hasValue { try container.encode(folder, forKey: .folder) } + if name.hasValue { try container.encode(name, forKey: .name) } + } + + enum CodingKeys: String, CodingKey { + case description + case folder + case name + } + } +} + extension InputObjects { struct CreateReactionInput: Encodable, Hashable { var code: Enums.ReactionType @@ -35031,6 +35691,8 @@ extension InputObjects { extension InputObjects { struct SaveFilterInput: Encodable, Hashable { + var category: OptionalArgument = .absent() + var description: OptionalArgument = .absent() var filter: String @@ -35043,6 +35705,7 @@ extension InputObjects { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + if category.hasValue { try container.encode(category, forKey: .category) } if description.hasValue { try container.encode(description, forKey: .description) } try container.encode(filter, forKey: .filter) if folder.hasValue { try container.encode(folder, forKey: .folder) } @@ -35051,6 +35714,7 @@ extension InputObjects { } enum CodingKeys: String, CodingKey { + case category case description case filter case folder @@ -35174,20 +35838,16 @@ extension InputObjects { struct ScanFeedsInput: Encodable, Hashable { var opml: OptionalArgument = .absent() - var type: Enums.ScanFeedsType - var url: OptionalArgument = .absent() func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) if opml.hasValue { try container.encode(opml, forKey: .opml) } - try container.encode(type, forKey: .type) if url.hasValue { try container.encode(url, forKey: .url) } } enum CodingKeys: String, CodingKey { case opml - case type case url } } @@ -35324,17 +35984,21 @@ extension InputObjects { var pageId: String + var source: OptionalArgument = .absent() + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) if labelIds.hasValue { try container.encode(labelIds, forKey: .labelIds) } if labels.hasValue { try container.encode(labels, forKey: .labels) } try container.encode(pageId, forKey: .pageId) + if source.hasValue { try container.encode(source, forKey: .source) } } enum CodingKeys: String, CodingKey { case labelIds case labels case pageId + case source } } } @@ -35537,6 +36201,10 @@ extension InputObjects { struct SubscribeInput: Encodable, Hashable { var autoAddToLibrary: OptionalArgument = .absent() + var fetchContent: OptionalArgument = .absent() + + var folder: OptionalArgument = .absent() + var isPrivate: OptionalArgument = .absent() var subscriptionType: OptionalArgument = .absent() @@ -35546,6 +36214,8 @@ extension InputObjects { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) if autoAddToLibrary.hasValue { try container.encode(autoAddToLibrary, forKey: .autoAddToLibrary) } + if fetchContent.hasValue { try container.encode(fetchContent, forKey: .fetchContent) } + if folder.hasValue { try container.encode(folder, forKey: .folder) } if isPrivate.hasValue { try container.encode(isPrivate, forKey: .isPrivate) } if subscriptionType.hasValue { try container.encode(subscriptionType, forKey: .subscriptionType) } try container.encode(url, forKey: .url) @@ -35553,6 +36223,8 @@ extension InputObjects { enum CodingKeys: String, CodingKey { case autoAddToLibrary + case fetchContent + case folder case isPrivate case subscriptionType case url @@ -35577,6 +36249,8 @@ extension InputObjects { extension InputObjects { struct UpdateFilterInput: Encodable, Hashable { + var category: OptionalArgument = .absent() + var description: OptionalArgument = .absent() var filter: OptionalArgument = .absent() @@ -35593,6 +36267,7 @@ extension InputObjects { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + if category.hasValue { try container.encode(category, forKey: .category) } if description.hasValue { try container.encode(description, forKey: .description) } if filter.hasValue { try container.encode(filter, forKey: .filter) } if folder.hasValue { try container.encode(folder, forKey: .folder) } @@ -35603,6 +36278,7 @@ extension InputObjects { } enum CodingKeys: String, CodingKey { + case category case description case filter case folder @@ -35718,6 +36394,33 @@ extension InputObjects { } } +extension InputObjects { + struct UpdateNewsletterEmailInput: Encodable, Hashable { + var description: OptionalArgument = .absent() + + var folder: OptionalArgument = .absent() + + var id: String + + var name: OptionalArgument = .absent() + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if description.hasValue { try container.encode(description, forKey: .description) } + if folder.hasValue { try container.encode(folder, forKey: .folder) } + try container.encode(id, forKey: .id) + if name.hasValue { try container.encode(name, forKey: .name) } + } + + enum CodingKeys: String, CodingKey { + case description + case folder + case id + case name + } + } +} + extension InputObjects { struct UpdatePageInput: Encodable, Hashable { var byline: OptionalArgument = .absent() @@ -35813,6 +36516,10 @@ extension InputObjects { var description: OptionalArgument = .absent() + var fetchContent: OptionalArgument = .absent() + + var folder: OptionalArgument = .absent() + var id: String var isPrivate: OptionalArgument = .absent() @@ -35831,6 +36538,8 @@ extension InputObjects { var container = encoder.container(keyedBy: CodingKeys.self) if autoAddToLibrary.hasValue { try container.encode(autoAddToLibrary, forKey: .autoAddToLibrary) } if description.hasValue { try container.encode(description, forKey: .description) } + if fetchContent.hasValue { try container.encode(fetchContent, forKey: .fetchContent) } + if folder.hasValue { try container.encode(folder, forKey: .folder) } try container.encode(id, forKey: .id) if isPrivate.hasValue { try container.encode(isPrivate, forKey: .isPrivate) } if lastFetchedAt.hasValue { try container.encode(lastFetchedAt, forKey: .lastFetchedAt) } @@ -35843,6 +36552,8 @@ extension InputObjects { enum CodingKeys: String, CodingKey { case autoAddToLibrary case description + case fetchContent + case folder case id case isPrivate case lastFetchedAt diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/ArchiveLink.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/ArchiveLink.swift index a2355cff8..a70c63ef9 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/ArchiveLink.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/ArchiveLink.swift @@ -15,7 +15,7 @@ extension DataService { // Send update to server self.syncLinkArchiveStatus(itemID: linkedItem.unwrappedID, archived: archived) - let message = archived ? "Link archived" : "Link moved to Inbox" + let message = archived ? "Link archived" : "Link unarchived" DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { showInLibrarySnackbar(message) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateNewsletterEmailMutation.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateNewsletterEmailMutation.swift index f65f5400f..cf8036263 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateNewsletterEmailMutation.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateNewsletterEmailMutation.swift @@ -20,6 +20,8 @@ public extension DataService { InternalNewsletterEmail( emailId: try $0.id(), email: try $0.address(), + folder: try $0.folder(), + descriptionNote: try $0.description(), confirmationCode: try $0.confirmationCode() ) })) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateReminder.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateReminder.swift deleted file mode 100644 index 8b1378917..000000000 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateReminder.swift +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/DeleteSubscription.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/DeleteSubscription.swift index e35a8bd69..497809b7e 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/DeleteSubscription.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/DeleteSubscription.swift @@ -4,7 +4,7 @@ import Models import SwiftGraphQL public extension DataService { - func deleteSubscription(subscriptionName: String) async throws { + func deleteSubscription(subscriptionName: String, subscriptionId: String) async throws { enum MutationResult { case success(id: String) case error(errorMessage: String) @@ -20,7 +20,7 @@ public extension DataService { } let mutation = Selection.Mutation { - try $0.unsubscribe(name: subscriptionName, selection: selection) + try $0.unsubscribe(name: subscriptionName, subscriptionId: OptionalArgument(subscriptionId), selection: selection) } let path = appEnvironment.graphqlPath diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/FetchContent.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/FetchContent.swift new file mode 100644 index 000000000..9291304a0 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/FetchContent.swift @@ -0,0 +1,47 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func fetchContent(itemID: String) async throws { + enum MutationResult { + case result(success: Bool) + case error(errorMessage: String) + } + + let selection = Selection { + try $0.on( + fetchContentError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") }, + fetchContentSuccess: .init { .result(success: try $0.success()) } + ) + } + + let mutation = Selection.Mutation { + try $0.fetchContent(id: itemID, selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + + switch payload.data { + case let .result(success: success): + if success { + continuation.resume() + } else { + continuation.resume(throwing: BasicError.message(messageText: "Operation failed")) + } + case let .error(errorMessage: errorMessage): + continuation.resume(throwing: BasicError.message(messageText: errorMessage)) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift new file mode 100644 index 000000000..48dfc16f5 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift @@ -0,0 +1,92 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public struct Rule { + public let id: String + public let name: String + public let actions: [RuleAction] +} + +public enum RuleActionType { + case addLabel + case archive + case markAsRead + case sendNotification + + static func from(_ other: Enums.RuleActionType) -> RuleActionType { + switch other { + case Enums.RuleActionType.addLabel: + return .addLabel + case Enums.RuleActionType.archive: + return .archive + case Enums.RuleActionType.markAsRead: + return .markAsRead + case Enums.RuleActionType.sendNotification: + return .sendNotification + } + } +} + +public struct RuleAction { + public let params: [String] + public let type: RuleActionType +} + +let actionSelection = Selection.RuleAction { + RuleAction(params: try $0.params(), type: RuleActionType.from(try $0.type())) +} + +let ruleSelection = Selection.Rule { + Rule(id: try $0.id(), name: try $0.name(), actions: try $0.actions(selection: actionSelection.list)) +} + +public extension DataService { + func createOrUpdateAddLabelsRule(existingID: String?, name: String, filter: String, labelIDs: [String]) async throws -> Rule { + enum MutationResult { + case result(rule: Rule) + case error(errorMessage: String) + } + + let selection = Selection { + try $0.on( + setRuleError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") }, + setRuleSuccess: .init { .result(rule: try $0.rule(selection: ruleSelection)) } + ) + } + + let mutation = Selection.Mutation { + try $0.setRule( + input: InputObjects.SetRuleInput( + actions: [InputObjects.RuleActionInput(params: labelIDs, type: .addLabel)], + enabled: true, + eventTypes: [.pageCreated], + filter: filter, + id: OptionalArgument(existingID), + name: name + ), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + + switch payload.data { + case let .result(rule: rule): + continuation.resume(returning: rule) + case let .error(errorMessage: errorMessage): + continuation.resume(throwing: BasicError.message(messageText: errorMessage)) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SubscribeToFeed.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SubscribeToFeed.swift new file mode 100644 index 000000000..4b8111306 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SubscribeToFeed.swift @@ -0,0 +1,56 @@ +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func subscribeToFeed(feedURL: String, folder: String? = nil, fetchContent: Bool? = nil) async throws -> Bool { + enum MutationResult { + case success(subscriptionIds: [String]) + case error(errorMessage: String) + } + + let subscriptionIdSelection = Selection.Subscription { + try $0.id() + } + + let selection = Selection { + try $0.on( + subscribeError: .init { + .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "unknown error") + }, + subscribeSuccess: .init { + .success(subscriptionIds: try $0.subscriptions(selection: subscriptionIdSelection.list)) + } + ) + } + + let mutation = Selection.Mutation { + try $0.subscribe(input: InputObjects.SubscribeInput( + fetchContent: OptionalArgument(fetchContent), + folder: OptionalArgument(folder), + url: feedURL + ), selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { mutationResult in + guard let payload = try? mutationResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "failed to add feed \(feedURL)")) + return + } + + switch payload.data { + case .success: + print("subscribed to feed:", feedURL) + continuation.resume(returning: true) + case .error: + print("failed to subscribe to feed:", feedURL) + continuation.resume(throwing: BasicError.message(messageText: "failed to add feed \(feedURL)")) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateNewsletterEmailMutation.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateNewsletterEmailMutation.swift new file mode 100644 index 000000000..506377459 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateNewsletterEmailMutation.swift @@ -0,0 +1,53 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func updateNewsletterEmail( + emailID: String, folder: String? = nil, description: String? = nil + ) async throws -> InternalNewsletterEmail { + enum MutationResult { + case result(email: InternalNewsletterEmail) + case error(errorMessage: String) + } + + let selection = Selection { + try $0.on( + updateNewsletterEmailError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") }, + updateNewsletterEmailSuccess: .init { .result(email: try $0.newsletterEmail(selection: newsletterEmailSelection)) } + ) + } + + let mutation = Selection.Mutation { + try $0.updateNewsletterEmail( + input: InputObjects.UpdateNewsletterEmailInput( + description: OptionalArgument(description), + folder: OptionalArgument(folder), + + id: emailID + ), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + + switch payload.data { + case let .result(email: email): + continuation.resume(returning: email) + case let .error(errorMessage: errorMessage): + continuation.resume(throwing: BasicError.message(messageText: errorMessage)) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateSubscription.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateSubscription.swift new file mode 100644 index 000000000..1c16479b1 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateSubscription.swift @@ -0,0 +1,50 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func updateSubscription(_ subscriptionID: String, folder: String? = nil, fetchContent: Bool? = nil) async throws { + enum MutationResult { + case success(subscriptionID: String) + case error(errorMessage: String) + } + + let selection = Selection { + try $0.on( + updateSubscriptionError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") }, + updateSubscriptionSuccess: .init { .success(subscriptionID: try $0.subscription(selection: subscriptionSelection).subscriptionID) } + ) + } + + let mutation = Selection.Mutation { + try $0.updateSubscription( + input: InputObjects.UpdateSubscriptionInput( + fetchContent: OptionalArgument(fetchContent), + folder: OptionalArgument(folder), + id: subscriptionID + ), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + + switch payload.data { + case .success: + continuation.resume() + case let .error(errorMessage: errorMessage): + continuation.resume(throwing: BasicError.message(messageText: errorMessage)) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/NewsletterEmailsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/NewsletterEmailsQuery.swift index ff8e05bf2..ee0d7e6ec 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/NewsletterEmailsQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/NewsletterEmailsQuery.swift @@ -3,6 +3,16 @@ import Foundation import Models import SwiftGraphQL +let newsletterEmailSelection = Selection.NewsletterEmail { + InternalNewsletterEmail( + emailId: try $0.id(), + email: try $0.address(), + folder: try $0.folder(), + descriptionNote: try $0.description(), + confirmationCode: try $0.confirmationCode() + ) +} + public extension DataService { func newsletterEmails() async throws -> [NSManagedObjectID] { enum QueryResult { @@ -10,14 +20,6 @@ public extension DataService { case error(error: String) } - let newsletterEmailSelection = Selection.NewsletterEmail { - InternalNewsletterEmail( - emailId: try $0.id(), - email: try $0.address(), - confirmationCode: try $0.confirmationCode() - ) - } - let selection = Selection { try $0.on( newsletterEmailsError: .init { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/RulesQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/RulesQuery.swift new file mode 100644 index 000000000..a227b28bd --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/RulesQuery.swift @@ -0,0 +1,46 @@ +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func rules() async throws -> [Rule] { + enum QueryResult { + case success(result: [Rule]) + case error(error: String) + } + + let selection = Selection { + try $0.on( + rulesError: .init { + QueryResult.error(error: try $0.errorCodes().description) + }, + rulesSuccess: .init { + QueryResult.success(result: try $0.rules(selection: ruleSelection.list)) + } + ) + } + + let query = Selection.Query { + try $0.rules(selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(query, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network request failed")) + return + } + + switch payload.data { + case let .success(result: result): + continuation.resume(returning: result) + case .error: + continuation.resume(throwing: BasicError.message(messageText: "Rules fetch error")) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ScanFeed.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ScanFeed.swift new file mode 100644 index 000000000..52747241c --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ScanFeed.swift @@ -0,0 +1,69 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public struct Feed { + public var title: String + public var url: String + public var description: String? + public var image: String? + public var author: String? +} + +let feedSelection = Selection.Feed { + Feed( + title: try $0.title(), + url: try $0.url(), + description: try $0.description(), + image: try $0.image(), + author: try $0.author() + ) +} + +public extension DataService { + func scanFeed(feedURL: URL) async throws -> [Feed] { + enum QueryResult { + case success(result: [Feed]) + case error(error: String) + } + + let selection = Selection { + try $0.on( + scanFeedsError: .init { + QueryResult.error(error: try $0.errorCodes().description) + }, + scanFeedsSuccess: .init { + QueryResult.success(result: try $0.feeds(selection: feedSelection.list)) + } + ) + } + + let query = Selection.Query { + try $0.scanFeeds( + input: InputObjects.ScanFeedsInput(url: OptionalArgument(feedURL.absoluteString)), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(query, to: path, headers: headers) { queryResult in + print("QUERY RESULT: ", queryResult) + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network request failed")) + return + } + + switch payload.data { + case let .success(result: feeds): + continuation.resume(returning: feeds) + case .error: + continuation.resume(throwing: BasicError.message(messageText: "scanFeed fetch error")) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/SubscriptionsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/SubscriptionsQuery.swift index 17ee2d565..0c30cda28 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/SubscriptionsQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/SubscriptionsQuery.swift @@ -9,29 +9,13 @@ public extension DataService { case error(error: String) } - let subsciptionSelection = Selection.Subscription { - Subscription( - createdAt: try $0.createdAt().value, - description: try $0.description(), - subscriptionID: try $0.id(), - name: try $0.name(), - newsletterEmailAddress: try $0.newsletterEmail(), - status: try SubscriptionStatus.make(from: $0.status()), - unsubscribeHttpUrl: try $0.unsubscribeHttpUrl(), - unsubscribeMailTo: try $0.unsubscribeMailTo(), - updatedAt: try $0.updatedAt()?.value ?? Date(), - url: try $0.url(), - icon: try $0.icon() - ) - } - let selection = Selection { try $0.on( subscriptionsError: .init { QueryResult.error(error: try $0.errorCodes().description) }, subscriptionsSuccess: .init { - QueryResult.success(result: try $0.subscriptions(selection: subsciptionSelection.list)) + QueryResult.success(result: try $0.subscriptions(selection: subscriptionSelection.list)) } ) } @@ -54,22 +38,10 @@ public extension DataService { case let .success(result: result): continuation.resume(returning: result) case .error: + print("QUERY RESULT: ", queryResult) continuation.resume(throwing: BasicError.message(messageText: "Subscriptions fetch error")) } } } } } - -extension SubscriptionStatus { - static func make(from status: Enums.SubscriptionStatus) -> SubscriptionStatus { - switch status { - case .active: - return .active - case .deleted: - return .deleted - case .unsubscribed: - return .unsubscribed - } - } -} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/FilterSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FilterSelection.swift index 4017d7276..cee6186dd 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Selections/FilterSelection.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FilterSelection.swift @@ -6,7 +6,7 @@ let filterSelection = Selection.Filter { InternalFilter( id: try $0.id(), name: try $0.name(), - folder: try $0.folder(), + folder: try $0.folder() ?? "inbox", filter: try $0.filter(), visible: try $0.visible() ?? true, position: try $0.position(), diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/SubsciptionSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/SubsciptionSelection.swift new file mode 100644 index 000000000..f8e883e97 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/SubsciptionSelection.swift @@ -0,0 +1,47 @@ + +import Foundation +import Models +import SwiftGraphQL + +let subscriptionSelection = Selection.Subscription { + Subscription( + createdAt: try $0.createdAt().value, + description: try $0.description(), + subscriptionID: try $0.id(), + name: try $0.name(), + type: try SubscriptionType.from($0.type()), + folder: try $0.folder(), + fetchContent: try $0.fetchContent(), + newsletterEmailAddress: try $0.newsletterEmail(), + status: try SubscriptionStatus.make(from: $0.status()), + unsubscribeHttpUrl: try $0.unsubscribeHttpUrl(), + unsubscribeMailTo: try $0.unsubscribeMailTo(), + updatedAt: try $0.updatedAt()?.value ?? Date(), + url: try $0.url(), + icon: try $0.icon() + ) +} + +extension SubscriptionStatus { + static func make(from status: Enums.SubscriptionStatus) -> SubscriptionStatus { + switch status { + case .active: + return .active + case .deleted: + return .deleted + case .unsubscribed: + return .unsubscribed + } + } +} + +extension SubscriptionType { + static func from(_ other: Enums.SubscriptionType) -> SubscriptionType { + switch other { + case .rss: + return .feed + case .newsletter: + return .newsletter + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift index f2b3431c4..a87be224c 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift @@ -39,6 +39,18 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable { ) } + public static var UnreadFilter: InternalFilter { + InternalFilter( + id: "unread", + name: "Unread", + folder: "inbox", + filter: "in:inbox is:unread", + visible: true, + position: -1, + defaultFilter: true + ) + } + public static var DefaultInboxFilters: [InternalFilter] { [ InternalFilter( @@ -120,11 +132,29 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable { [ InternalFilter( id: "following", - name: "RSS", + name: "Following", + folder: "following", + filter: "in:following", + visible: true, + position: 1, + defaultFilter: true + ), + InternalFilter( + id: "rss", + name: "Feeds", folder: "following", filter: "in:following label:RSS", visible: true, - position: 1, + position: 2, + defaultFilter: true + ), + InternalFilter( + id: "newsletters", + name: "Newsletters", + folder: "following", + filter: "in:following label:Newsletter", + visible: true, + position: 3, defaultFilter: true ) ] @@ -143,10 +173,9 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable { } public var predicate: NSPredicate? { - if !defaultFilter { - return nil - } - + let folderPredicate = NSPredicate( + format: "%K == %@", #keyPath(Models.LibraryItem.folder), folder + ) let undeletedPredicate = NSPredicate( format: "%K != %i AND %K != \"DELETED\"", #keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue), @@ -161,10 +190,16 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable { let feedLabelPredicate = NSPredicate( format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0" ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, undeletedPredicate, feedLabelPredicate]) + return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, notInArchivePredicate, undeletedPredicate, feedLabelPredicate]) + case "Following": + return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, notInArchivePredicate, undeletedPredicate]) case "Inbox": - // non-archived items - return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate]) + return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate]) + case "Unread": + let isUnread = NSPredicate( + format: "readAt == nil" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate, isUnread]) case "Non-Feed Items": // non-archived or deleted items without the Newsletter label let nonNewsletterLabelPredicate = NSPredicate( @@ -174,7 +209,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable { format: "NOT SUBQUERY(labels, $label, $label.name == \"RSS\") .@count > 0" ) return NSCompoundPredicate(andPredicateWithSubpredicates: [ - undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate + folderPredicate, undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate ]) case "Downloaded": // include pdf only @@ -188,50 +223,51 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable { format: "localPDF.length > 0" ) let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate, localPDFURL]) - return NSCompoundPredicate(orPredicateWithSubpredicates: [hasHTMLContent, downloadedPDF]) + return NSCompoundPredicate(orPredicateWithSubpredicates: [folderPredicate, hasHTMLContent, downloadedPDF]) case "Newsletters": // non-archived or deleted items with the Newsletter label let newsletterLabelPredicate = NSPredicate( format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0" ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, newsletterLabelPredicate]) + return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate, newsletterLabelPredicate]) case "Feeds": let feedLabelPredicate = NSPredicate( format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0" ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, feedLabelPredicate]) + return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate, feedLabelPredicate]) case "Recommended": // non-archived or deleted items with the Newsletter label let recommendedPredicate = NSPredicate( format: "recommendations.@count > 0" ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, recommendedPredicate]) + return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate, recommendedPredicate]) case "All": // include everything undeleted - return undeletedPredicate + return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate]) case "Archived": let inArchivePredicate = NSPredicate( format: "%K == %@", #keyPath(Models.LibraryItem.isArchived), Int(truncating: true) as NSNumber ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate]) + return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, inArchivePredicate]) case "Deleted": let deletedPredicate = NSPredicate( format: "%K == %i OR %K == \"DELETED\"", #keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue), #keyPath(Models.LibraryItem.state) ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate]) + return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, deletedPredicate]) case "Files": // include pdf only let isPDFPredicate = NSPredicate( format: "%K == %@", #keyPath(Models.LibraryItem.contentReader), "PDF" ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate]) + return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, isPDFPredicate]) case "Highlights": let hasHighlightsPredicate = NSPredicate( format: "highlights.@count > 0" ) return NSCompoundPredicate(andPredicateWithSubpredicates: [ + folderPredicate, undeletedPredicate, hasHighlightsPredicate ]) @@ -310,6 +346,20 @@ public extension Filter { return filter } + + static func lookup(byFilter filter: String, inContext context: NSManagedObjectContext) -> Filter? { + let fetchRequest: NSFetchRequest = Filter.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "filter == %@", filter + ) + + var filter: Filter? + context.performAndWait { + filter = (try? context.fetch(fetchRequest))?.first + } + + return filter + } } extension Sequence where Element == InternalFilter { diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalNewsletterEmail.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalNewsletterEmail.swift index 7620bec8e..919dcbd77 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalNewsletterEmail.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalNewsletterEmail.swift @@ -2,9 +2,11 @@ import CoreData import Foundation import Models -struct InternalNewsletterEmail { +public struct InternalNewsletterEmail { let emailId: String let email: String + let folder: String + let descriptionNote: String? let confirmationCode: String? func persist(context: NSManagedObjectContext) -> NSManagedObjectID? { @@ -32,7 +34,10 @@ struct InternalNewsletterEmail { newsletterEmail.emailId = emailId newsletterEmail.email = email + newsletterEmail.folder = folder + newsletterEmail.descriptionNote = descriptionNote newsletterEmail.confirmationCode = confirmationCode + return newsletterEmail } } diff --git a/apple/OmnivoreKit/Sources/Services/NSNotification+Operation.swift b/apple/OmnivoreKit/Sources/Services/NSNotification+Operation.swift index 7abfd17e1..8848ab1b8 100644 --- a/apple/OmnivoreKit/Sources/Services/NSNotification+Operation.swift +++ b/apple/OmnivoreKit/Sources/Services/NSNotification+Operation.swift @@ -13,6 +13,11 @@ public extension NSNotification { static let DisplayProfile = Notification.Name("DisplayProfile") static let Logout = Notification.Name("Logout") static let ScrollToTop = Notification.Name("ScrollToTop") + static let PerformSync = Notification.Name("PerformSync") + + static var performSyncPublisher: NotificationCenter.Publisher { + NotificationCenter.default.publisher(for: PerformSync) + } static var pushFeedItemPublisher: NotificationCenter.Publisher { NotificationCenter.default.publisher(for: PushJSONArticle) diff --git a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift index 25959090d..6b7db00d6 100644 --- a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift +++ b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift @@ -33,6 +33,7 @@ public enum UserDefaultKey: String { case deviceTokenID case userWordsPerMinute case hideFeatureSection + case hideSystemLabels case justifyText case prefersHideStatusBarInReader case visibleShareExtensionTab diff --git a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift index 79a97b8df..cd768e3a4 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift +++ b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift @@ -48,6 +48,9 @@ public extension Color { static var thFeatureSeparator: Color { Color("featureSeparator", bundle: .module) } + static var thFallbackImageForeground: Color { Color("thFallbackImageForeground", bundle: .module) } + static var thFallbackImageBackground: Color { Color("thFallbackImageBackground", bundle: .module) } + static var circleButtonBackground: Color { Color("_circleButtonBackground", bundle: .module) } static var circleButtonForeground: Color { Color("_circleButtonForeground", bundle: .module) } static var extensionBackground: Color { Color("_extensionBackground", bundle: .module) } diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/thFallbackImageBackground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/thFallbackImageBackground.colorset/Contents.json new file mode 100644 index 000000000..bb8146518 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/thFallbackImageBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xED", + "green" : "0xED", + "red" : "0xED" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3C", + "green" : "0x3C", + "red" : "0x3C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/thFallbackImageForeground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/thFallbackImageForeground.colorset/Contents.json new file mode 100644 index 000000000..69f05fa6f --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/thFallbackImageForeground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2A", + "green" : "0x2A", + "red" : "0x2A" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/thSnackbarBackground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/thSnackbarBackground.colorset/Contents.json new file mode 100644 index 000000000..982111ca3 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/thSnackbarBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2A", + "green" : "0x2A", + "red" : "0x2A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index c45a7b31e..782c7d735 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -12,12 +12,12 @@ public enum GridCardAction { public struct GridCard: View { @Binding var isContextMenuOpen: Bool - let item: Models.LibraryItem + let item: LibraryItemData let actionHandler: (GridCardAction) -> Void // let tapAction: () -> Void public init( - item: Models.LibraryItem, + item: LibraryItemData, isContextMenuOpen: Binding, actionHandler: @escaping (GridCardAction) -> Void ) { @@ -51,7 +51,7 @@ public struct GridCard: View { ) Button( action: { menuActionHandler(.editLabels) }, - label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") } + label: { Label(item.sortedLabels.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") } ) Button( action: { menuActionHandler(.toggleArchiveStatus) }, @@ -77,7 +77,7 @@ public struct GridCard: View { CachedAsyncImage(url: imageURL) { phase in switch phase { case .empty: - Color.systemBackground + Color.clear .frame(maxWidth: .infinity, maxHeight: geo.size.height) case let .success(image): image.resizable() @@ -93,7 +93,7 @@ public struct GridCard: View { // we need to add this currently unused fallback // to handle any new cases that might be added // in the future: - Color.systemBackground + Color.clear .frame(maxWidth: .infinity, maxHeight: geo.size.height) } } @@ -117,16 +117,16 @@ public struct GridCard: View { var fallbackImage: some View { GeometryReader { geo in HStack { - Text(item.unwrappedTitle) + Text(item.title) .font(fallbackFont) .frame(alignment: .center) .multilineTextAlignment(.leading) .lineLimit(2) .padding(10) - .foregroundColor(Color.themeMiddleGray) + .foregroundColor(Color.thFallbackImageForeground) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.thLightWhiteGrey) + .background(Color.thFallbackImageBackground) .frame(width: geo.size.width, height: geo.size.height) } } @@ -168,7 +168,7 @@ public struct GridCard: View { VStack(alignment: .leading, spacing: 4) { HStack { - Text(item.unwrappedTitle) + Text(item.title) .font(.appHeadline) .foregroundColor(.appGrayTextContrast) .lineLimit(1) @@ -183,7 +183,7 @@ public struct GridCard: View { // Link description and image HStack(alignment: .top) { - Text(item.descriptionText ?? item.unwrappedTitle) + Text(item.descriptionText ?? item.title) .font(.appSubheadline) .foregroundColor(.appGrayTextContrast) .lineLimit(3) diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryFeatureCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryFeatureCard.swift index 4f8d6832e..aa5e31f96 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryFeatureCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryFeatureCard.swift @@ -37,7 +37,7 @@ public struct LibraryFeatureCard: View { CachedAsyncImage(url: imageURL) { phase in switch phase { case .empty: - Color.systemBackground + Color.clear .frame(width: 146, height: 90) case let .success(image): image.resizable() @@ -81,10 +81,10 @@ public struct LibraryFeatureCard: View { .multilineTextAlignment(.leading) .lineLimit(2) .padding(10) - .foregroundColor(Color.themeMiddleGray) + .foregroundColor(Color.thFallbackImageForeground) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.thLightWhiteGrey) + .background(Color.thFallbackImageBackground) .frame(width: 146, height: 90) } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift index ee064fc15..0a963f7da 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift @@ -32,11 +32,11 @@ enum FlairLabels: String { } public extension View { - func draggableItem(item: Models.LibraryItem) -> some View { + func draggableItem(item: LibraryItemData) -> some View { #if os(iOS) if #available(iOS 16.0, *), let url = item.deepLink { return AnyView(self.draggable(url) { - Label(item.unwrappedTitle, systemImage: "link") + Label(item.title, systemImage: "link") }) } #endif @@ -44,12 +44,75 @@ public extension View { } } +public struct LibraryItemData { + public var id: String + public let title: String + public let pageURLString: String + public var isArchived: Bool + public let author: String? + public let deepLink: URL? + public let hasLabels: Bool + public let noteText: String? + public let readingProgress: Double + public let wordsCount: Int64 + public let isPDF: Bool + public let highlights: NSSet? + public let sortedLabels: [LinkedItemLabel] + public let imageURL: URL? + public let publisherDisplayName: String? + public let descriptionText: String? + + public init(id: String, title: String, pageURLString: String, isArchived: Bool, author: String?, + deepLink: URL?, hasLabels: Bool, noteText: String?, + readingProgress: Double, wordsCount: Int64, isPDF: Bool, highlights: NSSet?, + sortedLabels: [LinkedItemLabel], imageURL: URL?, publisherDisplayName: String?, descriptionText: String?) + { + self.id = id + self.title = title + self.pageURLString = pageURLString + self.isArchived = isArchived + self.author = author + self.deepLink = deepLink + self.hasLabels = hasLabels + self.noteText = noteText + self.readingProgress = readingProgress + self.wordsCount = wordsCount + self.isPDF = isPDF + self.highlights = highlights + self.sortedLabels = sortedLabels + self.imageURL = imageURL + self.publisherDisplayName = publisherDisplayName + self.descriptionText = descriptionText + } + + public static func make(from item: Models.LibraryItem) -> LibraryItemData { + LibraryItemData( + id: item.unwrappedID, + title: item.unwrappedTitle, + pageURLString: item.unwrappedPageURLString, + isArchived: item.isArchived, + author: item.author, + deepLink: item.deepLink, + hasLabels: item.hasLabels, + noteText: item.noteText, + readingProgress: item.readingProgress, + wordsCount: item.wordsCount, + isPDF: item.isPDF, + highlights: item.highlights, + sortedLabels: item.sortedLabels, + imageURL: item.imageURL, + publisherDisplayName: item.publisherDisplayName, + descriptionText: item.descriptionText + ) + } +} + public struct LibraryItemCard: View { let viewer: Viewer? - @ObservedObject var item: Models.LibraryItem + var item: LibraryItemData @State var noteLineLimit: Int? = 3 - public init(item: Models.LibraryItem, viewer: Viewer?) { + public init(item: LibraryItemData, viewer: Viewer?) { self.item = item self.viewer = viewer } @@ -249,7 +312,7 @@ public struct LibraryItemCard: View { .cornerRadius(5) .padding(.top, 2) } else { - Color.systemBackground + Color.clear .frame(width: 50, height: 75) .cornerRadius(5) .padding(.top, 2) @@ -299,7 +362,7 @@ public struct LibraryItemCard: View { readInfo .dynamicTypeSize(.xSmall ... .medium) - Text(item.unwrappedTitle) + Text(item.title) .font(.body).fontWeight(.semibold) .lineSpacing(1.25) .foregroundColor(.appGrayTextContrast) diff --git a/apple/OmnivoreKit/Sources/Views/FormSheetWrapper.swift b/apple/OmnivoreKit/Sources/Views/FormSheetWrapper.swift index b20503955..090528d57 100644 --- a/apple/OmnivoreKit/Sources/Views/FormSheetWrapper.swift +++ b/apple/OmnivoreKit/Sources/Views/FormSheetWrapper.swift @@ -1,10 +1,3 @@ -// -// FormSheet.swift -// -// -// Created by Jackson Harper on 1/29/22. -// - import SwiftUI #if os(iOS) diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.swift b/apple/OmnivoreKit/Sources/Views/Images/Images.swift index eb8817b63..8dd6c6fe4 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.swift +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.swift @@ -13,11 +13,20 @@ public extension Image { static var tabHighlights: Image { Image("_tab_highlights", bundle: .module).renderingMode(.template) } static var tabProfile: Image { Image("_tab_profile", bundle: .module).renderingMode(.template) } + static var toolbarArchive: Image { Image("toolbar-archive", bundle: .module).renderingMode(.template) } + static var toolbarUnarchive: Image { Image("toolbar-unarchive", bundle: .module).renderingMode(.template) } + + static var toolbarShare: Image { Image("toolbar-share", bundle: .module).renderingMode(.template) } + static var toolbarTrash: Image { Image("toolbar-trash", bundle: .module).renderingMode(.template) } + static var pinRotated: Image { Image("pin-rotated", bundle: .module) } static var chevronRight: Image { Image("chevron-right", bundle: .module) } static var notebook: Image { Image("notebook", bundle: .module) } static var headphones: Image { Image("headphones", bundle: .module) } + + static var audioPlay: Image { Image("header-play", bundle: .module) } + static var audioPause: Image { Image("header-pause", bundle: .module) } static var readerSettings: Image { Image("reader-settings", bundle: .module) } static var utilityMenu: Image { Image("utility-menu", bundle: .module) } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_chart-line-up.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_chart-line-up.imageset/Contents.json deleted file mode 100644 index 7f06548a7..000000000 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_chart-line-up.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "chart-line-up.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "template-rendering-intent" : "template" - } -} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_chart-line-up.imageset/chart-line-up.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_chart-line-up.imageset/chart-line-up.svg deleted file mode 100644 index 483852af2..000000000 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_chart-line-up.imageset/chart-line-up.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-pause.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-pause.imageset/Contents.json new file mode 100644 index 000000000..3e1fa6739 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-pause.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "header-pause.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-pause.imageset/header-pause.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-pause.imageset/header-pause.svg new file mode 100644 index 000000000..b3de00f7e --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-pause.imageset/header-pause.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-play.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-play.imageset/Contents.json new file mode 100644 index 000000000..e0caa008b --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-play.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "PlayCircle.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-play.imageset/PlayCircle.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-play.imageset/PlayCircle.svg new file mode 100644 index 000000000..9e9f29086 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-play.imageset/PlayCircle.svg @@ -0,0 +1,3 @@ + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/Contents.json index 57d38850f..fa6683df2 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/Contents.json +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/Contents.json @@ -1,17 +1,15 @@ { "images" : [ { - "filename" : "label.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "label@2x.png", + "filename" : "header-label.svg", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "label@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/header-label.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/header-label.svg new file mode 100644 index 000000000..54ca883ee --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/header-label.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/label.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/label.png deleted file mode 100644 index caadbf431..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/label.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/label@2x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/label@2x.png deleted file mode 100644 index 11cb17298..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/label@2x.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/label@3x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/label@3x.png deleted file mode 100644 index 3bea0685f..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/label@3x.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/Contents.json index 7c317014a..5b7ff0d2c 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/Contents.json +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/Contents.json @@ -1,17 +1,15 @@ { "images" : [ { - "filename" : "notebooks.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "notebooks@2x.png", + "filename" : "header-notebook.svg", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "notebooks@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/header-notebook.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/header-notebook.svg new file mode 100644 index 000000000..35a023b01 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/header-notebook.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/notebooks.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/notebooks.png deleted file mode 100644 index 99669636d..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/notebooks.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/notebooks@2x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/notebooks@2x.png deleted file mode 100644 index efe587e07..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/notebooks@2x.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/notebooks@3x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/notebooks@3x.png deleted file mode 100644 index f8cc77e31..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/notebook.imageset/notebooks@3x.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/Contents.json index 875c3056b..47f6ec866 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/Contents.json +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/Contents.json @@ -1,17 +1,15 @@ { "images" : [ { - "filename" : "reader-settings.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "reader-settings@2x.png", + "filename" : "header-reader-settings.svg", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "reader-settings@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/header-reader-settings.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/header-reader-settings.svg new file mode 100644 index 000000000..53467d960 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/header-reader-settings.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/reader-settings.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/reader-settings.png deleted file mode 100644 index 7b800592d..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/reader-settings.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/reader-settings@2x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/reader-settings@2x.png deleted file mode 100644 index d965379a6..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/reader-settings@2x.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/reader-settings@3x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/reader-settings@3x.png deleted file mode 100644 index 720b4636c..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/reader-settings.imageset/reader-settings@3x.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/Contents.json index a02d05dc2..67d103d43 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/Contents.json +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/Contents.json @@ -1,17 +1,15 @@ { "images" : [ { - "filename" : "remove.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "remove@2x.png", + "filename" : "toolbar-delete.svg", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "remove@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/remove.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/remove.png deleted file mode 100644 index 6f0731f80..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/remove.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/remove@2x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/remove@2x.png deleted file mode 100644 index 6506db813..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/remove@2x.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/remove@3x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/remove@3x.png deleted file mode 100644 index 235beb5c6..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/remove@3x.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/toolbar-delete.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/toolbar-delete.svg new file mode 100644 index 000000000..7e4f7cde8 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/remove.imageset/toolbar-delete.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-archive.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-archive.imageset/Contents.json new file mode 100644 index 000000000..f0ac632b6 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-archive.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "toolbar-archive.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-archive.imageset/toolbar-archive.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-archive.imageset/toolbar-archive.svg new file mode 100644 index 000000000..b3bf4ff3b --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-archive.imageset/toolbar-archive.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-share.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-share.imageset/Contents.json new file mode 100644 index 000000000..b27f103c2 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-share.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "toolbar-share.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-share.imageset/toolbar-share.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-share.imageset/toolbar-share.svg new file mode 100644 index 000000000..6473f98ab --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-share.imageset/toolbar-share.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-trash.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-trash.imageset/Contents.json new file mode 100644 index 000000000..67d103d43 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-trash.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "toolbar-delete.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-trash.imageset/toolbar-delete.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-trash.imageset/toolbar-delete.svg new file mode 100644 index 000000000..7e4f7cde8 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-trash.imageset/toolbar-delete.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-unarchive.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-unarchive.imageset/Contents.json new file mode 100644 index 000000000..418dd2e1e --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-unarchive.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "toolbar-unarchive.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-unarchive.imageset/toolbar-unarchive.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-unarchive.imageset/toolbar-unarchive.svg new file mode 100644 index 000000000..1739db681 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/toolbar-unarchive.imageset/toolbar-unarchive.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/unarchive.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/unarchive.imageset/Contents.json index 1c4647938..a8c7a9385 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/unarchive.imageset/Contents.json +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/unarchive.imageset/Contents.json @@ -1,7 +1,6 @@ { "images" : [ { - "filename" : "unarchive.png", "idiom" : "universal", "scale" : "1x" }, @@ -11,7 +10,6 @@ "scale" : "2x" }, { - "filename" : "unarchive@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/unarchive.imageset/unarchive.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/unarchive.imageset/unarchive.png deleted file mode 100644 index 091687457..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/unarchive.imageset/unarchive.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/unarchive.imageset/unarchive@3x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/unarchive.imageset/unarchive@3x.png deleted file mode 100644 index 4a45a4d94..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/unarchive.imageset/unarchive@3x.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/Contents.json index 8c12c640e..87bbd8256 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/Contents.json +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/Contents.json @@ -1,17 +1,15 @@ { "images" : [ { - "filename" : "utility-menu.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "utility-menu@2x.png", + "filename" : "header-three-dots.svg", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "utility-menu@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/header-three-dots.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/header-three-dots.svg new file mode 100644 index 000000000..d5669198f --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/header-three-dots.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/utility-menu.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/utility-menu.png deleted file mode 100644 index 041425b60..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/utility-menu.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/utility-menu@2x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/utility-menu@2x.png deleted file mode 100644 index da622ac13..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/utility-menu@2x.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/utility-menu@3x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/utility-menu@3x.png deleted file mode 100644 index a4ced564a..000000000 Binary files a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/utility-menu.imageset/utility-menu@3x.png and /dev/null differ diff --git a/apple/OmnivoreKit/Sources/Views/Resources/Fonts/Fraunces-Italic.ttf b/apple/OmnivoreKit/Sources/Views/Resources/Fonts/Fraunces-Italic.ttf new file mode 100644 index 000000000..7884a1ecf Binary files /dev/null and b/apple/OmnivoreKit/Sources/Views/Resources/Fonts/Fraunces-Italic.ttf differ diff --git a/apple/OmnivoreKit/Sources/Views/Resources/Fonts/Fraunces-Variable.ttf b/apple/OmnivoreKit/Sources/Views/Resources/Fonts/Fraunces-Variable.ttf new file mode 100644 index 000000000..c3f165aed Binary files /dev/null and b/apple/OmnivoreKit/Sources/Views/Resources/Fonts/Fraunces-Variable.ttf differ diff --git a/apple/OmnivoreKit/Sources/Views/Resources/Fonts/Literata-Italic.ttf b/apple/OmnivoreKit/Sources/Views/Resources/Fonts/Literata-Italic.ttf new file mode 100644 index 000000000..29743a318 Binary files /dev/null and b/apple/OmnivoreKit/Sources/Views/Resources/Fonts/Literata-Italic.ttf differ diff --git a/apple/OmnivoreKit/Sources/Views/Resources/Fonts/Literata-Variable.ttf b/apple/OmnivoreKit/Sources/Views/Resources/Fonts/Literata-Variable.ttf new file mode 100644 index 000000000..527a458a9 Binary files /dev/null and b/apple/OmnivoreKit/Sources/Views/Resources/Fonts/Literata-Variable.ttf differ diff --git a/apple/OmnivoreKit/Sources/Views/Theme.swift b/apple/OmnivoreKit/Sources/Views/Theme.swift index 31ddec76f..e4b9147a9 100644 --- a/apple/OmnivoreKit/Sources/Views/Theme.swift +++ b/apple/OmnivoreKit/Sources/Views/Theme.swift @@ -31,6 +31,10 @@ public enum Theme: String, CaseIterable { } } + public var toolbarColor: Color { + ThemeManager.currentTheme.isDark ? Color.themeDarkWhiteGray : Color.themeMiddleGray + } + public var highlightColor: Color { switch self { case .light, .sepia: diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/100.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/100.png index b05446cc4..01fcd2338 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/100.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/102.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/102.png new file mode 100644 index 000000000..f5fe221f6 Binary files /dev/null and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/102.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/1024.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/1024.png index 1d730a207..2491947ea 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/1024.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/114.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/114.png index c5b1a9ec7..34b88fc22 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/114.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/120.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/120.png index 2d29a454a..16536c52c 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/120.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/128.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/128.png index 1e32997be..d1cb6c709 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/128.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/128.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/144.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/144.png index 1e85b1eba..0c970c39d 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/144.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/144.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/152.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/152.png index d67375bc0..a71513553 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/152.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/16.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/16.png new file mode 100644 index 000000000..90f8121d8 Binary files /dev/null and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/16.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/167.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/167.png index 112c03700..2d9f0d48a 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/167.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/172.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/172.png index e4565bd3c..f02ecd802 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/172.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/172.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/180.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/180.png index bd895c1c6..7a3e22380 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/180.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/196.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/196.png index d98ad8c0f..3e5d43be5 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/196.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/196.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/20.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/20.png index 9d32728b4..12c691e12 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/20.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/216.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/216.png index 6ee0b5763..e4cd6649f 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/216.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/216.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/256.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/256.png index 769c354d7..f77d48a92 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/256.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/256.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/29.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/29.png index 1f335b31e..178ba60df 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/29.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/32.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/32.png new file mode 100644 index 000000000..663b7704a Binary files /dev/null and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/32.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/40.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/40.png index 8d2bd163f..b031f7c12 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/40.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/48.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/48.png index d10e015c2..e84c3d084 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/48.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/48.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/50.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/50.png index 6e9a82ed0..313874136 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/50.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/50.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/512.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/512.png new file mode 100644 index 000000000..2255a4b59 Binary files /dev/null and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/512.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/55.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/55.png index 249d054a7..908b8ef72 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/55.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/55.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/57.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/57.png index 5bb947b38..51aa2121e 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/57.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/58.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/58.png index 3430925c2..d9b378c25 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/58.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/60.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/60.png index 42ed00f69..c8705d6f8 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/60.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/64.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/64.png new file mode 100644 index 000000000..447fafc66 Binary files /dev/null and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/64.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/66.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/66.png new file mode 100644 index 000000000..6fa49f249 Binary files /dev/null and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/66.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/72.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/72.png index 44170f0e8..ba9307e60 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/72.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/72.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/76.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/76.png index bd163096c..be24500fe 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/76.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/80.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/80.png index 2ec51e0bb..53cc17578 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/80.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/87.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/87.png index f32f5abae..62bd34c1a 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/87.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/88.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/88.png index 8f1e258f2..cfe86e617 100644 Binary files a/apple/Resources/Assets.xcassets/AppIcon.appiconset/88.png and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/88.png differ diff --git a/apple/Resources/Assets.xcassets/AppIcon.appiconset/92.png b/apple/Resources/Assets.xcassets/AppIcon.appiconset/92.png new file mode 100644 index 000000000..3e7dc5833 Binary files /dev/null and b/apple/Resources/Assets.xcassets/AppIcon.appiconset/92.png differ diff --git a/apple/Sources/AppDelegate.swift b/apple/Sources/AppDelegate.swift index 6d5298db9..3082ddc91 100644 --- a/apple/Sources/AppDelegate.swift +++ b/apple/Sources/AppDelegate.swift @@ -56,6 +56,9 @@ private let logger = Logger(subsystem: "app.omnivore", category: "app-delegate") Services.registerBackgroundFetch() configureFirebase() + // TODO: remove after filter badge is re-enabled + UIApplication.shared.applicationIconBadgeNumber = 0 + // swiftlint:disable:next line_length NotificationCenter.default.addObserver(forName: Notification.Name("ReconfigurePushNotifications"), object: nil, queue: OperationQueue.main) { _ in if UserDefaults.standard.bool(forKey: UserDefaultKey.notificationsEnabled.rawValue) { diff --git a/apple/Sources/SafariExtension/Resources/scripts/api.js b/apple/Sources/SafariExtension/Resources/scripts/api.js new file mode 100644 index 000000000..ca8ac9911 --- /dev/null +++ b/apple/Sources/SafariExtension/Resources/scripts/api.js @@ -0,0 +1 @@ +function gqlRequest(e,n){return getStorageItem("apiKey").then((t=>fetch(e,{method:"POST",redirect:"follow",credentials:"include",mode:"cors",headers:{Accept:"application/json","Content-Type":"application/json",...t?{Authorization:t}:{}},body:n}))).then((e=>e.json())).then((e=>{if(!e.data)throw new Error("No response data");return e.data}))}async function updateLabelsCache(e,n){const t=JSON.stringify({query:"query GetLabels {\n labels {\n ... on LabelsSuccess {\n labels {\n ...LabelFields\n }\n }\n ... on LabelsError {\n errorCodes\n }\n }\n }\n fragment LabelFields on Label {\n id\n name\n color\n description\n createdAt\n }\n "}),r=await gqlRequest(e,t);return r.labels&&!r.labels.errorCodes&&r.labels.labels?(await setStorage({labels:r.labels.labels,labelsLastUpdated:(new Date).toISOString()}),r.labels.labels):(console.log("GQL Error updating label cache response:",r,r),console.log(!r.labels,r.labels.errorCodes,!r.labels.labels),[])}async function updatePageTitle(e,n,t){const r=JSON.stringify({query:"mutation UpdatePage($input: UpdatePageInput!) {\n updatePage(input: $input) {\n ... on UpdatePageSuccess {\n updatedPage {\n id\n }\n }\n ... on UpdatePageError {\n errorCodes\n }\n }\n }\n ",variables:{input:{pageId:n,title:t}}}),i=await gqlRequest(e,r);if(!i.updatePage||i.updatePage.errorCodes||!i.updatePage.updatedPage)throw console.log("GQL Error updating page:",i),new Error("Error updating title.");return i.updatePage.updatePage}async function setLabels(e,n,t){const r=JSON.stringify({query:"mutation SetLabels($input: SetLabelsInput!) {\n setLabels(input: $input) {\n ... on SetLabelsSuccess {\n labels {\n id\n name\n color\n }\n }\n ... on SetLabelsError {\n errorCodes\n }\n }\n }\n ",variables:{input:{pageId:n,labels:t}}}),i=await gqlRequest(e,r);if(!i.setLabels||i.setLabels.errorCodes||!i.setLabels.labels)throw console.log("GQL Error setting labels:",i),new Error("Error setting labels.");return await appendLabelsToCache(i.setLabels.labels),i.setLabels.labels}async function appendLabelsToCache(e){const n=await getStorageItem("labels");n?(e.forEach((e=>{n.find((n=>n.name===e.name))||n.unshift(e)})),await setStorage({labels:n,labelsLastUpdated:(new Date).toISOString()})):await setStorage({labels:e,labelsLastUpdated:(new Date).toISOString()})}async function addNote(e,n,t,r,i){const a=JSON.stringify({query:"query GetArticle(\n $username: String!\n $slug: String!\n $includeFriendsHighlights: Boolean\n ) {\n article(username: $username, slug: $slug) {\n ... on ArticleSuccess {\n article {\n highlights(input: { includeFriends: $includeFriendsHighlights }) {\n ...HighlightFields\n }\n }\n }\n ... on ArticleError {\n errorCodes\n }\n }\n }\n fragment HighlightFields on Highlight {\n id\n type\n annotation\n }\n ",variables:{username:"me",slug:n,includeFriendsHighlights:!1}}),o=await gqlRequest(e,a);if(!o.article||o.article.errorCodes||!o.article.article)return void console.log("GQL Error getting existing highlights:",o);const l=o.article.article.highlights.find((e=>"NOTE"==e.type));if(l){const n=JSON.stringify({query:"\n mutation UpdateHighlight($input: UpdateHighlightInput!) {\n updateHighlight(input: $input) {\n ... on UpdateHighlightSuccess {\n highlight {\n id\n }\n }\n ... on UpdateHighlightError {\n errorCodes\n }\n }\n }\n ",variables:{input:{highlightId:l.id,annotation:l.annotation?l.annotation+"\n\n"+i:i}}}),t=await gqlRequest(e,n);return t.updateHighlight&&!t.updateHighlight.errorCodes&&t.updateHighlight.highlight?t.updateHighlight.highlight.id:void console.log("GQL Error updating note:",t)}{const a=JSON.stringify({query:"\n mutation CreateHighlight($input: CreateHighlightInput!) {\n createHighlight(input: $input) {\n ... on CreateHighlightSuccess {\n highlight {\n id\n }\n }\n ... on CreateHighlightError {\n errorCodes\n }\n }\n }\n ",variables:{input:{id:t,shortId:r,type:"NOTE",articleId:n,annotation:i}}}),o=await gqlRequest(e,a);return o.createHighlight&&!o.createHighlight.errorCodes&&o.createHighlight.highlight?o.createHighlight.highlight.id:void console.log("GQL Error setting note:",o)}}async function archive(e,n){const t=JSON.stringify({query:"mutation SetLinkArchived($input: ArchiveLinkInput!) {\n setLinkArchived(input: $input) {\n ... on ArchiveLinkSuccess {\n linkId\n message\n }\n ... on ArchiveLinkError {\n message\n errorCodes\n }\n }\n }\n ",variables:{input:{linkId:n,archived:!0}}}),r=await gqlRequest(e,t);if(!r.setLinkArchived||r.setLinkArchived.errorCodes||!r.setLinkArchived.linkId)throw console.log("GQL Error archiving:",r),new Error("Error archiving.");return r.setLinkArchived.linkId}async function deleteItem(e,n){const t=JSON.stringify({query:"mutation SetBookmarkArticle($input: SetBookmarkArticleInput!) {\n setBookmarkArticle(input: $input) {\n ... on SetBookmarkArticleSuccess {\n bookmarkedArticle {\n id\n }\n }\n ... on SetBookmarkArticleError {\n errorCodes\n }\n }\n }\n ",variables:{input:{articleID:n,bookmark:!1}}}),r=await gqlRequest(e,t);if(!r.setBookmarkArticle||r.setBookmarkArticle.errorCodes||!r.setBookmarkArticle.bookmarkedArticle)throw console.log("GQL Error deleting:",r),new Error("Error deleting.");return r.setBookmarkArticle.bookmarkedArticle} \ No newline at end of file diff --git a/apple/Sources/SafariExtension/Resources/scripts/common.js b/apple/Sources/SafariExtension/Resources/scripts/common.js new file mode 100644 index 000000000..b1823bb74 --- /dev/null +++ b/apple/Sources/SafariExtension/Resources/scripts/common.js @@ -0,0 +1 @@ +"use strict";function handleBackendUrl(e){try{return[/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:\/.*)?/,/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu\.be|piped\.video))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/].some((n=>n.test(e)))}catch(n){console.log("error checking url",e)}return!1}function getStorage(e){return new Promise((n=>{browserApi.storage.local.get(e||null,(e=>{n(e||{})}))}))}function getStorageItem(e){return new Promise((n=>{browserApi.storage.local.get(e,(r=>{const o=r&&r[e]||null;n(o)}))}))}function setStorage(e){return new Promise((n=>{browserApi.storage.local.set(e,n)}))}function removeStorage(e){return new Promise((n=>{browserApi.storage.local.remove(e,n)}))}window.browserApi="object"==typeof chrome&&chrome&&chrome.runtime&&chrome||"object"==typeof browser&&browser||{},window.browserActionApi=browserApi.action||browserApi.browserAction||browserApi.pageAction,window.browserScriptingApi=browserApi.scripting||browserApi.tabs,window.ENV_EXTENSION_ORIGIN=browserApi.runtime.getURL("PATH/").replace("/PATH/",""),window.ENV_IS_FIREFOX=ENV_EXTENSION_ORIGIN.startsWith("moz-extension://"),window.ENV_IS_EDGE=navigator.userAgent.toLowerCase().indexOf("edg")>-1,window.ENV_DOES_NOT_SUPPORT_BLOB_URL_ACCESS=/^((?!chrome|android).)*safari/i.test(navigator.userAgent),window.SELECTORS={CANONICAL_URL:["head > link[rel='canonical']"],TITLE:["head > meta[property='og:title']"]},window.ACTIONS={Ping:"PING",ShowMessage:"SHOW_MESSAGE",GetContent:"GET_CONTENT",AddIframeContent:"ADD_IFRAME_CONTENT",RefreshDarkMode:"REFRESH_DARK_MODE",GetAuthToken:"GET_AUTH_TOKEN",LabelCacheUpdated:"LABEL_CACHE_UPDATED",ShowToolbar:"SHOW_TOOLBAR",UpdateStatus:"UPDATE_STATUS",AddNote:"ADD_NOTE",EditTitle:"EDIT_TITLE",SetLabels:"SET_LABELS",Archive:"ARCHIVE",Delete:"DELETE"},window.SAVE_URL_QUERY="mutation SaveUrl ($input: SaveUrlInput!) {\n saveUrl(input:$input){\n ... on SaveSuccess {\n url\n clientRequestId\n }\n ... on SaveError {\n errorCodes\n }\n }\n}",window.SAVE_FILE_QUERY="mutation SaveFile ($input: SaveFileInput!) {\n saveFile(input:$input){\n ... on SaveSuccess {\n url\n clientRequestId\n }\n ... on SaveError {\n errorCodes\n }\n }\n}",window.SAVE_PAGE_QUERY="mutation SavePage ($input: SavePageInput!) {\n savePage(input:$input){\n ... on SaveSuccess {\n url\n clientRequestId\n }\n ... on SaveError {\n errorCodes\n }\n }\n}"; \ No newline at end of file diff --git a/apple/Sources/SafariExtension/Resources/scripts/options.js b/apple/Sources/SafariExtension/Resources/scripts/options.js new file mode 100644 index 000000000..c57be8e06 --- /dev/null +++ b/apple/Sources/SafariExtension/Resources/scripts/options.js @@ -0,0 +1 @@ +function saveAPIKey(){var e=document.getElementById("api-key").value;e?setStorage({apiKey:e}).then((()=>{alert("API key saved!")})):alert("No api-key specified, please create an API key at https://omnivore.app/settings/api")}function loadAPIKey(){getStorageItem("apiKey").then((e=>{e?document.getElementById("api-key").value=e:alert("No API key found in storage.")}))}function clearAPIKey(){document.getElementById("api-key").value="",setStorage({apiKey:void 0}).then((()=>{alert("API key cleared!")}))}function autoDismissChanged(e){const t=document.getElementById("disable-auto-dismiss").checked;console.log(" value: ",t,document.getElementById("disable-auto-dismiss")),setStorage({disableAutoDismiss:t?"true":null}).then((()=>{console.log("disableAutoDismiss updated",t)}))}function saveAutoDismissTime(){const e=document.getElementById("auto-dismiss-time").value;e.length<1||Number.isNaN(Number(e))?alert("Invalid value"):setStorage({autoDismissTime:e}).then((()=>{console.log("autoDismissTime updated",e)}))}document.getElementById("save-api-key-btn").addEventListener("click",saveAPIKey),document.getElementById("load-api-key-btn").addEventListener("click",loadAPIKey),document.getElementById("clear-api-key-btn").addEventListener("click",clearAPIKey),getStorageItem("disableAutoDismiss").then((e=>{document.getElementById("disable-auto-dismiss").checked=!!e})),document.getElementById("disable-auto-dismiss").addEventListener("change",autoDismissChanged),getStorageItem("autoDismissTime").then((e=>{document.getElementById("auto-dismiss-time").value=e??"2500"})),document.getElementById("auto-dismiss-time-btn").addEventListener("click",saveAutoDismissTime); \ No newline at end of file diff --git a/apple/Sources/SafariExtension/Resources/views/installed.html b/apple/Sources/SafariExtension/Resources/views/installed.html new file mode 100644 index 000000000..a886ae955 --- /dev/null +++ b/apple/Sources/SafariExtension/Resources/views/installed.html @@ -0,0 +1,118 @@ + + + + + + Data Collection Consent + + + + + + + + + + + diff --git a/apple/Sources/SafariExtension/Resources/views/options.html b/apple/Sources/SafariExtension/Resources/views/options.html new file mode 100644 index 000000000..6d94b26c6 --- /dev/null +++ b/apple/Sources/SafariExtension/Resources/views/options.html @@ -0,0 +1,52 @@ + + + + Omnivore Extension Settings + + + + +
+

API Key

+ +

Normally Omnivore uses an authentication cookie that is set when + you log in at omnivore.app. If + your security settings do not allow the extension to access this + cookie (for example if you are using containers), you can set an + API token in the extension's local storage instead. +

+ +

To do this, create an API key at + omnivore.app/settings/api, + paste it into the textboix below, and choose 'Save API Key'. +

+ +

+ + + +

+ + + + + +

 

+ +

Settings

+ +

+ + +

+ +

+ + + +

+ +
+ + + \ No newline at end of file diff --git a/apple/Sources/SafariExtension/Resources/views/toast.html b/apple/Sources/SafariExtension/Resources/views/toast.html new file mode 100644 index 000000000..c09979d78 --- /dev/null +++ b/apple/Sources/SafariExtension/Resources/views/toast.html @@ -0,0 +1,556 @@ + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + +
+ +
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+
+
    +
  • + +
  • +
+
+ +
+
+
+ +
+ + + + + + +
+ + +
\ No newline at end of file