Merge pull request #3238 from omnivore-app/feat/ios-add-feed

Add feed buttons for iOS
This commit is contained in:
Jackson Harper 2023-12-22 11:51:19 +08:00 committed by GitHub
commit 7caa6f5a12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
179 changed files with 4909 additions and 952 deletions

View file

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1420"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "42FF1B15271154A700B38C38"
BuildableName = "SafariExtension.appex"
BlueprintName = "SafariExtension (iOS)"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "42FF1B1F271154A700B38C38"
BuildableName = "SafariExtension.appex"
BlueprintName = "SafariExtension (macOS)"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<RemoteRunnable
runnableDebuggingMode = "0"
BundleIdentifier = "com.apple.Safari"
RemotePath = "/Applications/Safari.app">
</RemoteRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1340"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "42FF1B1F271154A700B38C38"
BuildableName = "SafariExtension.appex"
BlueprintName = "SafariExtension (macOS)"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
wasCreatedForAppExtension = "YES"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "046C5CD126A3F89A00AC5349"
BuildableName = "ShareExtension-Mac.appex"
BlueprintName = "ShareExtension-Mac"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
debugAsWhichUser = "root"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<PathRunnable
runnableDebuggingMode = "0"
BundleIdentifier = "com.apple.Safari"
FilePath = "/System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app">
</PathRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1340"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3D30FF2BDEFCACE5A2A13AD1"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "42E2BFB028E458E0007F29B2"
BuildableName = "AppStoreScreenshots.xctest"
BlueprintName = "AppStoreScreenshots"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -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 = ""

View file

@ -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)

View file

@ -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(

View file

@ -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)
})
}
}

View file

@ -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)
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -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) {

View file

@ -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<Models.LibraryItem> = LibraryItem.fetchRequest()
fetchRequest.predicate = internalFilter.predicate
if let count = try? dataService.backgroundContext.count(for: fetchRequest) {
UIApplication.shared.applicationIconBadgeNumber = count
}
}
}
}
}

View file

@ -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)
})

View file

@ -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<Models.LibraryItem>?
@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 {

View file

@ -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

View file

@ -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")
}
}
}
}

View file

@ -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<String>
@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))
}
}
}

View file

@ -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<Models.Filter> = 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<Models.LibraryItem> = 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) {

View file

@ -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
})
}
}
}

View file

@ -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) },

View file

@ -14,6 +14,7 @@ enum CardStyle {
struct LibraryListConfig {
var hasFeatureCards = false
var hasReadNowSection = false
var leadingSwipeActions = [SwipeAction]()
var trailingSwipeActions = [SwipeAction]()
var cardStyle = CardStyle.library

View file

@ -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
}
}
// }
}
}

View file

@ -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()
})
}
}
}
}
}

View file

@ -75,6 +75,7 @@
innerBody
}.introspectViewController { controller in
searchBar = Introspect.findChild(ofType: UISearchBar.self, in: controller.view)
searchBar?.smartQuotesType = .no
}
}

View file

@ -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()) }
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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"
}
}
}

View file

@ -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 }

View file

@ -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 }
}
}
}

View file

@ -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)
}
}

View file

@ -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) }

View file

@ -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)
// })
// }
// }
}
}
}

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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) }
}

View file

@ -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() }
}

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -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<Models.LinkedItemLabel> = 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<Content: View>: 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
}
}
})
}
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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()
}
}

View file

@ -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")
}
}
}

View file

@ -11,6 +11,7 @@ import Views
let isMacApp = true
#endif
@MainActor
public final class RootViewModel: ObservableObject {
let services = Services()

View file

@ -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)

View file

@ -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
}

View file

@ -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)

View file

@ -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)

View file

@ -16,6 +16,10 @@ struct SafariWebLink: Identifiable {
@Published var isDownloadingAudio: Bool = false
@Published var audioDownloadTask: Task<Void, Error>?
@Published var operationMessage: String?
@Published var showOperationToast: Bool = false
@Published var operationStatus: OperationStatus = .none
@Published var showSnackbar: Bool = false
var snackbarOperation: SnackbarOperation?

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="23B81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23B81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Filter" representedClassName="Filter" syncable="YES" codeGenerationType="class">
<attribute name="defaultFilter" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="filter" optional="YES" attributeType="String"/>
@ -96,8 +96,10 @@
</entity>
<entity name="NewsletterEmail" representedClassName="NewsletterEmail" syncable="YES" codeGenerationType="class">
<attribute name="confirmationCode" optional="YES" attributeType="String"/>
<attribute name="descriptionNote" optional="YES" attributeType="String"/>
<attribute name="email" attributeType="String"/>
<attribute name="emailId" attributeType="String"/>
<attribute name="folder" optional="YES" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="emailId"/>

View file

@ -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),

View file

@ -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
}

View file

@ -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

View file

@ -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 = [

View file

@ -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() {

View file

@ -128,6 +128,17 @@ public final class DataService: ObservableObject {
}
}
func deleteAllEntities(entityName: String, inContext context: NSManagedObjectContext) {
let deleteFetch = NSFetchRequest<NSFetchRequestResult>(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()

View file

@ -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)
}

View file

@ -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()
)
}))

View file

@ -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

View file

@ -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<MutationResult, Unions.FetchContentResult> {
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))
}
}
}
}
}

View file

@ -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<MutationResult, Unions.SetRuleResult> {
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))
}
}
}
}
}

View file

@ -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<MutationResult, Unions.SubscribeResult> {
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)"))
}
}
}
}
}

View file

@ -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<MutationResult, Unions.UpdateNewsletterEmailResult> {
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))
}
}
}
}
}

View file

@ -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<MutationResult, Unions.UpdateSubscriptionResult> {
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))
}
}
}
}
}

View file

@ -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<QueryResult, Unions.NewsletterEmailsResult> {
try $0.on(
newsletterEmailsError: .init {

View file

@ -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<QueryResult, Unions.RulesResult> {
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"))
}
}
}
}
}

View file

@ -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<QueryResult, Unions.ScanFeedsResult> {
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"))
}
}
}
}
}

View file

@ -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<QueryResult, Unions.SubscriptionsResult> {
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
}
}
}

View file

@ -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(),

View file

@ -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
}
}
}

View file

@ -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<Models.Filter> = 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 {

View file

@ -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
}
}

View file

@ -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)

View file

@ -33,6 +33,7 @@ public enum UserDefaultKey: String {
case deviceTokenID
case userWordsPerMinute
case hideFeatureSection
case hideSystemLabels
case justifyText
case prefersHideStatusBarInReader
case visibleShareExtensionTab

View file

@ -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) }

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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<Bool>,
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)

View file

@ -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)
}

View file

@ -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)

View file

@ -1,10 +1,3 @@
//
// FormSheet.swift
//
//
// Created by Jackson Harper on 1/29/22.
//
import SwiftUI
#if os(iOS)

View file

@ -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) }

View file

@ -1,15 +0,0 @@
{
"images" : [
{
"filename" : "chart-line-up.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32" version="1.1">
<g id="surface1">
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 224 208 L 32 208 L 32 48 " transform="matrix(0.125,0,0,0.125,0,0)"/>
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 208 64 L 128 144 L 96 112 L 32 176 " transform="matrix(0.125,0,0,0.125,0,0)"/>
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 208 104 L 208 64 L 168 64 " transform="matrix(0.125,0,0,0.125,0,0)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 869 B

View file

@ -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"
}
}

View file

@ -0,0 +1,11 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_6919_3680)">
<path d="M6.25024 6.25065C6.25024 5.97438 6.35999 5.70943 6.55534 5.51408C6.75069 5.31873 7.01564 5.20898 7.29191 5.20898H9.37524C9.65151 5.20898 9.91646 5.31873 10.1118 5.51408C10.3072 5.70943 10.4169 5.97438 10.4169 6.25065V18.7507C10.4169 19.0269 10.3072 19.2919 10.1118 19.4872C9.91646 19.6826 9.65151 19.7923 9.37524 19.7923H7.29191C7.01564 19.7923 6.75069 19.6826 6.55534 19.4872C6.35999 19.2919 6.25024 19.0269 6.25024 18.7507V6.25065Z" stroke="#6A6968" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.5835 6.25065C14.5835 5.97438 14.6932 5.70943 14.8886 5.51408C15.0839 5.31873 15.3489 5.20898 15.6252 5.20898H17.7085C17.9848 5.20898 18.2497 5.31873 18.4451 5.51408C18.6404 5.70943 18.7502 5.97438 18.7502 6.25065V18.7507C18.7502 19.0269 18.6404 19.2919 18.4451 19.4872C18.2497 19.6826 17.9848 19.7923 17.7085 19.7923H15.6252C15.3489 19.7923 15.0839 19.6826 14.8886 19.4872C14.6932 19.2919 14.5835 19.0269 14.5835 18.7507V6.25065Z" stroke="#6A6968" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_6919_3680">
<rect width="25" height="25" fill="white" transform="translate(0.000244141)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -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"
}
}

View file

@ -0,0 +1,3 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 2.4375C10.9109 2.4375 8.86879 3.05698 7.13179 4.2176C5.3948 5.37822 4.04098 7.02786 3.24153 8.95791C2.44208 10.8879 2.2329 13.0117 2.64046 15.0606C3.04802 17.1096 4.054 18.9916 5.53119 20.4688C7.00838 21.946 8.89044 22.952 10.9394 23.3595C12.9883 23.7671 15.1121 23.5579 17.0421 22.7585C18.9721 21.959 20.6218 20.6052 21.7824 18.8682C22.943 17.1312 23.5625 15.0891 23.5625 13C23.5595 10.1996 22.4458 7.51466 20.4656 5.53445C18.4853 3.55424 15.8004 2.44046 13 2.4375ZM13 21.9375C11.2323 21.9375 9.50436 21.4133 8.0346 20.4313C6.56483 19.4492 5.41929 18.0533 4.74283 16.4202C4.06637 14.7871 3.88938 12.9901 4.23424 11.2564C4.57909 9.52268 5.43031 7.93016 6.68024 6.68023C7.93017 5.4303 9.52268 4.57909 11.2564 4.23423C12.9901 3.88938 14.7871 4.06637 16.4202 4.74283C18.0534 5.41928 19.4492 6.56483 20.4313 8.03459C21.4133 9.50435 21.9375 11.2323 21.9375 13C21.9348 15.3695 20.9923 17.6413 19.3168 19.3168C17.6413 20.9923 15.3696 21.9348 13 21.9375ZM16.7009 12.3236L11.8259 9.07359C11.7036 8.99193 11.5613 8.94504 11.4143 8.93792C11.2674 8.9308 11.1212 8.96372 10.9915 9.03317C10.8618 9.10261 10.7534 9.20597 10.6778 9.33222C10.6023 9.45847 10.5624 9.60287 10.5625 9.75V16.25C10.5624 16.3971 10.6023 16.5415 10.6778 16.6678C10.7534 16.794 10.8618 16.8974 10.9915 16.9668C11.1212 17.0363 11.2674 17.0692 11.4143 17.0621C11.5613 17.055 11.7036 17.0081 11.8259 16.9264L16.7009 13.6764C16.8124 13.6022 16.9038 13.5017 16.967 13.3837C17.0302 13.2657 17.0633 13.1339 17.0633 13C17.0633 12.8661 17.0302 12.7343 16.967 12.6163C16.9038 12.4983 16.8124 12.3978 16.7009 12.3236ZM12.1875 14.7316V11.2734L14.7855 13L12.1875 14.7316Z" fill="#D9D9D9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -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"
}

View file

@ -0,0 +1,11 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_6919_3673)">
<path d="M8.85441 9.89583C9.42971 9.89583 9.89608 9.42946 9.89608 8.85417C9.89608 8.27887 9.42971 7.8125 8.85441 7.8125C8.27911 7.8125 7.81274 8.27887 7.81274 8.85417C7.81274 9.42946 8.27911 9.89583 8.85441 9.89583Z" stroke="#6A6968" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.16699 7.29102V11.3108C4.16699 11.8702 4.38887 12.4066 4.7847 12.8025L13.2389 21.2566C13.4347 21.4525 13.6673 21.608 13.9232 21.714C14.1792 21.82 14.4535 21.8746 14.7305 21.8746C15.0076 21.8746 15.2819 21.82 15.5378 21.714C15.7938 21.608 16.0263 21.4525 16.2222 21.2566L21.2576 16.2212C21.4535 16.0253 21.6089 15.7928 21.715 15.5369C21.821 15.2809 21.8756 15.0066 21.8756 14.7296C21.8756 14.4525 21.821 14.1782 21.715 13.9223C21.6089 13.6663 21.4535 13.4338 21.2576 13.2379L12.8024 4.78372C12.407 4.38841 11.8709 4.16623 11.3118 4.16602H7.29199C6.46319 4.16602 5.66833 4.49526 5.08228 5.08131C4.49623 5.66736 4.16699 6.46221 4.16699 7.29102Z" stroke="#6A6968" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_6919_3673">
<rect width="25" height="25" fill="white" transform="translate(0.000244141)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 B

Some files were not shown because too many files have changed in this diff Show more