Merge pull request #3188 from omnivore-app/feat/ios-following-view

Abstract out fetching from view model so we can better handle multiple fetch folders
This commit is contained in:
Jackson Harper 2023-12-08 10:48:30 +08:00 committed by GitHub
commit ee7f921fd1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
114 changed files with 4125 additions and 1578 deletions

View file

@ -38,6 +38,7 @@
42321E892714E6B00056429F /* views in Resources */ = {isa = PBXBuildFile; fileRef = 42321E842714E6B00056429F /* views */; };
42321E8A2714E6B00056429F /* views in Resources */ = {isa = PBXBuildFile; fileRef = 42321E842714E6B00056429F /* views */; };
42704E7328E6BDB000C8C73E /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42704E7228E6BDAF00C8C73E /* SnapshotHelper.swift */; };
42B4483A2B1DD8AC00CEC5A0 /* AppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B448392B1DD8AC00CEC5A0 /* AppIntents.swift */; };
42E2BFB428E458E0007F29B2 /* AppStoreScreenshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E2BFB328E458E0007F29B2 /* AppStoreScreenshots.swift */; };
42FF1B33271154A700B38C38 /* SafariWebExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42FF1AEB271154A600B38C38 /* SafariWebExtensionHandler.swift */; };
42FF1B34271154A700B38C38 /* SafariWebExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42FF1AEB271154A600B38C38 /* SafariWebExtensionHandler.swift */; };
@ -212,6 +213,7 @@
42321E822714E6B00056429F /* scripts */ = {isa = PBXFileReference; lastKnownFileType = folder; path = scripts; sourceTree = "<group>"; };
42321E842714E6B00056429F /* views */ = {isa = PBXFileReference; lastKnownFileType = folder; path = views; sourceTree = "<group>"; };
42704E7228E6BDAF00C8C73E /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = "<group>"; };
42B448392B1DD8AC00CEC5A0 /* AppIntents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppIntents.swift; sourceTree = "<group>"; };
42E2BFB128E458E0007F29B2 /* AppStoreScreenshots.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AppStoreScreenshots.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
42E2BFB328E458E0007F29B2 /* AppStoreScreenshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreScreenshots.swift; sourceTree = "<group>"; };
42FF1AEB271154A600B38C38 /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = "<group>"; };
@ -489,6 +491,7 @@
7C54279684D6252C9CD18562 /* Sources */ = {
isa = PBXGroup;
children = (
42B448392B1DD8AC00CEC5A0 /* AppIntents.swift */,
0480E71B26D95096006CAE2F /* AppDelegate.swift */,
D81BE98F0CB588F5FC577A13 /* MainApp.swift */,
42FF1AEA271154A600B38C38 /* SafariExtension */,
@ -1073,6 +1076,7 @@
buildActionMask = 2147483647;
files = (
0480E71C26D95096006CAE2F /* AppDelegate.swift in Sources */,
42B4483A2B1DD8AC00CEC5A0 /* AppIntents.swift in Sources */,
04920CC6279671EF003EC1B6 /* PushNotificationConfig.swift in Sources */,
CA7EE773095F267516D7AC98 /* MainApp.swift in Sources */,
);
@ -1384,7 +1388,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.39.0;
MARKETING_VERSION = 1.40.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
@ -1419,7 +1423,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.39.0;
MARKETING_VERSION = 1.40.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -1474,7 +1478,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.39.0;
MARKETING_VERSION = 1.40.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1815,7 +1819,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.39.0;
MARKETING_VERSION = 1.40.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -27,6 +27,15 @@
"version" : "0.9.0"
}
},
{
"identity" : "engine",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nathantannar4/Engine",
"state" : {
"revision" : "31949c114698e4fd43fd76290913bca415fa87bc",
"version" : "1.1.0"
}
},
{
"identity" : "files",
"kind" : "remoteSourceControl",
@ -207,6 +216,15 @@
"version" : "1.19.0"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-syntax.git",
"state" : {
"revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036",
"version" : "509.0.2"
}
},
{
"identity" : "swiftformat",
"kind" : "remoteSourceControl",
@ -225,6 +243,24 @@
"version" : "0.1.4"
}
},
{
"identity" : "transmission",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nathantannar4/Transmission",
"state" : {
"revision" : "9517912f8f528c777f86f7896b5c35d7e43fa916",
"version" : "1.0.1"
}
},
{
"identity" : "turbocharger",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nathantannar4/Turbocharger",
"state" : {
"revision" : "b4201ba0bc094facf6cabe3b36fd3763b51ccfc8",
"version" : "1.0.1"
}
},
{
"identity" : "valet",
"kind" : "remoteSourceControl",

View file

@ -15,6 +15,7 @@ let package = Package(
.library(name: "Services", targets: ["Services"]),
.library(name: "Models", targets: ["Models"]),
.library(name: "Utils", targets: ["Utils"])
],
dependencies: dependencies,
targets: [
@ -26,7 +27,8 @@ let package = Package(
"Models",
.product(name: "Introspect", package: "SwiftUI-Introspect"),
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.productItem(name: "PopupView", package: "PopupView")
.productItem(name: "PopupView", package: "PopupView"),
.product(name: "Transmission", package: "Transmission")
],
resources: [.process("Resources")]
),
@ -70,7 +72,8 @@ var dependencies: [Package.Dependency] {
.package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.2.2"),
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.0.0"),
.package(url: "https://github.com/exyte/PopupView.git", from: "2.6.0"),
.package(url: "https://github.com/PostHog/posthog-ios.git", from: "2.0.0")
.package(url: "https://github.com/PostHog/posthog-ios.git", from: "2.0.0"),
.package(url: "https://github.com/nathantannar4/Transmission", from: "1.0.1")
]
// Comment out following line for macOS build
deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", from: "13.1.0"))

View file

@ -1,32 +0,0 @@
#if os(iOS)
import AppIntents
import Services
import SwiftUI
@available(iOS 16.0, *)
struct SaveLinkToOmnivoreIntent: AppIntent {
static var title: LocalizedStringResource = "Show Sport Progress"
static var parameterSummary: some ParameterSummary {
Summary("Save \(\.$link) to your Omnivore library.")
}
@Parameter(title: "link")
var link: URL
@MainActor
func perform() async throws -> some IntentResult {
do {
let services = Services()
let requestId = UUID().uuidString.lowercased()
_ = try await services.dataService.saveURL(id: requestId, url: link.absoluteString)
return .result(dialog: "Link saved to Omnivore")
} catch {
print("error saving URL: ", error)
}
return .result(dialog: "Error saving link")
}
}
#endif

View file

@ -11,7 +11,7 @@ public class ShareExtensionViewModel: ObservableObject {
@Published public var url: String?
@Published public var iconURL: URL?
@Published public var highlightData: HighlightData?
@Published public var linkedItem: LinkedItem?
@Published public var linkedItem: Models.LibraryItem?
@Published public var requestId = UUID().uuidString.lowercased()
@Published var debugText: String?
@Published var noteText: String = ""
@ -51,8 +51,8 @@ public class ShareExtensionViewModel: ObservableObject {
dataService.archiveLink(objectID: objectID, archived: archived)
}
func removeLink(dataService: DataService, objectID: NSManagedObjectID) {
dataService.removeLink(objectID: objectID)
func removeLibraryItem(dataService: DataService, objectID: NSManagedObjectID) {
dataService.removeLibraryItem(objectID: objectID)
}
func submitTitleEdit(dataService: DataService, itemID: String, title: String, description: String) {
@ -204,7 +204,7 @@ public class ShareExtensionViewModel: ObservableObject {
}
if let objectID = objectID {
self.linkedItem = self.services.dataService.viewContext.object(with: objectID) as? LinkedItem
self.linkedItem = self.services.dataService.viewContext.object(with: objectID) as? Models.LibraryItem
if let title = self.linkedItem?.title {
self.title = title
}

View file

@ -220,7 +220,7 @@ public struct ShareExtensionView: View {
Button(
action: {
if let linkedItem = self.viewModel.linkedItem {
self.viewModel.removeLink(dataService: self.viewModel.services.dataService, objectID: linkedItem.objectID)
self.viewModel.removeLibraryItem(dataService: self.viewModel.services.dataService, objectID: linkedItem.objectID)
messageText = "Link Removed"
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)

View file

@ -0,0 +1,49 @@
#if os(iOS)
import AppIntents
import Services
import SwiftUI
@available(iOS 16.0, *)
public struct OmnivoreAppShorcuts: AppShortcutsProvider {
@AppShortcutsBuilder public static var appShortcuts: [AppShortcut] {
AppShortcut(intent: SaveToOmnivoreIntent(), phrases: ["Save URL to \(.applicationName)"])
}
}
//
// @available(iOS 16.0, *)
// struct ExportAllTransactionsIntent: AppIntent {
// static var title: LocalizedStringResource = "Export all transactions"
//
// static var description =
// IntentDescription("Exports your transaction history as CSV data.")
// }
@available(iOS 16.0, *)
struct SaveToOmnivoreIntent: AppIntent {
static var title: LocalizedStringResource = "Save to Omnivore"
static var description: LocalizedStringResource = "Save a URL to your Omnivore library"
static var parameterSummary: some ParameterSummary {
Summary("Save \(\.$link) to your Omnivore library.")
}
@Parameter(title: "link")
var link: URL
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue {
do {
let services = Services()
let requestId = UUID().uuidString.lowercased()
_ = try await services.dataService.saveURL(id: requestId, url: link.absoluteString)
return .result(dialog: "Link saved to Omnivore")
} catch {
print("error saving URL: ", error)
}
return .result(dialog: "Error saving link")
}
}
#endif

View file

@ -0,0 +1,40 @@
#if os(iOS)
import Models
import PSPDFKit
import PSPDFKitUI
import SwiftUI
import Utils
import WebKit
struct PDFSettingsView: UIViewControllerRepresentable {
@Environment(\.presentationMode) var presentationMode
let pdfViewController: PDFViewController?
func makeCoordinator() -> PDFSettingsViewCoordinator {
PDFSettingsViewCoordinator(self)
}
func makeUIViewController(context _: Context) -> some UIViewController {
let settingsViewcontroller = PSPDFKitUI.PDFSettingsViewController()
settingsViewcontroller.pdfViewController = pdfViewController
let nav = UINavigationController(rootViewController: settingsViewcontroller)
return nav
}
func updateUIViewController(_: UIViewControllerType, context _: Context) {}
}
class PDFSettingsViewCoordinator: NSObject, UINavigationControllerDelegate {
var parent: PDFSettingsView
init(_ parent: PDFSettingsView) {
self.parent = parent
}
@objc func dismiss() {
parent.presentationMode.wrappedValue.dismiss()
}
}
#endif

View file

@ -51,6 +51,9 @@ import Utils
@State private var annotation = ""
@State private var addNoteHighlight: Highlight?
@State private var showAnnotationModal = false
@State private var showSettingsModal = false
@Environment(\.dismiss) private var dismiss
init(viewModel: PDFViewerViewModel) {
self.viewModel = viewModel
@ -103,19 +106,13 @@ import Utils
guard pdfStateObject.controllerNeedsConfig else { return }
coordinator.setController(controller: controller, dataService: dataService)
// Disable the Document Editor
controller.navigationItem.setRightBarButtonItems(
[controller.thumbnailsButtonItem],
for: .thumbnails,
animated: false
)
let barButtonItems = [
UIBarButtonItem(
image: UIImage(systemName: "textformat"),
style: .plain,
target: controller.settingsButtonItem.target,
action: controller.settingsButtonItem.action
target: coordinator,
action: #selector(PDFViewCoordinator.displaySettingsSheet)
),
UIBarButtonItem(
image: UIImage(systemName: "book"),
@ -137,6 +134,15 @@ import Utils
)
]
let leftButtonItems = [
UIBarButtonItem(
image: UIImage(named: "chevron-right", in: Bundle(url: ViewsPackage.bundleURL), with: nil),
style: .plain,
target: coordinator,
action: #selector(PDFViewCoordinator.pop)
)
]
document.areAnnotationsEnabled = true
coordinator.viewer = self
@ -146,6 +152,7 @@ import Utils
controller.setPageIndex(pageIndex, animated: false)
}
controller.navigationItem.setLeftBarButtonItems(leftButtonItems, for: .document, animated: false)
controller.navigationItem.setRightBarButtonItems(barButtonItems, for: .document, animated: false)
pdfStateObject.controllerNeedsConfig = false
}
@ -171,7 +178,7 @@ import Utils
if let highlight = annotations?.compactMap({ $0 as? HighlightAnnotation }).first,
let customHighlight = highlight.customData?["omnivoreHighlight"] as? [String: String],
let highlightID = customHighlight["id"]?.lowercased(),
let selectedHighlight = viewModel.findHighlight(highlightID: highlightID)
let selectedHighlight = viewModel.findHighlight(dataService: dataService, highlightID: highlightID)
{
addNoteHighlight = selectedHighlight
annotation = selectedHighlight.annotation ?? ""
@ -215,6 +222,12 @@ import Utils
}
.navigationViewStyle(StackNavigationViewStyle())
}
.formSheet(isPresented: $showSettingsModal, modalSize: CGSize(width: 400, height: 475)) {
NavigationView {
PDFSettingsView(pdfViewController: coordinator.controller)
}
.navigationViewStyle(StackNavigationViewStyle())
}
.fullScreenCover(isPresented: $readerView, content: {
PDFReaderViewController(document: document)
})
@ -263,7 +276,7 @@ import Utils
var subscriptions = Set<AnyCancellable>()
public var viewer: PDFViewer?
var controller: PDFViewController?
public var controller: PDFViewController?
init(document: Document, viewModel: PDFViewerViewModel) {
self.document = document
@ -446,12 +459,24 @@ import Utils
}
}
@objc public func pop() {
if let viewer = self.viewer {
viewer.dismiss()
}
}
@objc public func toggleReaderView() {
if let viewer = self.viewer {
viewer.readerView = !viewer.readerView
}
}
@objc public func displaySettingsSheet() {
if let viewer = self.viewer {
viewer.showSettingsModal = true
}
}
@objc public func toggleNotebookView() {
if let viewer = self.viewer {
viewer.showNotebookView = !viewer.showNotebookView

View file

@ -12,9 +12,11 @@ final class PDFViewerViewModel: ObservableObject {
var snackbarMessage: String?
let pdfItem: PDFItem
var highlights: [Highlight]
init(pdfItem: PDFItem) {
self.pdfItem = pdfItem
self.highlights = pdfItem.highlights
}
func snackbar(message: String) {
@ -22,8 +24,9 @@ final class PDFViewerViewModel: ObservableObject {
showSnackbar = true
}
func findHighlight(highlightID: String) -> Highlight? {
pdfItem.highlights.first { $0.id == highlightID }
func findHighlight(dataService: DataService, highlightID: String) -> Highlight? {
let libraryItem = LibraryItem.lookup(byID: pdfItem.itemID, inContext: dataService.viewContext)
return libraryItem?.highlights.asArray(of: Highlight.self).first { $0.id == highlightID }
}
func loadHighlightPatches(completion onComplete: @escaping ([String]) -> Void) {

View file

@ -35,15 +35,6 @@ enum PrimaryContentCategory: Identifiable, Hashable, Equatable {
Label { Text(title) } icon: { image.renderingMode(.template) }
}
@MainActor @ViewBuilder var destinationView: some View {
switch self {
case .feed:
LibraryListView()
case .profile:
ProfileView()
}
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}

View file

@ -0,0 +1,44 @@
import Foundation
import SwiftUI
struct AudioToolbarButton: View {
@State private var drawingHeight = true
var animation: Animation {
.linear(duration: 0.5).repeatForever()
}
var body: some View {
ZStack {
Circle()
.stroke(Color.black)
.frame(width: 21, height: 21)
// HStack(spacing: 1) {
// bar(low: 0.4)
// .animation(animation.speed(1.5), value: drawingHeight)
// bar(low: 0.3)
// .animation(animation.speed(1.2), value: drawingHeight)
// bar(low: 0.5)
// .animation(animation.speed(1.0), value: drawingHeight)
// bar(low: 0.3)
// .animation(animation.speed(1.7), value: drawingHeight)
// // bar(low: 0.5)
// // .animation(animation.speed(1.0), value: drawingHeight)
// }
// .frame(width: 20)
// .onAppear {
// drawingHeight.toggle()
// }
}
}
func bar(low: CGFloat = 0.0, high: CGFloat = 1.0) -> some View {
RoundedRectangle(cornerRadius: 3)
.fill(Color.black)
.frame(width: 1)
.frame(height: (drawingHeight ? high : low) * 15)
.frame(height: 15, alignment: .bottom)
}
}

View file

@ -6,7 +6,7 @@
import Views
// swiftlint:disable file_length type_body_length
public struct ExpandedPlayer: View {
public struct ExpandedAudioPlayer: View {
@EnvironmentObject var audioController: AudioController
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@Environment(\.dismiss) private var dismiss
@ -416,14 +416,9 @@
NavigationView {
innerBody
.background(Color.themeDisabledBG)
.navigationTitle(LocalText.textToSpeechGeneric)
.navigationTitle(audioController.itemAudioProperties?.title ?? LocalText.textToSpeechGeneric)
.navigationBarItems(trailing: Button(action: { dismiss() }, label: { Text("Hide") }))
.navigationBarTitleDisplayMode(NavigationBarItem.TitleDisplayMode.inline)
// .searchable(text: $queryString, placement: .navigationBarDrawer(displayMode: .always)) {
// // print("searching: ", queryString)
// Text("content")
// }
// }
}
}

View file

@ -20,150 +20,6 @@
self.presentingView = AnyView(presentingView)
}
var playPauseButtonImage: String {
switch audioController.state {
case .playing:
return "pause.circle"
case .paused:
return "play.circle"
case .reachedEnd:
return "gobackward"
default:
return ""
}
}
var playPauseButtonItem: some View {
if audioController.playbackError {
return AnyView(Color.clear)
}
if let itemID = audioController.itemAudioProperties?.itemID, audioController.isLoadingItem(itemID: itemID) {
return AnyView(ProgressView())
} else {
return AnyView(Button(
action: {
switch audioController.state {
case .playing:
audioController.pause()
case .paused:
audioController.unpause()
case .reachedEnd:
audioController.seek(to: 0.0)
audioController.unpause()
default:
break
}
},
label: {
Image(systemName: playPauseButtonImage)
.resizable(resizingMode: Image.ResizingMode.stretch)
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.light))
}
))
}
}
var stopButton: some View {
Button(
action: {
audioController.stop()
},
label: {
ZStack {
Circle()
.foregroundColor(Color(hex: "#3D3D3D"))
Image(systemName: "xmark")
.resizable(resizingMode: Image.ResizingMode.stretch)
.foregroundColor(Color(hex: "#D9D9D9"))
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.medium))
.frame(width: 14, height: 14)
}
}
)
.background(Color.clear)
.buttonStyle(PlainButtonStyle())
}
func artwork(_ itemAudioProperties: LinkedItemAudioProperties, forDimensions dim: Double) -> some View {
if let imageURL = itemAudioProperties.imageURL {
return AnyView(AsyncImage(url: imageURL) { phase in
if let image = phase.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: dim, height: dim)
.cornerRadius(6)
} else if phase.error != nil {
defaultArtwork(forDimensions: dim)
} else {
Color.appButtonBackground
.frame(width: dim, height: dim)
.cornerRadius(6)
}
})
}
return AnyView(defaultArtwork(forDimensions: dim))
}
func defaultArtwork(forDimensions dim: Double) -> some View {
ZStack(alignment: .center) {
Color.appButtonBackground
.frame(width: dim, height: dim)
.cornerRadius(6)
Image.headphones
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: dim / 2, height: dim / 2)
}
}
func playerContent(_ itemAudioProperties: LinkedItemAudioProperties) -> some View {
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 15) {
if audioController.playbackError {
Text("There was an error playing back your audio.").foregroundColor(Color.red).font(.footnote)
Spacer(minLength: 0)
} else {
artwork(itemAudioProperties, forDimensions: 50)
Text(itemAudioProperties.title)
.font(Font.system(size: 17, weight: .medium))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
.foregroundColor(.appGrayTextContrast)
.frame(maxHeight: 40, alignment: .leading)
Spacer(minLength: 0)
playPauseButtonItem
.frame(width: 40, height: 40)
.foregroundColor(.themeAudioPlayerGray)
}
stopButton
.frame(width: 40, height: 40)
.foregroundColor(.themeAudioPlayerGray)
}
.padding(16)
.frame(maxHeight: .infinity)
}
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.background(
Color.systemBackground
.shadow(color: .gray.opacity(0.33), radius: 8, x: 0, y: 4)
.mask(Rectangle().padding(.top, -20))
)
.onTapGesture {
withAnimation(.easeIn(duration: 0.08)) { expanded = true }
}.fullScreenCover(isPresented: $expanded) {
ExpandedPlayer()
}
.offset(y: expanded ? 110 : 0)
}
public var body: some View {
ZStack(alignment: .center) {
presentingView
@ -174,8 +30,7 @@
VStack {
Spacer(minLength: 0)
playerContent(itemAudioProperties)
.frame(maxHeight: expanded ? 0 : 110)
MiniPlayerViewer(itemAudioProperties: itemAudioProperties)
}
}
}

View file

@ -0,0 +1,157 @@
#if os(iOS)
import Foundation
import Models
import Services
import SwiftUI
import Utils
import Views
public struct MiniPlayerViewer: View {
@EnvironmentObject var audioController: AudioController
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@State var expanded = true
let itemAudioProperties: LinkedItemAudioProperties
var playPauseButtonImage: String {
switch audioController.state {
case .playing:
return "pause.circle"
case .paused:
return "play.circle"
case .reachedEnd:
return "gobackward"
default:
return ""
}
}
var playPauseButtonItem: some View {
if audioController.playbackError {
return AnyView(Color.clear)
}
if let itemID = audioController.itemAudioProperties?.itemID, audioController.isLoadingItem(itemID: itemID) {
return AnyView(ProgressView())
} else {
return AnyView(Button(
action: {
switch audioController.state {
case .playing:
audioController.pause()
case .paused:
audioController.unpause()
case .reachedEnd:
audioController.seek(to: 0.0)
audioController.unpause()
default:
break
}
},
label: {
Image(systemName: playPauseButtonImage)
.resizable(resizingMode: Image.ResizingMode.stretch)
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.light))
}
))
}
}
var stopButton: some View {
Button(
action: {
audioController.stop()
},
label: {
ZStack {
Circle()
.foregroundColor(Color(hex: "#3D3D3D"))
Image(systemName: "xmark")
.resizable(resizingMode: Image.ResizingMode.stretch)
.foregroundColor(Color(hex: "#D9D9D9"))
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.medium))
.frame(width: 14, height: 14)
}
}
)
.background(Color.clear)
.buttonStyle(PlainButtonStyle())
}
func artwork(_ itemAudioProperties: LinkedItemAudioProperties, forDimensions dim: Double) -> some View {
if let imageURL = itemAudioProperties.imageURL {
return AnyView(AsyncImage(url: imageURL) { phase in
if let image = phase.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: dim, height: dim)
.cornerRadius(6)
} else if phase.error != nil {
defaultArtwork(forDimensions: dim)
} else {
Color.appButtonBackground
.frame(width: dim, height: dim)
.cornerRadius(6)
}
})
}
return AnyView(defaultArtwork(forDimensions: dim))
}
func defaultArtwork(forDimensions dim: Double) -> some View {
ZStack(alignment: .center) {
Color.appButtonBackground
.frame(width: dim, height: dim)
.cornerRadius(6)
Image.headphones
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: dim / 2, height: dim / 2)
}
}
public var body: some View {
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 15) {
if audioController.playbackError {
Text("There was an error playing back your audio.").foregroundColor(Color.red).font(.footnote)
Spacer(minLength: 0)
} else {
artwork(itemAudioProperties, forDimensions: 50)
Text(itemAudioProperties.title)
.font(Font.system(size: 17, weight: .medium))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
.foregroundColor(.appGrayTextContrast)
.frame(maxHeight: 40, alignment: .leading)
Spacer(minLength: 0)
playPauseButtonItem
.frame(width: 40, height: 40)
.foregroundColor(.themeAudioPlayerGray)
}
stopButton
.frame(width: 40, height: 40)
.foregroundColor(.themeAudioPlayerGray)
}
.padding(.vertical, 5)
.padding(.horizontal, 15)
.frame(maxHeight: .infinity)
}
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.background(
Color.themeTabBarColor
)
.frame(height: 60)
}
}
#endif

View file

@ -1,3 +1,5 @@
// swiftlint:disable line_length
import CoreData
import Models
import Services
@ -5,155 +7,122 @@ import SwiftUI
import Utils
import Views
@MainActor final class BriefingViewModel: ObservableObject {
@Published var item: LinkedItem?
let BRIEFING = """
## Inbox
func loadItem(articleId: String, dataService: DataService) async {
let item = await dataService.viewContext.perform {
LinkedItem.lookup(byID: articleId, inContext: dataService.viewContext)
// dataService.viewContext.object(with: linkedItemObjectID) as? LinkedItem
}
- [ ] Who owns Real Madrid? How 'socios' remain in control of Los Blancos with president Florentino Perez at the helm | Sporting News Singapore | Kyle Bonn
if let item = item {
self.item = item
}
}
## Subscriptions
// func handleArchiveAction(dataService: DataService) {
// guard let objectID = item?.objectID ?? pdfItem?.objectID else { return }
// dataService.archiveLink(objectID: objectID, archived: !isItemArchived)
// showInSnackbar(!isItemArchived ? "Link archived" : "Link moved to Inbox")
// }
//
// func handleDeleteAction(dataService: DataService) {
// guard let objectID = item?.objectID ?? pdfItem?.objectID else { return }
// showInSnackbar("Link removed")
// dataService.removeLink(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
// )
// }
- [ ] Astral Codex Ten
- [ ] In Continued Defense Of Effective Altruism
- [ ] God Help Us, Let's Try To Understand AI Monosemanticity
- [ ] Open Thread 304
- [ ] The Pragmatic Engineer: Holiday Season Gift Ideas for Techies
- [ ] Golang Weekly: 🥶 Like me, Go 1.22 is now frozen
- [ ] Linus Ekenstam at Inside My Head: Enhance/upscale anything - Magnific AI
- [ ] Not Boring: Narrative Tug-of-War
- [ ] Colin Wright: One Sentence News / November 28, 2023
- [ ] Lenny's Newsletter
- [ ] Lessons from going freemium: a decision that broke our business
- [ ] Billion dollar failures, and billion dollar success | Tom Conrad (Quibi, Pandora, Pets.com, Snap, Zero)
- [ ] Aeon+Psyche Daily
- [ ] When bereavement turns to activism
- [ ] Why training wont solve implicit bias
- [ ] Etgar Keret from Alphabet Soup: Alternative Fun Facts: Friendship Baby
- [ ] Huddle Up: How Populous Became The Top Sports Architecture Firm In The World
- [ ] Write With AI: How To (Productively) Edit Your Writing With ChatGPT
// private func trackReadEvent() {
// guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return }
// guard let slug = item?.unwrappedSlug ?? pdfItem?.slug else { return }
// guard let originalArticleURL = item?.unwrappedPageURLString ?? pdfItem?.originalArticleURL else { return }
//
// EventTracker.track(
// .linkRead(
// linkID: itemID,
// slug: slug,
// originalArticleURL: originalArticleURL
// )
// )
// }
## Read
- [ ] Big brands keep dropping X over antisemitism; $75M loss, report estimates | Ars Technica
- [ ] My Hero [Comic]Geeks are Sexy Technology News
- [ ] 'Project DNA': How Japan's J1 League became a 'flair factory' for Europe's top clubs
- [ ] One Sentence News / November 27, 2023
- [ ] Only 1 day left to claim your 30% discount on annual membership!
## Highlights
- [ ] Big brands keep dropping X over antisemitism; $75M loss, report estimates | Ars Technica
- [ ] Musk [responded](https://twitter.com/elonmusk/status/1728164110137725260) to an X user who [said](https://x.com/JohnnaCrider1/status/1728155588993970484?s=20) that some users were "mad" over lower ad revenue-sharing, saying there was "not much we can do if advertisers boycott or reduce spend on our platform."
- [ ] 'Project DNA': How Japan's J1 League became a 'flair factory' for Europe's top clubs
- [ ] communities are being bound to clubs to create a nascent cultural heritage.
- [ ] The FA and the J1 League brought prefectures, associations, clubs, coaches, universities and schools together to work for a common good. There is a Japanese concept ikigai which describes the sourcing of meaning or fulfilment from a purpose. The national team was the ikigai.
- [ ] That aim is governed by regulation. It is now mandatory for every club to run their own academy with at least Under-15 and Under-18 teams. There are limits on the number of foreigners on each squad. The first team starting XI must contain at least two homegrown players and one Under-21 player.
- [ ] Clubs are also rewarded for developing and playing academy graduates. The earlier the player moves, the more development they get in a different footballing culture and the more space it allows for another young player to replace them.
- [ ] But in 2016, the Japanese FA created Project DNA, an initiative that aimed to adjust and amend existing training methods to produce more rounded footballers. They sent coaches to European clubs, including those in the Premier League. They studied and they cherry-picked and they vowed that insularity should never rule because Japan would never have enough alone.
- [ ] Japans most successful recent exports (Mitoma at Brighton, Daichi Kamada at Eintracht Frankfurt, Takefusa Kubo at Real Sociedad) are fun, unpredictable, exciting attacking players.
## Archived
- [ ] Why Hunter Biden Asked to Testify Publicly in Impeachment Bid | TIME
"""
struct BriefingSection {
let title: String
var items = [String]()
}
struct BriefingView: View {
@EnvironmentObject var authenticator: Authenticator
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
@MainActor final class BriefingViewModel: ObservableObject {
@Published var sections = [BriefingSection]()
static let navBarHeight = 50.0
let articleId: String
func load() async {
var currentSection: BriefingSection?
var sections = [BriefingSection]()
let lines = BRIEFING.components(separatedBy: .newlines)
@StateObject private var viewModel = BriefingViewModel()
@State private var showFontSizePopover = false
@State private var showTitleEdit = false
@State private var navBarVisibilityRatio = 1.0
@State private var showDeleteConfirmation = false
var removeLinkToolbarItem: some View {
Button(
action: { print("delete item action") },
label: {
Image(systemName: "trash")
lines.forEach { line in
if line.starts(with: "## ") {
if let currentSection = currentSection {
sections.append(currentSection)
}
let title = line.replacingOccurrences(of: "## ", with: "")
currentSection = BriefingSection(title: title)
} else if line.isEmpty {
return
} else {
currentSection?.items.append(line)
}
)
}
if let currentSection = currentSection {
sections.append(currentSection)
}
self.sections = sections
}
}
@MainActor
struct BriefingView: View {
@StateObject private var viewModel = BriefingViewModel()
var body: some View {
ZStack { // Using ZStack so .task can be used on if/else body
if let item = viewModel.item {
WebReaderContainerView(item: item, pop: {})
}
}
.task {
await viewModel.loadItem(articleId: articleId, dataService: dataService)
NotificationCenter.default.post(Notification(name: Notification.Name("ReaderSettingsChanged")))
// static var readerSettingsChangedPublisher: NotificationCenter.Publisher {
// NotificationCenter.default.publisher(for: ReaderSettingsChanged)
// }
}
#if os(iOS)
.navigationBarHidden(true)
#endif
}
var navBar: some View {
HStack(alignment: .center) {
Spacer()
Button(
action: { showFontSizePopover.toggle() },
label: {
Image(systemName: "textformat.size")
.font(.appTitleTwo)
}
)
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
Menu(
content: {
Group {
Button(
action: { showTitleEdit = true },
label: { Label("Edit Info", systemImage: "info.circle") }
)
// Button(
// action: { viewModel.handleArchiveAction(dataService: dataService) },
// label: {
// Label(
// viewModel.isItemArchived ? "Unarchive" : "Archive",
// systemImage: viewModel.isItemArchived ? "tray.and.arrow.down.fill" : "archivebox"
// )
// }
// )
Button(
action: { showDeleteConfirmation = true },
label: { Label("Delete", systemImage: "trash") }
)
List {
ForEach(viewModel.sections, id: \.title) { section in
Section(section.title) {
ForEach(section.items, id: \.self) { item in
if item.starts(with: "- [ ] ") {
let idx = item.index(item.startIndex, offsetBy: 6)
HStack {
Image(systemName: "square")
Text(item.suffix(from: idx))
.lineLimit(2)
}
} else if item.starts(with: " - [ ] ") {
let idx = item.index(item.startIndex, offsetBy: 8)
HStack {
Text(" ")
Image(systemName: "square")
Text(item.suffix(from: idx))
.lineLimit(2)
}
}
}
},
label: {
Image(systemName: "ellipsis")
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
}
)
}
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
.opacity(navBarVisibilityRatio)
.background(Color.systemBackground)
.onTapGesture {
showFontSizePopover = false
}
.alert("Are you sure?", isPresented: $showDeleteConfirmation) {
// Button("Remove Link", role: .destructive) {
// viewModel.handleDeleteAction(dataService: dataService)
// }
Button(LocalText.cancelGeneric, role: .cancel, action: {})
}
.sheet(isPresented: $showTitleEdit) {
if let item = viewModel.item {
LinkedItemMetadataEditView(item: item)
}
}
.listStyle(.plain)
.task {
await viewModel.load()
}
}
}

View file

@ -25,7 +25,7 @@ struct NoteItemParams: Identifiable {
@Published var highlightItems = [HighlightListItemParams]()
func load(itemObjectID: NSManagedObjectID, dataService: DataService) {
if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? LinkedItem {
if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? Models.LibraryItem {
loadHighlights(item: linkedItem)
}
}
@ -53,7 +53,7 @@ struct NoteItemParams: Identifiable {
let highlightId = UUID().uuidString.lowercased()
let shortId = NanoID.generate(alphabet: NanoID.Alphabet.urlSafe.rawValue, size: 8)
if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? LinkedItem {
if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? Models.LibraryItem {
noteItem = NoteItemParams(highlightID: highlightId, annotation: annotation)
let highlight = dataService.createNote(shortId: shortId,
highlightID: highlightId,
@ -105,7 +105,7 @@ struct NoteItemParams: Identifiable {
highlightItems.map { highlightAsMarkdown(item: $0) }.lazy.joined(separator: "\n\n")
}
private func loadHighlights(item: LinkedItem) {
private func loadHighlights(item: Models.LibraryItem) {
let unsortedHighlights = item.highlights.asArray(of: Highlight.self)
.filter { $0.type == "HIGHLIGHT" && $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue }

View file

@ -1,13 +1,14 @@
import Models
import Services
import SwiftUI
import Transmission
import Views
struct MacFeedCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
let item: LinkedItem
let item: Models.LibraryItem
@ObservedObject var viewModel: HomeFeedViewModel
@ -31,22 +32,32 @@ struct FeedCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
let item: LinkedItem
let item: Models.LibraryItem
let isInMultiSelectMode: Bool
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
ZStack {
LibraryItemCard(item: item, viewer: dataService.currentViewer)
NavigationLink(destination: LinkItemDetailView(
linkedItemObjectID: item.objectID,
isPDF: item.isPDF
), label: {
EmptyView()
}).opacity(0)
PresentationLink(
transition: PresentationLinkTransition.slide(
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
options:
PresentationLinkTransition.Options(
modalPresentationCapturesStatusBarAppearance: true
))),
destination: {
LinkItemDetailView(
linkedItemObjectID: item.objectID,
isPDF: item.isPDF
)
}, label: {
EmptyView()
}
)
}
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
.task {
await viewModel.itemAppeared(item: item, dataService: dataService)
}
}
}
@ -57,7 +68,7 @@ struct GridCardNavigationLink: View {
@State private var scale = 1.0
let item: LinkedItem
let item: Models.LibraryItem
let actionHandler: (GridCardAction) -> Void
@Binding var isContextMenuOpen: Bool
@ -65,14 +76,24 @@ struct GridCardNavigationLink: View {
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
NavigationLink(destination: LinkItemDetailView(
linkedItemObjectID: item.objectID,
isPDF: item.isPDF
)) {
GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler)
}
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
PresentationLink(
transition: PresentationLinkTransition.slide(
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
options:
PresentationLinkTransition.Options(
modalPresentationCapturesStatusBarAppearance: true
))),
destination: {
LinkItemDetailView(
linkedItemObjectID: item.objectID,
isPDF: item.isPDF
)
}, label: {
GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler)
}
)
.task {
await viewModel.itemAppeared(item: item, dataService: dataService)
}
.aspectRatio(1.0, contentMode: .fill)
.background(

View file

@ -8,24 +8,36 @@
import Models
import Services
import SwiftUI
import Transmission
import Views
struct LibraryFeatureCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
let item: LinkedItem
let item: Models.LibraryItem
@ObservedObject var viewModel: HomeFeedViewModel
@State var showFeatureActions = false
var body: some View {
NavigationLink(destination: LinkItemDetailView(
linkedItemObjectID: item.objectID,
isPDF: item.isPDF
)) {
LibraryFeatureCard(item: item, viewer: dataService.currentViewer)
}
PresentationLink(
transition: PresentationLinkTransition.slide(
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
options:
PresentationLinkTransition.Options(
modalPresentationCapturesStatusBarAppearance: true
))),
destination: {
LinkItemDetailView(
linkedItemObjectID: item.objectID,
isPDF: item.isPDF
)
.background(ThemeManager.currentBgColor)
}, label: {
LibraryFeatureCard(item: item, viewer: dataService.currentViewer)
}
)
.confirmationDialog("", isPresented: $showFeatureActions) {
if FeaturedItemFilter(rawValue: viewModel.featureFilter) == .pinned {
Button("Unpin", action: {
@ -39,7 +51,7 @@ struct LibraryFeatureCardNavigationLink: View {
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: true)
})
Button("Remove", action: {
viewModel.removeLink(dataService: dataService, objectID: item.objectID)
viewModel.removeLibraryItem(dataService: dataService, objectID: item.objectID)
})
if FeaturedItemFilter(rawValue: viewModel.featureFilter) != .pinned {
Button("Mark Read", action: {

View file

@ -0,0 +1,260 @@
import Foundation
import CoreData
import Models
import Services
import SwiftUI
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 }
private var fetchedResultsController: NSFetchedResultsController<Models.LibraryItem>?
var cursor: String?
// These are used to make sure we handle search result
// responses in the right order
var searchIdx = 0
var receivedIdx = 0
var syncCursor: String?
func setItems(_: NSManagedObjectContext, _ items: [Models.LibraryItem]) {
self.items = items
}
func loadCurrentViewer(dataService: DataService) async {
// Cache the viewer
if dataService.currentViewer == nil {
_ = try? await dataService.fetchViewer()
}
}
func loadLabels(dataService: DataService) async {
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
fetchRequest.fetchLimit = 1
if (try? dataService.viewContext.count(for: fetchRequest)) == 0 {
_ = try? await dataService.labels()
}
}
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 {
let thisSearchIdx = searchIdx
searchIdx += 1
if thisSearchIdx > 0, thisSearchIdx <= receivedIdx {
return
}
let queryResult = try? await dataService.loadLinkedItems(
limit: 10,
searchQuery: searchQuery(filterState),
cursor: isRefresh ? nil : cursor
)
if let appliedFilter = filterState.appliedFilter, let queryResult = queryResult {
let newItems: [Models.LibraryItem] = {
var itemObjects = [Models.LibraryItem]()
dataService.viewContext.performAndWait {
itemObjects = queryResult.itemIDs.compactMap { dataService.viewContext.object(with: $0) as? Models.LibraryItem }
}
return itemObjects
}()
if filterState.searchTerm.replacingOccurrences(of: " ", with: "").isEmpty, appliedFilter.allowLocalFetch {
updateFetchController(dataService: dataService, filterState: filterState)
} else {
// Don't use FRC for searching. Use server results directly.
if fetchedResultsController != nil {
fetchedResultsController = nil
setItems(dataService.viewContext, [])
}
setItems(dataService.viewContext, isRefresh ? newItems : items + newItems)
}
receivedIdx = thisSearchIdx
cursor = queryResult.cursor
if let username = dataService.currentViewer?.username {
await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username)
}
} else {
updateFetchController(dataService: dataService, filterState: filterState)
}
}
func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
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()
}
if let appliedFilter = filterState.appliedFilter {
let shouldRemoteSearch = items.count < 1 || isRefresh && appliedFilter.shouldRemoteSearch
if shouldRemoteSearch {
await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
} else {
updateFetchController(dataService: dataService, filterState: filterState)
}
}
}
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
if let appliedFilter = filterState.appliedFilter, appliedFilter.shouldRemoteSearch {
await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
}
}
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)) ?? []
}
private func fetchRequest(_ filterState: FetcherFilterState) -> NSFetchRequest<Models.LibraryItem> {
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
var subPredicates = [NSPredicate]()
let folderPredicate = NSPredicate(
format: "%K == %@", #keyPath(Models.LibraryItem.folder), filterState.folder
)
subPredicates.append(folderPredicate)
if let predicate = filterState.appliedFilter?.predicate {
subPredicates.append(predicate)
}
if !filterState.selectedLabels.isEmpty {
var labelSubPredicates = [NSPredicate]()
for label in filterState.selectedLabels {
labelSubPredicates.append(
NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count > 0")
)
}
subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates))
}
if !filterState.negatedLabels.isEmpty {
var labelSubPredicates = [NSPredicate]()
for label in filterState.negatedLabels {
labelSubPredicates.append(
NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count == 0")
)
}
subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates))
}
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subPredicates)
fetchRequest.sortDescriptors = (LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest).sortDescriptors
return fetchRequest
}
private func updateFetchController(dataService: DataService, filterState: FetcherFilterState) {
fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest(filterState),
managedObjectContext: dataService.viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)
guard let fetchedResultsController = fetchedResultsController else {
return
}
fetchedResultsController.delegate = self
try? fetchedResultsController.performFetch()
setItems(dataService.viewContext, fetchedResultsController.fetchedObjects ?? [])
}
private func searchQuery(_ filterState: FetcherFilterState) -> String {
let sort = LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest
var query = sort.queryString
if let queryString = filterState.appliedFilter?.filter {
query = "\(queryString) \(sort.queryString)"
}
if !filterState.searchTerm.isEmpty {
query.append(" \(filterState.searchTerm)")
}
if !filterState.selectedLabels.isEmpty {
query.append(" label:")
query.append(filterState.selectedLabels.compactMap { label in
if let name = label.name {
return "\"\(name)\""
}
return nil
}.joined(separator: ","))
}
if !filterState.negatedLabels.isEmpty {
query.append(" !label:")
query.append(filterState.negatedLabels.compactMap { label in
if let name = label.name {
return "\"\(name)\""
}
return nil
}.joined(separator: ","))
}
query.append(" use:folders")
print("QUERY: `\(query)`")
return query
}
}
extension LibraryItemFetcher: NSFetchedResultsControllerDelegate {
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
setItems(controller.managedObjectContext, controller.fetchedObjects as? [Models.LibraryItem] ?? [])
}
}

View file

@ -0,0 +1,17 @@
import Foundation
import Models
import Services
import SwiftUI
import Utils
@MainActor
struct FetcherFilterState {
let folder: String
let searchTerm: String
let selectedLabels: [LinkedItemLabel]
let negatedLabels: [LinkedItemLabel]
let appliedSort: String
let appliedFilter: InternalFilter?
}

View file

@ -0,0 +1,121 @@
// import Introspect
// import Models
// import Services
// import SwiftUI
// import Views
//
// @MainActor final class FilterSelectorViewModel: NSObject, ObservableObject {
// @Published var isLoading = false
// @Published var errorMessage: String = ""
// @Published var showErrorMessage: Bool = false
//
// func error(_ msg: String) {
// errorMessage = msg
// showErrorMessage = true
// isLoading = false
// }
// }
//
// struct FilterSelectorView: View {
// @ObservedObject var viewModel: HomeFeedViewModel
// @ObservedObject var filterViewModel = FilterByLabelsViewModel()
// @EnvironmentObject var dataService: DataService
// @Environment(\.dismiss) private var dismiss
//
// @State var showLabelsSheet = false
//
// init(viewModel: HomeFeedViewModel) {
// self.viewModel = viewModel
// }
//
// var body: some View {
// Group {
// #if os(iOS)
// List {
// innerBody
// }
// .listStyle(.grouped)
// #elseif os(macOS)
// List {
// innerBody
// }
// .listStyle(.plain)
// #endif
// }
// #if os(iOS)
// .navigationBarTitle("Library")
// .navigationBarTitleDisplayMode(.inline)
// .navigationBarItems(trailing: doneButton)
// #endif
// }
//
// private var innerBody: some View {
// Group {
// Section {
// ForEach(LinkedItemFilter.allCases, id: \.self) { filter in
// HStack {
// Text(filter.displayName)
// .foregroundColor(filterState.appliedFilter == filter.rawValue ? Color.blue : Color.appTextDefault)
// Spacer()
// if filterState.appliedFilter == filter.rawValue {
// Image(systemName: "checkmark")
// .foregroundColor(Color.blue)
// }
// }
// .contentShape(Rectangle())
// .onTapGesture {
// filterState.appliedFilter = filter.rawValue
// }
// }
// }
//
// Section("Labels") {
// Button(
// action: {
// showLabelsSheet = true
// },
// label: {
// HStack {
// Text("Select Labels (\(filterState.selectedLabels.count))")
// Spacer()
// Image(systemName: "chevron.right")
// }
// }
// )
// }
// }
// .sheet(isPresented: $showLabelsSheet) {
// FilterByLabelsView(
// initiallySelected: filterState.selectedLabels,
// initiallyNegated: filterState.negatedLabels
// ) {
// self.filterState.selectedLabels = $0
// self.filterState.negatedLabels = $1
// }
// }
// .task {
// await filterViewModel.loadLabels(
// dataService: dataService,
// initiallySelectedLabels: filterState.selectedLabels,
// initiallyNegatedLabels: filterState.negatedLabels
// )
// }
// }
//
// func isNegated(_ label: LinkedItemLabel) -> Bool {
// filterViewModel.negatedLabels.contains(where: { $0.id == label.id })
// }
//
// func isSelected(_ label: LinkedItemLabel) -> Bool {
// filterViewModel.selectedLabels.contains(where: { $0.id == label.id })
// }
//
// var doneButton: some View {
// Button(
// action: { dismiss() },
// label: { Text("Done") }
// )
// .disabled(viewModel.isLoading)
// }
// }

View file

@ -38,6 +38,10 @@ public extension LinkedItemSort {
return LocalText.newestGeneric
case .oldest:
return LocalText.oldestGeneric
case .longest:
return LocalText.longestGeneric
case .shortest:
return LocalText.shortestGeneric
case .recentlyRead:
return LocalText.recentlyReadGeneric
case .recentlyPublished:

View file

@ -2,6 +2,7 @@ import CoreData
import Models
import Services
import SwiftUI
import Transmission
import UserNotifications
import Utils
import Views
@ -33,6 +34,7 @@ struct AnimatingCellHeight: AnimatableModifier {
@State var listTitle = ""
@State var isEditMode: EditMode = .inactive
@State var showOpenAIVoices = false
@State var showExpandedAudioPlayer = false
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@ -41,9 +43,12 @@ struct AnimatingCellHeight: AnimatableModifier {
@AppStorage(UserDefaultKey.openAIPrimerDisplayed.rawValue) var openAIPrimerDisplayed = false
@ObservedObject var viewModel: HomeFeedViewModel
@State private var selection = Set<String>()
init(viewModel: HomeFeedViewModel) {
_viewModel = ObservedObject(wrappedValue: viewModel)
}
func loadItems(isRefresh: Bool) {
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
}
@ -51,42 +56,65 @@ struct AnimatingCellHeight: AnimatableModifier {
var showFeatureCards: Bool {
viewModel.listConfig.hasFeatureCards &&
!viewModel.hideFeatureSection &&
viewModel.items.count > 0 &&
viewModel.fetcher.items.count > 0 &&
viewModel.searchTerm.isEmpty &&
viewModel.selectedLabels.isEmpty &&
viewModel.negatedLabels.isEmpty &&
viewModel.appliedFilterName == "inbox"
viewModel.appliedFilter?.name.lowercased() == "inbox"
}
var body: some View {
HomeFeedView(
listTitle: $listTitle,
isListScrolled: $isListScrolled,
prefersListLayout: $prefersListLayout,
isEditMode: $isEditMode,
selection: $selection,
viewModel: viewModel,
showFeatureCards: showFeatureCards
)
.refreshable {
loadItems(isRefresh: true)
}
.onChange(of: viewModel.searchTerm) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
loadItems(isRefresh: true)
}
.onChange(of: viewModel.selectedLabels) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.negatedLabels) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.appliedFilter) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.appliedSort) { _ in
loadItems(isRefresh: true)
ZStack {
HomeFeedView(
listTitle: $listTitle,
isListScrolled: $isListScrolled,
prefersListLayout: $prefersListLayout,
isEditMode: $isEditMode,
selection: $selection,
viewModel: viewModel,
showFeatureCards: showFeatureCards
)
.refreshable {
loadItems(isRefresh: true)
}
.onChange(of: viewModel.presentWebContainer) { _ in
if !viewModel.presentWebContainer {
viewModel.linkRequest = nil
}
}
.onChange(of: viewModel.searchTerm) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
loadItems(isRefresh: true)
}
.onChange(of: viewModel.selectedLabels) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.negatedLabels) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.appliedFilter) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.appliedSort) { _ in
loadItems(isRefresh: true)
}
if UIDevice.isIPad {
VStack(spacing: 0) {
Spacer()
if let audioProperties = audioController.itemAudioProperties {
MiniPlayerViewer(itemAudioProperties: audioProperties)
.padding(.top, 10)
.padding(.bottom, 20)
.background(Color.themeTabBarColor)
.onTapGesture {
showExpandedAudioPlayer = true
}
}
}
}
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(mode: .item(item), onSave: nil)
@ -97,6 +125,9 @@ struct AnimatingCellHeight: AnimatableModifier {
.sheet(item: $viewModel.itemForHighlightsView) { item in
NotebookView(itemObjectID: item.objectID, hasHighlightMutations: $hasHighlightMutations)
}
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
ExpandedAudioPlayer()
}
.sheet(isPresented: $showOpenAIVoices) {
OpenAIVoicesModal(audioController: audioController)
}
@ -115,7 +146,7 @@ struct AnimatingCellHeight: AnimatableModifier {
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushJSONArticle"))) { notification in
guard let jsonArticle = notification.userInfo?["article"] as? JSONArticle else { return }
guard let objectID = dataService.persist(jsonArticle: jsonArticle) else { return }
guard let linkedItem = dataService.viewContext.object(with: objectID) as? LinkedItem else { return }
guard let linkedItem = dataService.viewContext.object(with: objectID) as? Models.LibraryItem else { return }
viewModel.pushFeedItem(item: linkedItem)
viewModel.selectedItem = linkedItem
viewModel.linkIsActive = true
@ -134,6 +165,7 @@ struct AnimatingCellHeight: AnimatableModifier {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
withoutAnimation {
viewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID)
viewModel.presentWebContainer = true
}
}
}
@ -153,9 +185,10 @@ struct AnimatingCellHeight: AnimatableModifier {
}
}
.task {
if viewModel.items.isEmpty {
if viewModel.fetcher.items.isEmpty {
loadItems(isRefresh: false)
}
await viewModel.loadFilters(dataService: dataService)
}
.environment(\.editMode, self.$isEditMode)
}
@ -167,24 +200,23 @@ struct AnimatingCellHeight: AnimatableModifier {
let showDate = isListScrolled && !listTitle.isEmpty
if let title = viewModel.appliedFilter?.name {
Text(title)
.font(Font.system(size: showDate ? 10 : 18, weight: .semibold))
.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: .leading)
}
.frame(maxWidth: .infinity, alignment: .bottomLeading)
}
ToolbarItem(placement: .barTrailing) {
Button("", action: {})
.disabled(true)
.overlay {
if viewModel.isLoading, !prefersListLayout, enableGrid {
ProgressView()
}
}
if UIDevice.isIPad, viewModel.folder == "inbox" {
Button(action: { addLinkPresented = true }, label: {
Label("Add Link", systemImage: "plus")
})
}
}
ToolbarItem(placement: UIDevice.isIPhone ? .barLeading : .barTrailing) {
if enableGrid {
@ -194,8 +226,6 @@ struct AnimatingCellHeight: AnimatableModifier {
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
}
)
} else {
EmptyView()
}
}
ToolbarItem(placement: .barTrailing) {
@ -203,10 +233,6 @@ struct AnimatingCellHeight: AnimatableModifier {
action: { searchPresented = true },
label: {
Image(systemName: "magnifyingglass")
.resizable()
.frame(width: 18, height: 18)
.padding(.vertical)
.foregroundColor(.appGrayTextContrast)
}
)
}
@ -229,8 +255,6 @@ struct AnimatingCellHeight: AnimatableModifier {
Image.utilityMenu
})
.foregroundColor(.appGrayTextContrast)
} else {
EmptyView()
}
}
ToolbarItemGroup(placement: .bottomBar) {
@ -269,21 +293,22 @@ struct AnimatingCellHeight: AnimatableModifier {
var body: some View {
VStack(spacing: 0) {
if let linkRequest = viewModel.linkRequest {
NavigationLink(
destination: WebReaderLoadingContainer(requestID: linkRequest.serverID),
tag: linkRequest,
selection: $viewModel.linkRequest
) {
EmptyView()
}
PresentationLink(
transition: PresentationLinkTransition.slide(
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
options:
PresentationLinkTransition.Options(
modalPresentationCapturesStatusBarAppearance: true
))),
isPresented: $viewModel.presentWebContainer,
destination: {
WebReaderLoadingContainer(requestID: linkRequest.serverID)
.background(ThemeManager.currentBgColor)
}, label: {
EmptyView()
}
)
}
NavigationLink(
destination: LinkDestination(selectedItem: viewModel.selectedItem),
isActive: $viewModel.linkIsActive
) {
EmptyView()
}
if prefersListLayout || !enableGrid {
HomeFeedListView(
listTitle: $listTitle,
@ -295,15 +320,18 @@ struct AnimatingCellHeight: AnimatableModifier {
showFeatureCards: showFeatureCards
)
} else {
HomeFeedGridView(viewModel: viewModel, isListScrolled: $isListScrolled)
HomeFeedGridView(
viewModel: viewModel,
isListScrolled: $isListScrolled
)
}
}.sheet(isPresented: $viewModel.showLabelsSheet) {
FilterByLabelsView(
initiallySelected: viewModel.selectedLabels,
initiallyNegated: viewModel.negatedLabels
) {
self.viewModel.selectedLabels = $0
self.viewModel.negatedLabels = $1
viewModel.selectedLabels = $0
viewModel.negatedLabels = $1
}
}
.popup(isPresented: $viewModel.showSnackbar) {
@ -347,6 +375,27 @@ struct AnimatingCellHeight: AnimatableModifier {
let showFeatureCards: Bool
@State var shouldScrollToTop = false
@State var topItem: Models.LibraryItem?
@ObservedObject var networkMonitor = NetworkMonitor()
// init(listTitle: Binding<String>,
// isListScrolled: Binding<Bool>,
// prefersListLayout: Binding<Bool>,
// isEditMode: Binding<EditMode>,
// selection: Binding<Set<String>>,
// viewModel: HomeFeedViewModel,
// showFeatureCards: Bool)
// {
// self._listTitle = listTitle
// self._isListScrolled = isListScrolled
// self._prefersListLayout = prefersListLayout
// self._isEditMode = isEditMode
// self._selection = selection
// self.viewModel = viewModel
// self.showFeatureCards = showFeatureCards
// }
var filtersHeader: some View {
GeometryReader { reader in
ScrollView(.horizontal, showsIndicators: false) {
@ -359,7 +408,9 @@ struct AnimatingCellHeight: AnimatableModifier {
Menu(
content: {
ForEach(viewModel.filters) { filter in
Button(filter.name, action: { viewModel.appliedFilter = filter })
Button(filter.name, action: {
viewModel.appliedFilter = filter
})
}
},
label: {
@ -396,14 +447,23 @@ struct AnimatingCellHeight: AnimatableModifier {
}
Spacer()
}
.padding(0)
}
.listRowSeparator(.hidden)
}
.padding(.top, 0)
.padding(.bottom, 10)
.padding(.leading, 15)
.listRowSpacing(0)
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.frame(maxWidth: .infinity, minHeight: 38)
.background(Color.systemBackground)
.overlay(Rectangle()
.padding(.leading, 15)
.frame(width: nil, height: 0.5, alignment: .bottom)
.foregroundColor(isListScrolled ? Color(hex: "#3D3D3D") : Color.systemBackground), alignment: .bottom)
.dynamicTypeSize(.small ... .accessibility1)
}
func menuItems(for item: LinkedItem) -> some View {
func menuItems(for item: Models.LibraryItem) -> some View {
libraryItemMenu(dataService: dataService, viewModel: viewModel, item: item)
}
@ -509,9 +569,7 @@ struct AnimatingCellHeight: AnimatableModifier {
static func reduce(value _: inout CGPoint, nextValue _: () -> CGPoint) {}
}
@State var topItem: LinkedItem?
func setTopItem(_ item: LinkedItem) {
func setTopItem(_ item: Models.LibraryItem) {
if let date = item.savedAt, let daysAgo = Calendar.current.dateComponents([.day], from: date, to: Date()).day {
if daysAgo < 1 {
let formatter = DateFormatter()
@ -545,93 +603,100 @@ struct AnimatingCellHeight: AnimatableModifier {
}
}
@ObservedObject var networkMonitor = NetworkMonitor()
var body: some View {
let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10)
VStack(spacing: 0) {
if viewModel.showLoadingBar {
ShimmeringLoader()
} else {
Spacer(minLength: 2)
}
List(selection: $selection) {
filtersHeader
.listRowSeparator(.hidden, edges: .all)
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 0, trailing: horizontalInset))
if let appliedFilter = viewModel.appliedFilter,
networkMonitor.status == .disconnected,
!appliedFilter.allowLocalFetch
{
HStack {
Text("This search requires an internet connection.")
.padding()
.foregroundColor(Color.white)
Color.systemBackground.frame(height: 1)
ScrollViewReader { reader in
List(selection: $selection) {
Section(content: {
EmptyView().id("TOP")
if let appliedFilter = viewModel.appliedFilter,
networkMonitor.status == .disconnected,
!appliedFilter.allowLocalFetch
{
HStack {
Text("This search requires an internet connection.")
.padding()
.foregroundColor(Color.white)
.frame(maxWidth: .infinity, alignment: .center)
}
.background(Color.blue)
.frame(maxWidth: .infinity, alignment: .center)
}
.background(Color.blue)
.frame(maxWidth: .infinity, alignment: .center)
.listRowSeparator(.hidden, edges: .all)
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
} else {
if showFeatureCards {
featureCard
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowSeparator(.hidden, edges: .all)
.modifier(AnimatingCellHeight(height: 190 + 13))
.onDisappear {
withAnimation {
isListScrolled = true
}
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
} else {
if showFeatureCards {
featureCard
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowSeparator(.hidden, edges: .all)
.modifier(AnimatingCellHeight(height: 190 + 13))
.onDisappear {
withAnimation {
isListScrolled = true
}
}
.onAppear {
withAnimation {
isListScrolled = false
}
}
}
.onAppear {
withAnimation {
isListScrolled = false
}
}
}
ForEach(Array(viewModel.items.enumerated()), id: \.1.unwrappedID) { _, item in
FeedCardNavigationLink(
item: item,
isInMultiSelectMode: viewModel.isInMultiSelectMode,
viewModel: viewModel
)
.background(GeometryReader { geometry in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
})
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
if value.y < 100, value.y > 0 {
if item.savedAt != nil, topItem != item {
setTopItem(item)
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.unwrappedID) { _, item in
FeedCardNavigationLink(
item: item,
isInMultiSelectMode: viewModel.isInMultiSelectMode,
viewModel: viewModel
)
.background(GeometryReader { geometry in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
})
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
if value.y < 100, value.y > 0 {
if item.savedAt != nil, topItem != item {
setTopItem(item)
}
}
}
.listRowSeparatorTint(Color.thBorderColor)
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 10, trailing: horizontalInset))
.contextMenu {
menuItems(for: item)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
ForEach(viewModel.listConfig.leadingSwipeActions, id: \.self) { action in
swipeActionButton(action: action, item: item)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
ForEach(viewModel.listConfig.trailingSwipeActions, id: \.self) { action in
swipeActionButton(action: action, item: item)
}
}
}
}
.listRowSeparatorTint(Color.thBorderColor)
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 10, trailing: horizontalInset))
.contextMenu {
menuItems(for: item)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
ForEach(viewModel.listConfig.leadingSwipeActions, id: \.self) { action in
swipeActionButton(action: action, item: item)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
ForEach(viewModel.listConfig.trailingSwipeActions, id: \.self) { action in
swipeActionButton(action: action, item: item)
}
}, header: {
filtersHeader
})
}
.padding(0)
.listStyle(.plain)
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.coordinateSpace(name: "scroll")
.onChange(of: shouldScrollToTop) { _ in
if shouldScrollToTop {
withAnimation {
reader.scrollTo("TOP", anchor: .top)
}
}
shouldScrollToTop = false
}
}
.padding(0)
.listStyle(PlainListStyle())
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.coordinateSpace(name: "scroll")
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
shouldScrollToTop = true
}
}
.alert("The Feature Section will be removed from your library. You can add it back from the filter settings in your profile.",
isPresented: $showHideFeatureAlert) {
@ -639,44 +704,13 @@ struct AnimatingCellHeight: AnimatableModifier {
viewModel.hideFeatureSection = true
}
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFeatureAlert = false }
}.introspectNavigationController { nav in
nav.navigationBar.shadowImage = UIImage()
nav.navigationBar.setBackgroundImage(UIImage(), for: .default)
}
}
func dateSummaryCard(_: Date) -> some View {
VStack(alignment: .center, spacing: 15) {
Text("3 articles saved today")
.frame(maxWidth: .infinity, alignment: .center)
.font(.body)
HStack {
Spacer()
HStack(spacing: 0) {
Button(action: {}, label: {
Text("Archive all")
.font(Font.system(size: 14))
.padding(.horizontal, 10)
})
.frame(height: 30)
.background(Color.blue)
Button(action: {}, label: {
Image(systemName: "chevron.down")
.resizable()
.scaledToFit()
.frame(width: 10, height: 10)
.padding(.leading, 7.5)
.padding(.trailing, 7.5)
.foregroundColor(Color.white)
})
.frame(height: 30)
.background(Color(hex: "345BB8"))
}
.cornerRadius(2.5)
Spacer()
}
}
.padding(15)
}
func swipeActionButton(action: SwipeAction, item: LinkedItem) -> AnyView {
func swipeActionButton(action: SwipeAction, item: Models.LibraryItem) -> AnyView {
switch action {
case .pin:
let isPinned = item.labels?.allObjects.first { ($0 as? LinkedItemLabel)?.name == "Pinned" } != nil
@ -705,7 +739,7 @@ struct AnimatingCellHeight: AnimatableModifier {
case .delete:
return AnyView(Button(
action: {
viewModel.removeLink(dataService: dataService, objectID: item.objectID)
viewModel.removeLibraryItem(dataService: dataService, objectID: item.objectID)
},
label: {
Label("Remove", systemImage: "trash")
@ -714,10 +748,11 @@ struct AnimatingCellHeight: AnimatableModifier {
case .moveToInbox:
return AnyView(Button(
action: {
// viewModel.addLabel(dataService: dataService, item: item, label: "Inbox", color)
viewModel.moveToFolder(dataService: dataService, item: item, folder: "inbox")
},
label: {
Label("Move to Inbox", systemImage: "tray.fill")
Label(title: { Text("Move to Library") },
icon: { Image.tabLibrary })
}
).tint(Color(hex: "#0A84FF")))
}
@ -731,16 +766,17 @@ struct AnimatingCellHeight: AnimatableModifier {
@State var isContextMenuOpen = false
@ObservedObject var viewModel: HomeFeedViewModel
@Binding var isListScrolled: Bool
func contextMenuActionHandler(item: LinkedItem, action: GridCardAction) {
func contextMenuActionHandler(item: Models.LibraryItem, action: GridCardAction) {
switch action {
case .viewHighlights:
viewModel.itemForHighlightsView = item
case .toggleArchiveStatus:
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: !item.isArchived)
case .delete:
viewModel.removeLink(dataService: dataService, objectID: item.objectID)
viewModel.removeLibraryItem(dataService: dataService, objectID: item.objectID)
case .editLabels:
viewModel.itemUnderLabelEdit = item
case .editTitle:
@ -810,12 +846,7 @@ struct AnimatingCellHeight: AnimatableModifier {
var body: some View {
VStack(alignment: .leading) {
if viewModel.showLoadingBar {
ShimmeringLoader()
} else {
Spacer(minLength: 2)
}
Color.systemBackground.frame(height: 1)
filtersHeader
.onAppear {
withAnimation {
@ -832,7 +863,7 @@ struct AnimatingCellHeight: AnimatableModifier {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325, maximum: 400), spacing: 16)], alignment: .center, spacing: 30) {
ForEach(viewModel.items) { item in
ForEach(viewModel.fetcher.items) { item in
GridCardNavigationLink(
item: item,
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
@ -860,7 +891,7 @@ struct AnimatingCellHeight: AnimatableModifier {
}
}
if viewModel.items.isEmpty, viewModel.isLoading {
if viewModel.fetcher.items.isEmpty, viewModel.isLoading {
LoadingSection()
}
}
@ -896,7 +927,7 @@ struct ScrollViewOffsetPreferenceKey: PreferenceKey {
#endif
struct LinkDestination: View {
let selectedItem: LinkedItem?
let selectedItem: Models.LibraryItem?
var body: some View {
Group {

View file

@ -11,7 +11,7 @@ import Views
@EnvironmentObject var audioController: AudioController
@EnvironmentObject var authenticator: Authenticator
@State private var itemToRemove: LinkedItem?
@State private var itemToRemove: Models.LibraryItem?
@State private var confirmationShown = false
@State private var presentProfileSheet = false
@State private var addLinkPresented = false
@ -29,7 +29,7 @@ import Views
}
}
func menuItems(_ item: LinkedItem) -> some View {
func menuItems(_ item: Models.LibraryItem) -> some View {
Group {
Button(
action: { viewModel.itemUnderTitleEdit = item },
@ -143,7 +143,7 @@ import Views
Button("Remove Link", role: .destructive) {
if let itemToRemove = itemToRemove {
withAnimation {
viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID)
viewModel.removeLibraryItem(dataService: dataService, objectID: itemToRemove.objectID)
self.itemToRemove = nil
}
}

View file

@ -5,69 +5,61 @@ import SwiftUI
import Utils
import Views
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject {
var currentDetailViewModel: LinkItemDetailViewModel?
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
let folder: String
let fetcher: LibraryItemFetcher
let listConfig: LibraryListConfig
private var fetchedResultsController: NSFetchedResultsController<LinkedItem>?
private var fetchedResultsController: NSFetchedResultsController<Models.LibraryItem>?
@Published var items = [LinkedItem]()
@Published var isLoading = false
@Published var showPushNotificationPrimer = false
@Published var itemUnderLabelEdit: LinkedItem?
@Published var itemUnderTitleEdit: LinkedItem?
@Published var itemForHighlightsView: LinkedItem?
@Published var searchTerm = ""
@Published var scopeSelection = 0
@Published var selectedLabels = [LinkedItemLabel]()
@Published var negatedLabels = [LinkedItemLabel]()
@Published var snoozePresented = false
@Published var itemToSnoozeID: String?
@Published var itemUnderLabelEdit: Models.LibraryItem?
@Published var itemUnderTitleEdit: Models.LibraryItem?
@Published var itemForHighlightsView: Models.LibraryItem?
@Published var linkRequest: LinkRequest?
@Published var presentWebContainer = false
@Published var showLoadingBar = false
@Published var isInMultiSelectMode = false
@Published var appliedSort = LinkedItemSort.newest.rawValue
@Published var selectedLinkItem: NSManagedObjectID? // used by mac app only
@Published var selectedItem: LinkedItem?
@Published var selectedItem: Models.LibraryItem?
@Published var linkIsActive = false
@Published var showLabelsSheet = false
@Published var showFiltersModal = false
@Published var featureItems = [LinkedItem]()
@Published var listConfig: LibraryListConfig
@Published var showCommunityModal = false
@Published var featureItems = [Models.LibraryItem]()
@Published var showSnackbar = false
@Published var snackbarOperation: SnackbarOperation?
@Published var filters = [InternalFilter]()
@Published var searchTerm = ""
@Published var selectedLabels = [LinkedItemLabel]()
@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
@Published var appliedFilter: InternalFilter? {
didSet {
appliedFilterName = appliedFilter?.name.lowercased() ?? "inbox"
let filterKey = UserDefaults.standard.string(forKey: "lastSelected-\(folder)-filter") ?? folder
UserDefaults.standard.setValue(appliedFilter?.name, forKey: filterKey)
}
}
var cursor: String?
// These are used to make sure we handle search result
// responses in the right order
var searchIdx = 0
var receivedIdx = 0
var syncCursor: String?
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
@AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilterName = "inbox"
@AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue
init(listConfig: LibraryListConfig) {
self.listConfig = listConfig
super.init()
private var filterState: FetcherFilterState {
FetcherFilterState(folder: folder, searchTerm: searchTerm, selectedLabels: selectedLabels, negatedLabels: negatedLabels, appliedSort: appliedSort, appliedFilter: appliedFilter)
}
func setItems(_ context: NSManagedObjectContext, _ items: [LinkedItem]) {
self.items = items
updateFeatureFilter(context: context, filter: FeaturedItemFilter(rawValue: featureFilter))
init(folder: String, fetcher: LibraryItemFetcher, listConfig: LibraryListConfig) {
self.folder = folder
self.fetcher = fetcher
self.listConfig = listConfig
super.init()
}
func updateFeatureFilter(context: NSManagedObjectContext, filter: FeaturedItemFilter?) {
@ -86,21 +78,47 @@ import Views
}
}
func itemAppeared(item: LinkedItem, dataService: DataService) async {
func loadFilters(dataService: DataService) async {
switch folder {
case "following":
updateFilters(newFilters: InternalFilter.DefaultFollowingFilters, defaultName: "rss")
default:
var hasLocalResults = false
let fetchRequest: NSFetchRequest<Models.Filter> = Filter.fetchRequest()
// Load from disk
if let results = try? dataService.viewContext.fetch(fetchRequest) {
hasLocalResults = true
updateFilters(newFilters: InternalFilter.make(from: results), defaultName: "inbox")
}
let hasResults = hasLocalResults
Task.detached {
if let downloadedFilters = try? await dataService.filters() {
await self.updateFilters(newFilters: downloadedFilters, defaultName: "inbox")
} else if !hasResults {
await self.updateFilters(newFilters: InternalFilter.DefaultInboxFilters, defaultName: "inbox")
}
}
}
}
func itemAppeared(item: Models.LibraryItem, dataService: DataService) async {
if isLoading { return }
let itemIndex = items.firstIndex(where: { $0.id == item.id })
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
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, isRefresh: false)
await loadMoreItems(dataService: dataService, filterState: filterState, isRefresh: false)
}
}
func pushFeedItem(item: LinkedItem) {
items.insert(item, at: 0)
func pushFeedItem(item _: Models.LibraryItem) {
/// TODO: jackson
// fetcher.items.insert(item, at: 0)
}
func loadCurrentViewer(dataService: DataService) async {
@ -119,130 +137,24 @@ import Views
}
}
func loadFilters(dataService: DataService) async {
var hasLocalResults = false
let fetchRequest: NSFetchRequest<Models.Filter> = Filter.fetchRequest()
func updateFilters(newFilters: [InternalFilter], defaultName: String) {
let appliedFilterName = UserDefaults.standard.string(forKey: "lastSelected-\(filterState.folder)-filter") ?? defaultName
// Load from disk
if let results = try? dataService.viewContext.fetch(fetchRequest) {
hasLocalResults = true
updateFilters(newFilters: InternalFilter.make(from: results))
}
filters = newFilters
.filter { $0.folder == filterState.folder }
.sorted(by: { $0.position < $1.position })
+ (folder == "inbox" ? [InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter] : [InternalFilter.DownloadedFilter])
let hasResults = hasLocalResults
Task.detached {
if let downloadedFilters = try? await dataService.filters() {
await self.updateFilters(newFilters: downloadedFilters)
} else if !hasResults {
await self.updateFilters(newFilters: InternalFilter.DefaultFilters)
}
}
}
func updateFilters(newFilters: [InternalFilter]) {
filters = newFilters.sorted(by: { $0.position < $1.position }) + [InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter]
if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id {
appliedFilter = newFilter
}
}
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) {
// Set isLoading to false here
self.isLoading = false
}
} 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, isRefresh: Bool) async {
let thisSearchIdx = searchIdx
searchIdx += 1
if thisSearchIdx > 0, thisSearchIdx <= receivedIdx {
return
}
let queryResult = try? await dataService.loadLinkedItems(
limit: 10,
searchQuery: searchQuery,
cursor: isRefresh ? nil : cursor
)
if let appliedFilter = appliedFilter, let queryResult = queryResult {
let newItems: [LinkedItem] = {
var itemObjects = [LinkedItem]()
dataService.viewContext.performAndWait {
itemObjects = queryResult.itemIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItem }
}
return itemObjects
}()
if searchTerm.replacingOccurrences(of: " ", with: "").isEmpty, appliedFilter.predicate != nil {
updateFetchController(dataService: dataService)
} else {
// Don't use FRC for searching. Use server results directly.
if fetchedResultsController != nil {
fetchedResultsController = nil
setItems(dataService.viewContext, [])
}
setItems(dataService.viewContext, isRefresh ? newItems : items + newItems)
}
isLoading = false
receivedIdx = thisSearchIdx
cursor = queryResult.cursor
if let username = dataService.currentViewer?.username {
await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username)
}
} else {
updateFetchController(dataService: dataService)
}
}
func loadItems(dataService: DataService, isRefresh: Bool) async {
isLoading = true
showLoadingBar = true
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.loadFilters(dataService: dataService) }
group.addTask { await self.syncItems(dataService: dataService) }
group.addTask { await self.updateFetchController(dataService: dataService) }
await group.waitForAll()
}
if let appliedFilter = appliedFilter {
let shouldRemoteSearch = items.count < 1 || isRefresh && appliedFilter.shouldRemoteSearch
if shouldRemoteSearch {
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
} else {
updateFetchController(dataService: dataService)
}
}
await fetcher.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
@ -250,20 +162,18 @@ import Views
showLoadingBar = false
}
func loadMoreItems(dataService: DataService, isRefresh: Bool) async {
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
isLoading = true
showLoadingBar = true
if let appliedFilter, appliedFilter.shouldRemoteSearch {
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
}
await fetcher.loadMoreItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
isLoading = false
showLoadingBar = false
}
func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [LinkedItem] {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
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]
@ -271,62 +181,6 @@ import Views
return (try? context.fetch(fetchRequest)) ?? []
}
private var fetchRequest: NSFetchRequest<Models.LinkedItem> {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
var subPredicates = [NSPredicate]()
if let predicate = appliedFilter?.predicate {
subPredicates.append(predicate)
}
if !selectedLabels.isEmpty {
var labelSubPredicates = [NSPredicate]()
for label in selectedLabels {
labelSubPredicates.append(
NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count > 0")
)
}
subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates))
}
if !negatedLabels.isEmpty {
var labelSubPredicates = [NSPredicate]()
for label in negatedLabels {
labelSubPredicates.append(
NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count == 0")
)
}
subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates))
}
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subPredicates)
fetchRequest.sortDescriptors = (LinkedItemSort(rawValue: appliedSort) ?? .newest).sortDescriptors
return fetchRequest
}
private func updateFetchController(dataService: DataService) {
fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: dataService.viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)
guard let fetchedResultsController = fetchedResultsController else {
return
}
fetchedResultsController.delegate = self
try? fetchedResultsController.performFetch()
setItems(dataService.viewContext, fetchedResultsController.fetchedObjects ?? [])
}
func snackbar(_ message: String, undoAction: SnackbarUndoAction? = nil) {
snackbarOperation = SnackbarOperation(message: message, undoAction: undoAction)
showSnackbar = true
@ -337,7 +191,7 @@ import Views
snackbar(archived ? "Link archived" : "Link moved to Inbox")
}
func removeLink(dataService: DataService, objectID: NSManagedObjectID) {
func removeLibraryItem(dataService: DataService, objectID: NSManagedObjectID) {
removeLibraryItemAction(dataService: dataService, objectID: objectID)
}
@ -362,7 +216,7 @@ import Views
return nil
}
func addLabel(dataService: DataService, item: LinkedItem, label: String, color: String) {
func addLabel(dataService: DataService, item: Models.LibraryItem, label: String, color: String) {
if let label = getOrCreateLabel(dataService: dataService, named: "Pinned", color: color) {
let existingLabels = item.labels?.allObjects.compactMap { $0 as? LinkedItemLabel } ?? []
dataService.setItemLabels(itemID: item.unwrappedID, labels: InternalLinkedItemLabel.make(Set(existingLabels + [label]) as NSSet))
@ -372,7 +226,7 @@ import Views
}
}
func removeLabel(dataService: DataService, item: LinkedItem, named: String) {
func removeLabel(dataService: DataService, item: Models.LibraryItem, named: String) {
let labels = item.labels?
.filter { ($0 as? LinkedItemLabel)?.name != named }
.compactMap { $0 as? LinkedItemLabel } ?? []
@ -380,28 +234,39 @@ import Views
item.update(inContext: dataService.viewContext)
}
func pinItem(dataService: DataService, item: LinkedItem) {
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)
}
}
func unpinItem(dataService: DataService, item: LinkedItem) {
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)
}
}
func markRead(dataService: DataService, item: LinkedItem) {
func markRead(dataService: DataService, item: Models.LibraryItem) {
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 100, anchorIndex: 0, force: true)
}
func markUnread(dataService: DataService, item: LinkedItem) {
func markUnread(dataService: DataService, item: Models.LibraryItem) {
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0, force: true)
}
func moveToFolder(dataService: DataService, item: Models.LibraryItem, folder: String) {
Task {
do {
try await dataService.moveItem(itemID: item.unwrappedID, folder: folder)
snackbar("Item moved")
} catch {
snackbar("Error moving item to \(folder)")
}
}
}
func bulkAction(dataService: DataService, action: BulkAction, items: [String]) {
if items.count < 1 {
snackbar("No items selected")
@ -412,6 +277,7 @@ import Views
try await dataService.bulkAction(action: action, items: items)
snackbar("Operation completed")
} catch {
print("ERROR: ", error)
snackbar("Error performing operation")
}
}
@ -420,55 +286,4 @@ import Views
func findFilter(_: DataService, named: String) -> InternalFilter? {
filters.first(where: { $0.name == named })
}
private var queryContainsFilter: Bool {
if searchTerm.contains("in:inbox") || searchTerm.contains("in:all") || searchTerm.contains("in:archive") {
return true
}
return false
}
private var searchQuery: String {
let sort = LinkedItemSort(rawValue: appliedSort) ?? .newest
var query = sort.queryString
if !queryContainsFilter, let filter = appliedFilter?.filter {
query = "\(filter) \(sort.queryString)"
}
if !searchTerm.isEmpty {
query.append(" \(searchTerm)")
}
if !selectedLabels.isEmpty {
query.append(" label:")
query.append(selectedLabels.compactMap { label in
if let name = label.name {
return "\"\(name)\""
}
return nil
}.joined(separator: ","))
}
if !negatedLabels.isEmpty {
query.append(" !label:")
query.append(negatedLabels.compactMap { label in
if let name = label.name {
return "\"\(name)\""
}
return nil
}.joined(separator: ","))
}
print("QUERY: `\(query)`")
return query
}
}
extension HomeFeedViewModel: NSFetchedResultsControllerDelegate {
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
setItems(controller.managedObjectContext, controller.fetchedObjects as? [LinkedItem] ?? [])
}
}

View file

@ -6,7 +6,7 @@ import UserNotifications
import Utils
import Views
@MainActor func libraryItemMenu(dataService: DataService, viewModel: HomeFeedViewModel, item: LinkedItem) -> some View {
@MainActor func libraryItemMenu(dataService: DataService, viewModel: HomeFeedViewModel, item: Models.LibraryItem) -> some View {
Group {
if item.state != "DELETED" {
Button(
@ -17,33 +17,33 @@ 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.searchTerm = "author:\"\(author)\""
},
label: {
Label(String("More by \(author)"), systemImage: "person")
}
)
}
// 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

@ -10,16 +10,9 @@ import Models
import SwiftUI
struct LibraryListView: View {
@StateObject private var subViewModel = HomeFeedViewModel(
listConfig: LibraryListConfig(
hasFeatureCards: false,
leadingSwipeActions: [.moveToInbox],
trailingSwipeActions: [.archive, .delete],
cardStyle: .library
)
)
@StateObject private var libraryViewModel = HomeFeedViewModel(
folder: "inbox",
fetcher: LibraryItemFetcher(),
listConfig: LibraryListConfig(
hasFeatureCards: true,
leadingSwipeActions: [.pin],
@ -28,15 +21,6 @@ struct LibraryListView: View {
)
)
@StateObject private var highlightsViewModel = HomeFeedViewModel(
listConfig: LibraryListConfig(
hasFeatureCards: true,
leadingSwipeActions: [.pin],
trailingSwipeActions: [.archive, .delete],
cardStyle: .highlights
)
)
var body: some View {
// ZStack {
// NavigationLink(

View file

@ -33,11 +33,11 @@
performTypeahead(searchTerm)
}
func performSearch(_ searchTerm: String) {
let term = searchTerm.trimmingCharacters(in: Foundation.CharacterSet.whitespacesAndNewlines)
viewModel.saveRecentSearch(dataService: dataService, searchTerm: term)
recents = viewModel.recentSearches(dataService: dataService)
homeFeedViewModel.searchTerm = term
func performSearch(_: String) {
// let term = searchTerm.trimmingCharacters(in: Foundation.CharacterSet.whitespacesAndNewlines)
// viewModel.saveRecentSearch(dataService: dataService, searchTerm: term)
// recents = viewModel.recentSearches(dataService: dataService)
// homeFeedViewModel.searchTerm = term
dismiss()
}

View file

@ -1,12 +1,4 @@
// swiftlint:disable line_length
//
// CommunityModal.swift
//
//
// Created by Jackson Harper on 12/7/22.
//
#if os(iOS)
import Foundation
import Models

View file

@ -7,7 +7,7 @@ import Views
@MainActor
struct ApplyLabelsView: View {
enum Mode {
case item(LinkedItem)
case item(Models.LibraryItem)
case highlight(Highlight)
case list([LinkedItemLabel])
@ -106,16 +106,14 @@ struct ApplyLabelsView: View {
.sheet(isPresented: $viewModel.showCreateLabelModal) {
CreateLabelView(viewModel: viewModel, newLabelName: viewModel.labelSearchFilter)
}
.onAppear {
Task {
switch mode {
case let .item(feedItem):
await viewModel.loadLabels(dataService: dataService, item: feedItem)
case let .highlight(highlight):
await viewModel.loadLabels(dataService: dataService, highlight: highlight)
case let .list(labels):
await viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels)
}
.task {
switch mode {
case let .item(feedItem):
await viewModel.loadLabels(dataService: dataService, item: feedItem)
case let .highlight(highlight):
await viewModel.loadLabels(dataService: dataService, highlight: highlight)
case let .list(labels):
await viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels)
}
}
}

View file

@ -25,7 +25,7 @@ import SwiftUI
func loadLabels(
dataService: DataService,
item: LinkedItem? = nil,
item: Models.LibraryItem? = nil,
highlight: Highlight? = nil,
initiallySelectedLabels: [LinkedItemLabel]? = nil
) async {

View file

@ -1,98 +0,0 @@
#if os(iOS)
import SwiftUI
// Mostly from: https://kavsoft.dev/swiftui_3.0_marquee_text_animation with some customizations
struct Marquee: View {
var text: String
var font: UIFont
// Storing Text Size
@State var storedSize: CGSize = .zero
@State var offset: CGFloat = 0
@State var animatedText: String = ""
var animationSpeed: Double = 0.03
var delayTime: Double = 3.0
var body: some View {
// Since it scrolls horizontal using ScrollView
GeometryReader { proxy in
let size = proxy.size
let condition = textSize(text: text).width < (size.width - 50)
ScrollView(condition ? .init() : .horizontal, showsIndicators: false) {
HStack(alignment: .center) {
Spacer(minLength: 0)
Text(condition ? text : animatedText)
.font(Font(font))
.offset(x: condition ? 0 : offset)
.padding(.horizontal, 15)
Spacer(minLength: 0)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
.frame(height: storedSize.height)
.overlay(content: {
// swiftlint:disable line_length
HStack {
let color: Color = .systemBackground
LinearGradient(colors: [color, color.opacity(0.7), color.opacity(0.5), color.opacity(0.3)], startPoint: .leading, endPoint: .trailing)
.frame(width: 8)
Spacer()
LinearGradient(colors: [color, color.opacity(0.7), color.opacity(0.5), color.opacity(0.3)].reversed(), startPoint: .leading, endPoint: .trailing)
.frame(width: 8)
// swiftlint:enable line_length
}
})
.disabled(true)
.onAppear {
startAnimation(text: text)
}
.onReceive(Timer.publish(every: (animationSpeed * storedSize.width) + delayTime,
on: .main,
in: .default).autoconnect()
) { _ in
offset = 0
withAnimation(.linear(duration: animationSpeed * storedSize.width).delay(delayTime)) {
offset = -storedSize.width
}
}
.onChange(of: text) { newValue in
animatedText = ""
offset = 0
startAnimation(text: newValue)
}
}
func startAnimation(text: String) {
// Double the text with some spacing so that we can create a continuous loop
animatedText.append(text)
(1 ... 15).forEach { _ in
animatedText.append(" ")
}
storedSize = textSize(text: animatedText)
animatedText.append(text)
let timing: Double = (animationSpeed * storedSize.width)
withAnimation(.linear(duration: timing).delay(delayTime)) {
offset = -storedSize.width
}
}
func textSize(text: String) -> CGSize {
let attributes = [NSAttributedString.Key.font: font]
let size = (text as NSString).size(withAttributes: attributes)
return size
}
}
#endif

View file

@ -0,0 +1,203 @@
import Foundation
import Services
import SwiftUI
@MainActor struct LibrarySidebar: View {
@ObservedObject var inboxViewModel: HomeFeedViewModel
@ObservedObject var followingViewModel: HomeFeedViewModel
@EnvironmentObject var dataService: DataService
@State private var addLinkPresented = false
@State private var showProfile = false
@State private var selection: String?
@State private var selectedFilter: InternalFilter?
@AppStorage("inboxActive") private var inboxActive = true
@AppStorage("followingActive") private var followingActive = false
@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)
NavigationLink("", destination: HomeView(viewModel: followingViewModel), isActive: $followingActive)
List {
Section {
Button(action: { inboxMenuState = inboxMenuState == "open" ? "closed" : "open" }, label: {
HStack {
Image.tabLibrary
Text("Library")
Spacer()
if inboxMenuState == "open" {
Image(systemName: "chevron.down")
} else {
Image(systemName: "chevron.right")
}
}
})
if inboxMenuState == "open" {
ForEach(inboxViewModel.filters, id: \.self) { filter in
Button(action: {
inboxViewModel.appliedFilter = filter
selectedFilter = filter
followingActive = false
inboxActive = true
}, label: {
HStack {
Spacer().frame(width: 35)
Text(filter.name)
.lineLimit(1)
}
})
.listRowBackground(
selectedFilter == filter && inboxActive
? Color.systemBackground.cornerRadius(8) : Color.clear.cornerRadius(8)
)
}
}
}
Section {
Button(action: { followingMenuState = followingMenuState == "open" ? "closed" : "open" }, label: {
HStack {
Image.tabFollowing
Text("Following")
Spacer()
if followingMenuState == "open" {
Image(systemName: "chevron.down")
} else {
Image(systemName: "chevron.right")
}
}
})
if followingMenuState == "open" {
ForEach(followingViewModel.filters, id: \.self) { filter in
Button(action: {
followingViewModel.appliedFilter = filter
selectedFilter = filter
inboxActive = false
followingActive = true
}, label: {
HStack {
Spacer().frame(width: 35)
Text(filter.name)
.lineLimit(1)
}
})
.listRowBackground(
selectedFilter == filter && followingActive
? Color.systemBackground.cornerRadius(8) : Color.clear.cornerRadius(8)
)
}
}
}
}
.listStyle(.sidebar)
.dynamicTypeSize(.small ... .large)
.sheet(isPresented: $addLinkPresented) {
NavigationView {
LibraryAddLinkView()
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
}
.sheet(isPresented: $showProfile) {
NavigationView {
ProfileView()
.toolbar {
ToolbarItem(placement: .barTrailing) {
Button(action: { showProfile = false }, label: {
Text("Close")
.bold()
})
}
}
}
}.task {
await inboxViewModel.loadFilters(dataService: dataService)
await followingViewModel.loadFilters(dataService: dataService)
if inboxActive {
selectedFilter = inboxViewModel.appliedFilter
} else {
selectedFilter = followingViewModel.appliedFilter
}
}.onChange(of: inboxViewModel.appliedFilter) { filter in
// When the user uses the dropdown menu to change filter we need to update in the sidebar
if inboxActive, filter != selectedFilter {
selectedFilter = filter
}
}.onChange(of: followingViewModel.appliedFilter) { filter in
if followingActive, filter != selectedFilter {
selectedFilter = filter
}
}
}
var body: some View {
#if os(iOS)
innerBody
.toolbar {
ToolbarItem(placement: .barTrailing) {
Button(action: { showProfile = true }, label: { Image.tabProfile })
}
}
#elseif os(macOS)
innerBody
.frame(minWidth: 200)
.toolbar {
ToolbarItem {
Button(
action: {
NSApp.keyWindow?.firstResponder?.tryToPerform(
#selector(NSSplitViewController.toggleSidebar(_:)), with: nil
)
},
label: { Label(LocalText.navigationSelectSidebarToggle, systemImage: "sidebar.left") }
)
}
}
#endif
}
}

View file

@ -0,0 +1,62 @@
import Foundation
import Services
import SwiftUI
@MainActor
public struct LibrarySplitView: View {
@EnvironmentObject var audioController: AudioController
@StateObject private var inboxViewModel = HomeFeedViewModel(
folder: "inbox",
fetcher: LibraryItemFetcher(),
listConfig: LibraryListConfig(
hasFeatureCards: true,
leadingSwipeActions: [.pin],
trailingSwipeActions: [.archive, .delete],
cardStyle: .library
)
)
@StateObject private var followingViewModel = HomeFeedViewModel(
folder: "following",
fetcher: LibraryItemFetcher(),
listConfig: LibraryListConfig(
hasFeatureCards: false,
leadingSwipeActions: [.moveToInbox],
trailingSwipeActions: [.archive, .delete],
cardStyle: .library
)
)
@State var selected = "home"
#if os(iOS)
public var body: some View {
NavigationView {
LibrarySidebar(inboxViewModel: inboxViewModel, followingViewModel: followingViewModel)
.navigationBarTitleDisplayMode(.inline)
.tag("inbox")
HomeFeedContainerView(viewModel: inboxViewModel)
.navigationViewStyle(.stack)
.navigationBarTitleDisplayMode(.inline)
.tag("following")
}
.navigationBarTitleDisplayMode(.inline)
.accentColor(.appGrayTextContrast)
.introspectSplitViewController {
$0.preferredPrimaryColumnWidth = 230
$0.displayModeButtonVisibility = .always
}
}
#endif
#if os(macOS)
public var body: some View {
NavigationView {
LibraryListView()
Text(LocalText.navigationSelectLink)
}
}
#endif
}

View file

@ -10,13 +10,27 @@ import Models
import PopupView
import Services
import SwiftUI
import Transmission
import Utils
import Views
@MainActor
struct LibraryTabView: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@StateObject private var subViewModel = HomeFeedViewModel(
@AppStorage(UserDefaultKey.lastSelectedTabItem.rawValue) var selectedTab = "inbox"
@State var showExpandedAudioPlayer = false
@MainActor
public init() {
UITabBar.appearance().isHidden = true
}
@StateObject private var followingViewModel = HomeFeedViewModel(
folder: "following",
fetcher: LibraryItemFetcher(),
listConfig: LibraryListConfig(
hasFeatureCards: false,
leadingSwipeActions: [.moveToInbox],
@ -26,6 +40,8 @@ struct LibraryTabView: View {
)
@StateObject private var libraryViewModel = HomeFeedViewModel(
folder: "inbox",
fetcher: LibraryItemFetcher(),
listConfig: LibraryListConfig(
hasFeatureCards: true,
leadingSwipeActions: [.pin],
@ -34,21 +50,42 @@ struct LibraryTabView: View {
)
)
@StateObject private var highlightsViewModel = HomeFeedViewModel(
listConfig: LibraryListConfig(
hasFeatureCards: true,
leadingSwipeActions: [.pin],
trailingSwipeActions: [.archive, .delete],
cardStyle: .highlights
)
)
var body: some View {
NavigationView {
HomeView(viewModel: libraryViewModel)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
VStack(spacing: 0) {
TabView(selection: $selectedTab) {
NavigationView {
HomeFeedContainerView(viewModel: followingViewModel)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
}.tag("following")
NavigationView {
HomeFeedContainerView(viewModel: libraryViewModel)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
}.tag("inbox")
NavigationView {
ProfileView()
.navigationViewStyle(.stack)
}.tag("profile")
}
if let audioProperties = audioController.itemAudioProperties {
MiniPlayerViewer(itemAudioProperties: audioProperties)
.onTapGesture {
showExpandedAudioPlayer = true
}
.padding(0)
Color(hex: "#3D3D3D")
.frame(height: 1)
.frame(maxWidth: .infinity)
}
CustomTabBar(selectedTab: $selectedTab)
.padding(0)
}
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
ExpandedAudioPlayer()
}
.navigationBarHidden(true)
}
}

View file

@ -7,11 +7,11 @@ import Views
@MainActor final class LinkItemDetailViewModel: ObservableObject {
@Published var pdfItem: PDFItem?
@Published var item: LinkedItem?
@Published var item: Models.LibraryItem?
func loadItem(linkedItemObjectID: NSManagedObjectID, dataService: DataService) async {
let item = await dataService.viewContext.perform {
dataService.viewContext.object(with: linkedItemObjectID) as? LinkedItem
dataService.viewContext.object(with: linkedItemObjectID) as? Models.LibraryItem
}
if let item = item {
@ -85,17 +85,16 @@ struct LinkItemDetailView: View {
}
var body: some View {
ZStack {
Group {
if isPDF {
pdfContainerView
NavigationView {
pdfContainerView
.navigationBarBackButtonHidden(false)
}
.navigationViewStyle(.stack)
} else if let item = viewModel.item {
WebReaderContainerView(item: item, pop: { dismiss() })
#if os(iOS)
.navigationBarHidden(true)
.lazyPop(pop: {
dismiss()
}, isEnabled: $isEnabled)
#endif
.background(ThemeManager.currentBgColor)
}
}
.ignoresSafeArea(.all, edges: .bottom)

View file

@ -8,13 +8,13 @@ import Views
@Published var description = ""
@Published var author = ""
func load(item: LinkedItem) {
func load(item: Models.LibraryItem) {
title = item.unwrappedTitle
author = item.author ?? ""
description = item.descriptionText ?? ""
}
func submit(dataService: DataService, item: LinkedItem) {
func submit(dataService: DataService, item: Models.LibraryItem) {
dataService.updateLinkedItemTitleAndDescription(
itemID: item.unwrappedID,
title: title,
@ -30,10 +30,10 @@ struct LinkedItemMetadataEditView: View {
@Environment(\.presentationMode) private var presentationMode
@StateObject var viewModel = LinkedItemMetadataEditViewModel()
let item: LinkedItem
let item: Models.LibraryItem
let onSave: ((String, String) -> Void)?
init(item: LinkedItem, onSave: ((String, String) -> Void)? = nil) {
init(item: Models.LibraryItem, onSave: ((String, String) -> Void)? = nil) {
self.item = item
self.onSave = onSave
}

View file

@ -4,11 +4,6 @@ import SwiftUI
import Views
@MainActor public struct PrimaryContentView: View {
let categories = [
PrimaryContentCategory.feed,
PrimaryContentCategory.profile
]
@State var searchTerm: String = ""
public var body: some View {
@ -18,110 +13,16 @@ import Views
public var innerBody: some View {
#if os(iOS)
if UIDevice.isIPad {
return AnyView(splitView)
return AnyView(
LibrarySplitView()
)
} else {
return AnyView(
LibraryTabView()
.navigationViewStyle(.stack)
)
}
#else
return AnyView(splitView)
#endif
}
#if os(macOS)
private var splitView: some View {
NavigationView {
PrimaryContentCategory.feed.destinationView
Text(LocalText.navigationSelectLink)
}
}
#endif
#if os(iOS)
private var splitView: some View {
NavigationView {
// The first column is the sidebar.
PrimaryContentSidebar(categories: categories)
.navigationBarTitleDisplayMode(.inline)
// Second column is the Primary Nav Stack
PrimaryContentCategory.feed.destinationView
.navigationBarTitleDisplayMode(.inline)
}
.navigationBarTitleDisplayMode(.inline)
.accentColor(.appGrayTextContrast)
.introspectSplitViewController {
$0.preferredPrimaryColumnWidth = 160
$0.displayModeButtonVisibility = .always
}
}
#endif
}
@MainActor struct PrimaryContentSidebar: View {
@State private var addLinkPresented = false
@State private var showProfile = false
@State private var selectedCategory: PrimaryContentCategory?
let categories: [PrimaryContentCategory]
var innerBody: some View {
List {
NavigationLink(
destination: PrimaryContentCategory.feed.destinationView,
tag: PrimaryContentCategory.feed,
selection: $selectedCategory,
label: { PrimaryContentCategory.feed.listLabel }
)
.listRowBackground(Color.systemBackground.cornerRadius(8))
Button(action: { showProfile = true }, label: {
PrimaryContentCategory.profile.listLabel
})
Button(action: { addLinkPresented = true }, label: {
Label("Add Link", systemImage: "plus.circle")
})
}
.dynamicTypeSize(.small ... .large)
.listStyle(.sidebar)
.sheet(isPresented: $addLinkPresented) {
NavigationView {
LibraryAddLinkView()
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
.sheet(isPresented: $showProfile) {
NavigationView {
PrimaryContentCategory.profile.destinationView
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
}
var body: some View {
#if os(iOS)
innerBody
#elseif os(macOS)
innerBody
.frame(minWidth: 200)
.toolbar {
ToolbarItem {
Button(
action: {
NSApp.keyWindow?.firstResponder?.tryToPerform(
#selector(NSSplitViewController.toggleSidebar(_:)), with: nil
)
},
label: { Label(LocalText.navigationSelectSidebarToggle, systemImage: "sidebar.left") }
)
}
}
#endif
}
}

View file

@ -69,12 +69,8 @@ struct ProfileView: View {
Form {
innerBody
}
.navigationTitle(LocalText.genericProfile)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
dismissButton
}
toolbarItems
}
#elseif os(macOS)
List {
@ -85,11 +81,16 @@ struct ProfileView: View {
#endif
}
var dismissButton: some View {
Button(
action: { dismiss() },
label: { Text(LocalText.genericClose) }
)
var toolbarItems: some ToolbarContent {
Group {
ToolbarItem(placement: .barLeading) {
VStack(alignment: .leading) {
Text(LocalText.genericProfile)
.font(Font.system(size: 24, weight: .semibold))
}
.frame(maxWidth: .infinity, alignment: .bottomLeading)
}
}
}
private var accountSection: some View {

View file

@ -8,7 +8,7 @@ import Views
func removeLibraryItemAction(dataService: DataService, objectID: NSManagedObjectID) {
dataService.viewContext.performAndWait {
if let item = dataService.viewContext.object(with: objectID) as? LinkedItem {
if let item = dataService.viewContext.object(with: objectID) as? Models.LibraryItem {
item.state = "DELETED"
try? dataService.viewContext.save()
@ -25,7 +25,7 @@ func removeLibraryItemAction(dataService: DataService, objectID: NSManagedObject
let canceled = Task.isCancelled
if !canceled {
print("syncing link deletion")
dataService.removeLink(objectID: objectID, sync: true)
dataService.removeLibraryItem(objectID: objectID, sync: true)
}
} catch {
print("error running task: ", error)
@ -37,7 +37,7 @@ func removeLibraryItemAction(dataService: DataService, objectID: NSManagedObject
print("canceling task", syncTask)
syncTask.cancel()
dataService.viewContext.performAndWait {
if let item = dataService.viewContext.object(with: objectID) as? LinkedItem {
if let item = dataService.viewContext.object(with: objectID) as? Models.LibraryItem {
item.state = "SUCCEEDED"
try? dataService.viewContext.save()
}

View file

@ -51,9 +51,6 @@ struct InnerRootView: View {
@ViewBuilder private var innerBody: some View {
if authenticator.isLoggedIn {
PrimaryContentView()
#if os(iOS)
.miniPlayer()
#endif
} else {
WelcomeView()
.accessibilityElement()

View file

@ -0,0 +1,39 @@
import Foundation
import SwiftUI
struct CustomTabBar: View {
@Binding var selectedTab: String
var body: some View {
HStack(spacing: 0) {
TabBarButton(key: "following", image: Image.tabFollowing, selectedTab: $selectedTab)
TabBarButton(key: "inbox", image: Image.tabLibrary, selectedTab: $selectedTab)
TabBarButton(key: "profile", image: Image.tabProfile, selectedTab: $selectedTab)
}
.padding(.top, 10)
.padding(.bottom, 10)
.background(Color.themeTabBarColor)
}
}
struct TabBarButton: View {
let key: String
let image: Image
@Binding var selectedTab: String
var body: some View {
Button(action: {
if selectedTab == key {
NotificationCenter.default.post(Notification(name: Notification.Name("ScrollToTop")))
}
selectedTab = key
}, label: {
image
.resizable()
.renderingMode(.template)
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundColor(selectedTab == key ? Color.blue : Color.themeTabButtonColor)
.frame(maxWidth: .infinity)
})
}
}

View file

@ -0,0 +1,94 @@
// swiftlint:disable line_length
import Foundation
import Models
import SwiftUI
import Views
import WebKit
struct OpenArchiveTodayView: View {
let item: Models.LibraryItem
@State private var date = Date()
@State private var showSafariBrowser = false
@AppStorage("OpenArchiveTodayView::useInAppBrowser") var useInAppBrowser = false
let message = """
[Archive.today](https://archive.today) is a time capsule for web pages! It takes a 'snapshot' of a webpage that will always be online even if the original page disappears.
"""
init(item: Models.LibraryItem) {
self.item = item
self.date = item.savedAt ?? Date()
}
var archiveURL: URL? {
if let pageURL = item.pageURLString, let savedAt = item.savedAt {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let formattedDate = dateFormatter.string(from: savedAt)
let str = "https://archive.today/\(formattedDate)/\(pageURL)"
return URL(string: str)
}
return nil
}
var innerBody: some View {
VStack {
let parsedMessage = try? AttributedString(markdown: message.trimmingCharacters(in: .whitespacesAndNewlines),
options: .init(interpretedSyntax: .inlineOnly))
Rectangle()
.fill(Color.secondarySystemGroupedBackground)
.cornerRadius(10)
.overlay(
Text(parsedMessage ?? "")
.multilineTextAlignment(.leading)
.foregroundColor(Color.appGrayTextContrast)
.accentColor(.blue)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
)
Spacer()
DatePicker(
"Archived Date",
selection: $date,
displayedComponents: [.date, .hourAndMinute]
).padding(.top, 20)
Toggle("Use in-app Browser", isOn: $useInAppBrowser)
Divider().padding(.vertical, 20)
Button(
action: {
if useInAppBrowser {
showSafariBrowser = true
} else {
if let archiveURL = archiveURL {
UIApplication.shared.open(archiveURL)
}
}
},
label: { Text("Open on Archive.today").padding(5) }
)
.buttonStyle(RoundedRectButtonStyle(color: Color.blue, textColor: Color.white))
.frame(maxWidth: .infinity)
}.padding(20)
}
var body: some View {
NavigationView {
innerBody
.navigationTitle("Open on Archive.today")
.navigationBarTitleDisplayMode(.inline)
}.sheet(isPresented: $showSafariBrowser) {
if let archiveURL = archiveURL {
SafariView(url: archiveURL)
.ignoresSafeArea(.all, edges: .bottom)
}
}
}
}

View file

@ -6,7 +6,7 @@ import WebKit
@MainActor
struct WebReader: PlatformViewRepresentable {
let item: LinkedItem
let item: Models.LibraryItem
let viewModel: WebReaderViewModel
let articleContent: ArticleContent
let openLinkAction: (URL) -> Void

View file

@ -9,7 +9,7 @@ import WebKit
// swiftlint:disable file_length type_body_length
struct WebReaderContainerView: View {
let item: LinkedItem
let item: Models.LibraryItem
let pop: () -> Void
@State private var showPreferencesPopover = false
@ -25,6 +25,7 @@ struct WebReaderContainerView: View {
@State var readerSettingsChangedTransactionID: UUID?
@State var annotationSaveTransactionID: UUID?
@State var showNavBarActionID: UUID?
@State var showExpandedAudioPlayer = false
@State var shareActionID: UUID?
@State var annotation = String()
@State var showBottomBar = false
@ -32,6 +33,7 @@ struct WebReaderContainerView: View {
@State private var errorAlertMessage: String?
@State private var showErrorAlertMessage = false
@State private var showRecommendSheet = false
@State private var showOpenArchiveSheet = false
@State private var lastScrollPercentage: Int?
@State private var isRecovering = false
@ -209,7 +211,7 @@ struct WebReaderContainerView: View {
)
}
func menuItems(for item: LinkedItem) -> some View {
func menuItems(for item: Models.LibraryItem) -> some View {
let hasLabels = item.labels?.count != 0
return Group {
Button(
@ -246,6 +248,12 @@ struct WebReaderContainerView: View {
},
label: { Label("Open Original", systemImage: "safari") }
)
Button(
action: {
showOpenArchiveSheet = true
},
label: { Label("Open on Archive.today", systemImage: "globe") }
)
Button(
action: share,
label: { Label("Share Original", systemImage: "square.and.arrow.up") }
@ -462,6 +470,10 @@ struct WebReaderContainerView: View {
#if os(iOS)
.fullScreenCover(item: $safariWebLink) {
SafariView(url: $0.url)
.ignoresSafeArea(.all, edges: .bottom)
}
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
ExpandedAudioPlayer()
}
#endif
.alert(errorAlertMessage ?? LocalText.readerError, isPresented: $showErrorAlertMessage) {
@ -484,6 +496,9 @@ struct WebReaderContainerView: View {
showRecommendSheet = false
}
}
.formSheet(isPresented: $showOpenArchiveSheet) {
OpenArchiveTodayView(item: item)
}
#endif
.sheet(isPresented: $showHighlightAnnotationModal) {
NavigationView {
@ -592,7 +607,17 @@ struct WebReaderContainerView: View {
self.bottomBarOpacity = 0
}
}
if let audioProperties = audioController.itemAudioProperties {
MiniPlayerViewer(itemAudioProperties: audioProperties)
.padding(.top, 10)
.padding(.bottom, 40)
.background(Color.themeTabBarColor)
.onTapGesture {
showExpandedAudioPlayer = true
}
}
}
#endif
}
#if os(macOS)

View file

@ -7,7 +7,7 @@ struct WebReaderContent {
let textFontSize: Int
let lineHeight: Int
let maxWidthPercentage: Int
let item: LinkedItem
let item: LibraryItem
let isDark: Bool
let themeKey: String
let fontFamily: WebFont
@ -17,7 +17,7 @@ struct WebReaderContent {
let justifyText: Bool
init(
item: LinkedItem,
item: Models.LibraryItem,
articleContent: ArticleContent,
isDark: Bool,
fontSize: Int,

View file

@ -6,7 +6,7 @@ import Utils
import Views
@MainActor final class WebReaderLoadingContainerViewModel: ObservableObject {
@Published var item: LinkedItem?
@Published var item: Models.LibraryItem?
@Published var errorMessage: String?
func loadItem(dataService: DataService, username: String, requestID: String) async {
@ -15,7 +15,7 @@ import Views
else {
return
}
item = dataService.viewContext.object(with: objectID) as? LinkedItem
item = dataService.viewContext.object(with: objectID) as? Models.LibraryItem
}
func trackReadEvent() {
@ -38,7 +38,6 @@ public struct WebReaderLoadingContainer: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@State var lazyPopIsEnabled = true
@StateObject var viewModel = WebReaderLoadingContainerViewModel()
public var body: some View {
@ -49,7 +48,7 @@ public struct WebReaderLoadingContainer: View {
.navigationBarHidden(true)
.navigationViewStyle(.stack)
.accentColor(.appGrayTextContrast)
.task { viewModel.trackReadEvent() }
.onAppear { viewModel.trackReadEvent() }
#else
if let pdfURL = pdfItem.pdfURL {
PDFWrapperView(pdfURL: pdfURL)
@ -59,11 +58,9 @@ public struct WebReaderLoadingContainer: View {
WebReaderContainerView(item: item, pop: { dismiss() })
#if os(iOS)
.navigationViewStyle(.stack)
.navigationBarHidden(true)
.lazyPop(pop: { dismiss() }, isEnabled: $lazyPopIsEnabled)
#endif
.accentColor(.appGrayTextContrast)
.task { viewModel.trackReadEvent() }
.onAppear { viewModel.trackReadEvent() }
}
} else if let errorMessage = viewModel.errorMessage {
Text(errorMessage)

View file

@ -24,7 +24,7 @@ struct SafariWebLink: Identifiable {
showSnackbar = true
}
func hasOriginalUrl(_ item: LinkedItem) -> Bool {
func hasOriginalUrl(_ item: Models.LibraryItem) -> Bool {
if let pageURLString = item.pageURLString, let host = URL(string: pageURLString)?.host {
if host == "omnivore.app" {
return false
@ -34,7 +34,7 @@ struct SafariWebLink: Identifiable {
return false
}
func downloadAudio(audioController: AudioController, item: LinkedItem) {
func downloadAudio(audioController: AudioController, item: Models.LibraryItem) {
snackbar(message: "Downloading Offline Audio")
isDownloadingAudio = true

View file

@ -28,20 +28,21 @@
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="createdBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserProfile"/>
<relationship name="labels" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="highlights" inverseEntity="LinkedItemLabel"/>
<relationship name="linkedItem" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="highlights" inverseEntity="LinkedItem"/>
<relationship name="linkedItem" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LibraryItem" inverseName="highlights" inverseEntity="LibraryItem"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="LinkedItem" representedClassName="LinkedItem" syncable="YES" codeGenerationType="class">
<entity name="LibraryItem" representedClassName="LibraryItem" syncable="YES" codeGenerationType="class">
<attribute name="author" optional="YES" attributeType="String"/>
<attribute name="contentReader" optional="YES" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdId" optional="YES" attributeType="String"/>
<attribute name="descriptionText" optional="YES" attributeType="String"/>
<attribute name="downloadURL" optional="YES" attributeType="String"/>
<attribute name="folder" optional="YES" attributeType="String"/>
<attribute name="htmlContent" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="imageURLString" optional="YES" attributeType="String"/>
@ -86,7 +87,7 @@
<attribute name="name" attributeType="String"/>
<attribute name="serverSyncStatus" attributeType="Integer 64" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="highlights" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Highlight" inverseName="labels" inverseEntity="Highlight"/>
<relationship name="linkedItems" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="labels" inverseEntity="LinkedItem"/>
<relationship name="linkedItems" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LibraryItem" inverseName="labels" inverseEntity="LibraryItem"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
@ -113,7 +114,7 @@
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="recommendedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="linkeditem" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="recommendations" inverseEntity="LinkedItem"/>
<relationship name="linkeditem" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LibraryItem" inverseName="recommendations" inverseEntity="LibraryItem"/>
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserProfile"/>
</entity>
<entity name="RecommendationGroup" representedClassName="RecommendationGroup" syncable="YES" codeGenerationType="class">

View file

@ -47,6 +47,7 @@ public struct JSONArticle: Decodable {
public let updatedAt: Date
public let savedAt: Date
public let readAt: Date?
public let folder: String
public let image: String
public let readingProgressPercent: Double
public let readingProgressAnchorIndex: Int
@ -59,7 +60,7 @@ public struct JSONArticle: Decodable {
public let downloadURL: String
}
public extension LinkedItem {
public extension LibraryItem {
var unwrappedID: String { id ?? "" }
var unwrappedSlug: String { slug ?? "" }
var unwrappedTitle: String { title ?? "" }
@ -247,13 +248,13 @@ public extension LinkedItem {
)
}
static func lookup(byID itemID: String, inContext context: NSManagedObjectContext) -> LinkedItem? {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
static func lookup(byID itemID: String, inContext context: NSManagedObjectContext) -> LibraryItem? {
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "id == %@", itemID
)
var item: LinkedItem?
var item: LibraryItem?
context.performAndWait {
item = (try? context.fetch(fetchRequest))?.first

View file

@ -15,9 +15,9 @@ public struct PDFItem {
public let isArchived: Bool
public let isRead: Bool
public let downloadURL: String
public let highlights: [Highlight]
public var highlights: [Highlight]
public static func make(item: LinkedItem) -> PDFItem? {
public static func make(item: LibraryItem) -> PDFItem? {
guard item.isPDF else { return nil }
return PDFItem(

View file

@ -6,4 +6,5 @@ public enum ServerSyncStatus: Int {
case needsDeletion
case needsCreation
case needsUpdate
case needsMove
}

View file

@ -94,92 +94,92 @@ import Foundation
// }
// }
public enum FeaturedItemFilter: String, CaseIterable {
case continueReading
case recommended
case newsletters
case pinned
}
public extension FeaturedItemFilter {
var title: String {
switch self {
case .continueReading:
return "Continue Reading"
case .recommended:
return "Recommended"
case .newsletters:
return "Newsletters"
case .pinned:
return "Pinned"
}
}
var emptyMessage: String {
switch self {
case .continueReading:
return "Your recently read items will appear here."
case .pinned:
return "Create a label named Pinned and add it to items you would like to appear here."
case .recommended:
return "Reads recommended in your Clubs will appear here."
case .newsletters:
return "All your Newsletters will appear here."
}
}
var predicate: NSPredicate {
let undeletedPredicate = NSPredicate(
format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
)
let notInArchivePredicate = NSPredicate(
format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber
)
switch self {
case .continueReading:
// Use > 1 instead of 0 so its only reads they have made slight progress on.
let continueReadingPredicate = NSPredicate(
format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
continueReadingPredicate, undeletedPredicate, notInArchivePredicate
])
case .pinned:
let pinnedPredicate = NSPredicate(
format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
notInArchivePredicate, undeletedPredicate, pinnedPredicate
])
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: [
notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate
])
case .recommended:
// non-archived or deleted items with the Newsletter label
let recommendedPredicate = NSPredicate(
format: "recommendations.@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
notInArchivePredicate, undeletedPredicate, recommendedPredicate
])
}
}
var sortDescriptor: NSSortDescriptor {
let savedAtSort = NSSortDescriptor(key: #keyPath(LinkedItem.savedAt), ascending: false)
switch self {
case .continueReading:
return NSSortDescriptor(key: #keyPath(LinkedItem.readAt), ascending: false)
case .pinned:
return NSSortDescriptor(key: #keyPath(LinkedItem.updatedAt), ascending: false)
default:
return savedAtSort
}
}
}
// public enum FeaturedItemFilter: String, CaseIterable {
// case continueReading
// case recommended
// case newsletters
// case pinned
// }
//
// public extension FeaturedItemFilter {
// var title: String {
// switch self {
// case .continueReading:
// return "Continue Reading"
// case .recommended:
// return "Recommended"
// case .newsletters:
// return "Newsletters"
// case .pinned:
// return "Pinned"
// }
// }
//
// var emptyMessage: String {
// switch self {
// case .continueReading:
// return "Your recently read items will appear here."
// case .pinned:
// return "Create a label named Pinned and add it to items you would like to appear here."
// case .recommended:
// return "Reads recommended in your Clubs will appear here."
// case .newsletters:
// return "All your Newsletters will appear here."
// }
// }
//
// var predicate: NSPredicate {
// let undeletedPredicate = NSPredicate(
// format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
// )
// let notInArchivePredicate = NSPredicate(
// format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber
// )
//
// switch self {
// case .continueReading:
// // Use > 1 instead of 0 so its only reads they have made slight progress on.
// let continueReadingPredicate = NSPredicate(
// format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil"
// )
// return NSCompoundPredicate(andPredicateWithSubpredicates: [
// continueReadingPredicate, undeletedPredicate, notInArchivePredicate
// ])
// case .pinned:
// let pinnedPredicate = NSPredicate(
// format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0"
// )
// return NSCompoundPredicate(andPredicateWithSubpredicates: [
// notInArchivePredicate, undeletedPredicate, pinnedPredicate
// ])
// 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: [
// notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate
// ])
// case .recommended:
// // non-archived or deleted items with the Newsletter label
// let recommendedPredicate = NSPredicate(
// format: "recommendations.@count > 0"
// )
// return NSCompoundPredicate(andPredicateWithSubpredicates: [
// notInArchivePredicate, undeletedPredicate, recommendedPredicate
// ])
// }
// }
//
// var sortDescriptor: NSSortDescriptor {
// let savedAtSort = NSSortDescriptor(key: #keyPath(LinkedItem.savedAt), ascending: false)
// switch self {
// case .continueReading:
// return NSSortDescriptor(key: #keyPath(LinkedItem.readAt), ascending: false)
// case .pinned:
// return NSSortDescriptor(key: #keyPath(LinkedItem.updatedAt), ascending: false)
// default:
// return savedAtSort
// }
// }
// }

View file

@ -0,0 +1,227 @@
import Foundation
public enum LinkedItemFilter: String, CaseIterable {
case inbox
case feeds
case readlater
case newsletters
case downloaded
case recommended
case all
case archived
case deleted
case hasHighlights
case files
}
public extension LinkedItemFilter {
var queryString: String {
switch self {
case .inbox:
return "in:inbox"
case .feeds:
return "label:RSS"
case .readlater:
return "in:library"
case .downloaded:
return ""
case .newsletters:
return "in:inbox label:Newsletter"
case .recommended:
return "recommendedBy:*"
case .all:
return "in:all"
case .archived:
return "in:archive"
case .deleted:
return "in:trash"
case .hasHighlights:
return "has:highlights"
case .files:
return "type:file"
}
}
var allowLocalFetch: Bool {
switch self {
case .inbox:
return true
default:
return false
}
}
var predicate: NSPredicate {
let undeletedPredicate = NSPredicate(
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
)
switch self {
case .inbox:
// non-archived items
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate])
case .readlater:
// non-archived or deleted items without the Newsletter label
let nonNewsletterLabelPredicate = NSPredicate(
format: "NOT SUBQUERY(labels, $label, $label.name == \"Newsletter\") .@count > 0"
)
let nonRSSPredicate = NSPredicate(
format: "NOT SUBQUERY(labels, $label, $label.name == \"RSS\") .@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate
])
case .downloaded:
// include pdf only
let hasHTMLContent = NSPredicate(
format: "htmlContent.length > 0"
)
let isPDFPredicate = NSPredicate(
format: "%K == %@", #keyPath(LibraryItem.contentReader), "PDF"
)
let localPDFURL = NSPredicate(
format: "localPDF.length > 0"
)
let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [isPDFPredicate, localPDFURL])
return NSCompoundPredicate(orPredicateWithSubpredicates: [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: [notInArchivePredicate, newsletterLabelPredicate])
case .feeds:
let feedLabelPredicate = NSPredicate(
format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, feedLabelPredicate])
case .recommended:
// non-archived or deleted items with the Newsletter label
let recommendedPredicate = NSPredicate(
format: "recommendations.@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, recommendedPredicate])
case .all:
// include everything undeleted
return undeletedPredicate
case .archived:
let inArchivePredicate = NSPredicate(
format: "%K == %@", #keyPath(LibraryItem.isArchived), Int(truncating: true) as NSNumber
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate])
case .deleted:
let deletedPredicate = NSPredicate(
format: "%K == %i", #keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate])
case .files:
// include pdf only
let isPDFPredicate = NSPredicate(
format: "%K == %@", #keyPath(LibraryItem.contentReader), "PDF"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate])
case .hasHighlights:
let hasHighlightsPredicate = NSPredicate(
format: "highlights.@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
hasHighlightsPredicate
])
}
}
}
public enum FeaturedItemFilter: String, CaseIterable {
case continueReading
case recommended
case newsletters
case pinned
}
public extension FeaturedItemFilter {
var title: String {
switch self {
case .continueReading:
return "Continue Reading"
case .recommended:
return "Recommended"
case .newsletters:
return "Newsletters"
case .pinned:
return "Pinned"
}
}
var emptyMessage: String {
switch self {
case .continueReading:
return "Your recently read items will appear here."
case .pinned:
return "Create a label named Pinned and add it to items you would like to appear here."
case .recommended:
return "Reads recommended in your Clubs will appear here."
case .newsletters:
return "All your Newsletters will appear here."
}
}
var predicate: NSPredicate {
let undeletedPredicate = NSPredicate(
format: "%K != %i", #keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
)
let notInArchivePredicate = NSPredicate(
format: "%K == %@", #keyPath(LibraryItem.isArchived), Int(truncating: false) as NSNumber
)
switch self {
case .continueReading:
// Use > 1 instead of 0 so its only reads they have made slight progress on.
let continueReadingPredicate = NSPredicate(
format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
continueReadingPredicate, undeletedPredicate, notInArchivePredicate
])
case .pinned:
let pinnedPredicate = NSPredicate(
format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
notInArchivePredicate, undeletedPredicate, pinnedPredicate
])
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: [
notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate
])
case .recommended:
// non-archived or deleted items with the Newsletter label
let recommendedPredicate = NSPredicate(
format: "recommendations.@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
notInArchivePredicate, undeletedPredicate, recommendedPredicate
])
}
}
var sortDescriptor: NSSortDescriptor {
let savedAtSort = NSSortDescriptor(key: #keyPath(LibraryItem.savedAt), ascending: false)
switch self {
case .continueReading:
return NSSortDescriptor(key: #keyPath(LibraryItem.readAt), ascending: false)
case .pinned:
return NSSortDescriptor(key: #keyPath(LibraryItem.updatedAt), ascending: false)
default:
return savedAtSort
}
}
}

View file

@ -3,6 +3,8 @@ import Foundation
public enum LinkedItemSort: String, CaseIterable {
case newest
case oldest
case shortest
case longest
case recentlyRead
case recentlyPublished
}
@ -14,6 +16,10 @@ public extension LinkedItemSort {
return "sort:saved"
case .oldest:
return "sort:saved-ASC"
case .longest:
return "sort:wordsCount-desc"
case .shortest:
return "sort:wordsCount-asc"
case .recentlyRead:
return "sort:read"
case .recentlyPublished:
@ -24,16 +30,20 @@ public extension LinkedItemSort {
var sortDescriptors: [NSSortDescriptor] {
switch self {
case .newest:
return [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false)]
return [NSSortDescriptor(keyPath: \LibraryItem.savedAt, ascending: false)]
case .oldest:
return [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: true)]
return [NSSortDescriptor(keyPath: \LibraryItem.savedAt, ascending: true)]
case .shortest:
return [NSSortDescriptor(keyPath: \LibraryItem.wordsCount, ascending: true)]
case .longest:
return [NSSortDescriptor(keyPath: \LibraryItem.wordsCount, ascending: false)]
case .recentlyRead:
return [
NSSortDescriptor(keyPath: \LinkedItem.readAt, ascending: false),
NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false)
NSSortDescriptor(keyPath: \LibraryItem.readAt, ascending: false),
NSSortDescriptor(keyPath: \LibraryItem.savedAt, ascending: false)
]
case .recentlyPublished:
return [NSSortDescriptor(keyPath: \LinkedItem.publishDate, ascending: false)]
return [NSSortDescriptor(keyPath: \LibraryItem.publishDate, ascending: false)]
}
}
}

View file

@ -10,7 +10,7 @@ struct PendingLink {
extension DataService {
func prefetchPage(pendingLink: PendingLink, username: String) async {
let content = try? await loadArticleContent(username: username, itemID: pendingLink.itemID, useCache: false)
let content = try? await loadArticleContent(username: username, itemID: pendingLink.itemID, useCache: true)
if content?.contentStatus == .processing, pendingLink.retryCount < 7 {
let retryDelayInNanoSeconds = UInt64(pendingLink.retryCount * 2 * 1_000_000_000)
@ -69,7 +69,7 @@ extension DataService {
}
func cachedArticleContent(itemID: String) async -> ArticleContent? {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
let linkedItemFetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate(
format: "id == %@", itemID
)
@ -105,11 +105,11 @@ extension DataService {
await backgroundContext.perform { [weak self] in
guard let self = self else { return }
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", articleProps.item.id)
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
let linkedItem = existingItem ?? LibraryItem(entity: LibraryItem.entity(), insertInto: self.backgroundContext)
objectID = linkedItem.objectID
let highlightObjects = articleProps.highlights.map {
@ -190,14 +190,14 @@ extension DataService {
/// - Returns: The id of the CoreData object if found.
func linkedItemID(from requestID: String) async -> String? {
await backgroundContext.perform(schedule: .immediate) {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "createdId == %@ OR id == %@", requestID, requestID)
return try? self.backgroundContext.fetch(fetchRequest).first?.unwrappedID
}
}
func syncUnsyncedArticleContent(itemID: String) async {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
let linkedItemFetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate(
format: "id == %@", itemID
)

View file

@ -69,7 +69,7 @@ public final class DataService: ObservableObject {
}
public func cleanupDeletedItems(in context: NSManagedObjectContext) {
let fetchRequest: NSFetchRequest<LinkedItem> = LinkedItem.fetchRequest()
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
let calendar = Calendar.current
let oneDayAgo = calendar.date(byAdding: .day, value: -1, to: Date())!
@ -219,12 +219,12 @@ public final class DataService: ObservableObject {
try await backgroundContext.perform { [weak self] in
guard let self = self else { return }
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "pageURLString = %@", normalizedURL)
let currentTime = Date()
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
let linkedItem = existingItem ?? LibraryItem(entity: LibraryItem.entity(), insertInto: self.backgroundContext)
linkedItem.createdId = requestId
linkedItem.id = existingItem?.unwrappedID ?? requestId

View file

@ -48,7 +48,7 @@ extension DataService {
}
func itemsNotInStore(from itemIDs: [String]) async -> [String] {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id IN %@", itemIDs)
return await backgroundContext.perform(schedule: .immediate) {

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@ extension DataService {
// Update CoreData
backgroundContext.perform { [weak self] in
guard let self = self else { return }
guard let linkedItem = self.backgroundContext.object(with: objectID) as? LinkedItem else { return }
guard let linkedItem = self.backgroundContext.object(with: objectID) as? LibraryItem else { return }
linkedItem.update(inContext: self.backgroundContext, newIsArchivedValue: archived)
// Send update to server
@ -54,7 +54,7 @@ extension DataService {
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
context.perform {
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return }
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
do {

View file

@ -29,7 +29,7 @@ public extension DataService {
// If the item is still available locally, update its state
backgroundContext.performAndWait {
items.forEach { itemID in
if let linkedItem = LinkedItem.lookup(byID: itemID, inContext: backgroundContext) {
if let linkedItem = Models.LibraryItem.lookup(byID: itemID, inContext: backgroundContext) {
if action == .delete {
linkedItem.state = "DELETED"
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)

View file

@ -0,0 +1,72 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func moveItem(itemID: String, folder: String) async throws {
backgroundContext.performAndWait {
if let linkedItem = Models.LibraryItem.lookup(byID: itemID, inContext: backgroundContext) {
linkedItem.folder = folder
linkedItem.savedAt = Date()
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsUpdate.rawValue)
}
do {
try backgroundContext.save()
logger.debug("LinkedItem updated succesfully")
} catch {
backgroundContext.rollback()
logger.debug("Failed to update LinkedItem: \(error.localizedDescription)")
}
}
try await syncMoveToFolder(itemID: itemID, folder: folder)
}
func syncMoveToFolder(itemID: String, folder: String) async throws {
enum MutationResult {
case result(success: Bool)
case error(errorMessage: String)
}
let selection = Selection<MutationResult, Unions.MoveToFolderResult> {
try $0.on(
moveToFolderError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") },
moveToFolderSuccess: .init {
.result(success: try $0.success())
}
)
}
let mutation = Selection.Mutation {
try $0.moveToFolder(
folder: folder,
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

@ -4,19 +4,19 @@ import Models
import SwiftGraphQL
public extension DataService {
func removeLink(objectID: NSManagedObjectID, sync: Bool = true) {
func removeLibraryItem(objectID: NSManagedObjectID, sync: Bool = true) {
// First try to get the item synchronously, this is used later to delete files
// Then we can async update core data and make the API call to sync the deletion
var linkedItemID: String?
viewContext.performAndWait {
guard let linkedItem = self.viewContext.object(with: objectID) as? LinkedItem else { return }
guard let linkedItem = self.viewContext.object(with: objectID) as? LibraryItem else { return }
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
linkedItemID = linkedItem.id
}
viewContext.perform {
guard let linkedItem = self.viewContext.object(with: objectID) as? LinkedItem else { return }
guard let linkedItem = self.viewContext.object(with: objectID) as? LibraryItem else { return }
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
linkedItemID = linkedItem.id
@ -78,7 +78,7 @@ public extension DataService {
let isSyncSuccess = data != nil
context.perform {
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return }
if isSyncSuccess {
linkedItem.remove(inContext: context)

View file

@ -8,7 +8,7 @@ public extension DataService {
var itemUpdatedLocal = false
// If the item is still available locally, update its state
backgroundContext.performAndWait {
if let linkedItem = LinkedItem.lookup(byID: itemID, inContext: backgroundContext) {
if let linkedItem = LibraryItem.lookup(byID: itemID, inContext: backgroundContext) {
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsUpdate.rawValue)
do {

View file

@ -7,7 +7,7 @@ public extension DataService {
func setItemLabels(itemID: String, labels: [InternalLinkedItemLabel]) {
backgroundContext.perform { [weak self] in
guard let self = self else { return }
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
if let existingLabels = linkedItem.labels {
linkedItem.removeFromLabels(existingLabels)
@ -65,7 +65,7 @@ public extension DataService {
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
context.perform {
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return }
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
do {

View file

@ -7,7 +7,7 @@ public extension DataService {
func updateLinkListeningProgress(itemID: String, listenIndex: Int, listenOffset: Double, listenTime: Double) {
backgroundContext.perform { [weak self] in
guard let self = self else { return }
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
linkedItem.update(
inContext: self.backgroundContext,

View file

@ -7,7 +7,7 @@ extension DataService {
public func updateLinkReadingProgress(itemID: String, readingProgress: Double, anchorIndex: Int, force: Bool?) {
backgroundContext.perform { [weak self] in
guard let self = self else { return }
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
if let force = force, !force {
if readingProgress != 0, readingProgress < linkedItem.readingProgress {
@ -70,7 +70,7 @@ extension DataService {
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
context.perform {
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return }
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
if let mutationResult = data?.data, case let MutationResult.saved(readAt) = mutationResult {
linkedItem.readAt = readAt

View file

@ -7,7 +7,7 @@ extension DataService {
public func updateLinkedItemTitleAndDescription(itemID: String, title: String, description: String, author: String?) {
backgroundContext.perform { [weak self] in
guard let self = self else { return }
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
linkedItem.update(
inContext: self.backgroundContext,
@ -65,7 +65,7 @@ extension DataService {
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
context.perform {
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return }
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
do {

View file

@ -5,11 +5,11 @@ import Utils
public extension DataService {
func syncOfflineItemsWithServerIfNeeded() async throws {
var unsyncedLinkedItems = [LinkedItem]()
var unsyncedLinkedItems = [LibraryItem]()
var unsyncedHighlights = [Highlight]()
// LinkedItems
let itemsFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
let itemsFetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
itemsFetchRequest.predicate = NSPredicate(
format: "serverSyncStatus != %i", Int64(ServerSyncStatus.isNSync.rawValue)
)
@ -37,7 +37,7 @@ public extension DataService {
private func updateLinkedItemStatus(id: String, newId: String?, status: ServerSyncStatus) async throws {
backgroundContext.performAndWait {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
guard let linkedItem = (try? backgroundContext.fetch(fetchRequest))?.first else { return }
@ -107,7 +107,7 @@ public extension DataService {
}
}
func syncLocalCreatedLinkedItem(item: LinkedItem) {
func syncLocalCreatedLinkedItem(item: LibraryItem) {
switch item.contentReader {
case "PDF":
let id = item.unwrappedID
@ -135,7 +135,7 @@ public extension DataService {
}
}
private func syncLinkedItems(unsyncedLinkedItems: [LinkedItem]) {
private func syncLinkedItems(unsyncedLinkedItems: [LibraryItem]) {
for item in unsyncedLinkedItems {
guard let syncStatus = ServerSyncStatus(rawValue: Int(item.serverSyncStatus)) else { continue }
@ -157,6 +157,15 @@ public extension DataService {
anchorIndex: Int(item.readingProgressAnchor),
force: item.isPDF
)
case .needsMove:
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
syncLinkArchiveStatus(itemID: item.unwrappedID, archived: item.isArchived)
syncLinkReadingProgress(
itemID: item.unwrappedID,
readingProgress: item.readingProgress,
anchorIndex: Int(item.readingProgressAnchor),
force: item.isPDF
)
}
}
}
@ -184,6 +193,9 @@ public extension DataService {
} else {
highlight.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
}
case .needsMove:
// Highlights can't be moved
break
}
}
}

View file

@ -13,7 +13,7 @@ public extension DataService {
) async throws -> LinkedItemSyncResult {
let fetchResult = try await linkedItemUpdates(since: since, limit: 20, cursor: cursor, descending: descending)
LinkedItem.deleteItems(ids: fetchResult.deletedItemIDs, context: backgroundContext)
LibraryItem.deleteItems(ids: fetchResult.deletedItemIDs, context: backgroundContext)
if fetchResult.items.persist(context: backgroundContext) == nil {
throw BasicError.message(messageText: "CoreData error")

View file

@ -9,7 +9,13 @@ public extension DataService {
throw BasicError.message(messageText: "No PDF URL found")
}
let result: (Data, URLResponse)? = try? await URLSession.shared.data(from: url)
var result: (Data, URLResponse)?
do {
let request = URLRequest(url: url, timeoutInterval: 120)
result = try await URLSession.shared.data(for: request)
} catch {
print("ERROR DOWNLOADING PDF DATA: ", error)
}
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
throw BasicError.message(messageText: "pdfFetch failed. no response or bad status code.")
@ -25,8 +31,8 @@ public extension DataService {
.appendingPathComponent(UUID().uuidString + ".pdf")
try await backgroundContext.perform { [weak self] in
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LinkedItem.slug), slug)
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.slug), slug)
let linkedItem = try? self?.backgroundContext.fetch(fetchRequest).first
guard let linkedItem = linkedItem else {

View file

@ -5,7 +5,7 @@ import SwiftGraphQL
import Utils
struct ArticleProps {
let item: InternalLinkedItem
let item: InternalLibraryItem
let htmlContent: String
let highlights: [InternalHighlight]
}
@ -20,13 +20,14 @@ extension DataService {
let articleContentSelection = Selection.Article {
ArticleProps(
item: InternalLinkedItem(
item: InternalLibraryItem(
id: try $0.id(),
title: try $0.title(),
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
readAt: try $0.readAt()?.value,
updatedAt: try $0.updatedAt()?.value ?? Date(),
folder: try $0.folder(),
state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded,
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),

View file

@ -4,12 +4,12 @@ import Models
import SwiftGraphQL
struct InternalLinkedItemQueryResult {
let items: [InternalLinkedItem]
let items: [InternalLibraryItem]
let cursor: String?
}
struct InternalLinkedItemUpdatesQueryResult {
let items: [InternalLinkedItem]
let items: [InternalLibraryItem]
let deletedItemIDs: [String]
let cursor: String?
let hasMoreItems: Bool
@ -19,7 +19,7 @@ struct InternalLinkedItemUpdatesQueryResult {
private struct SyncItemEdge {
let itemID: String
let isDeletedItem: Bool
let item: InternalLinkedItem?
let item: InternalLibraryItem?
}
extension DataService {
@ -90,7 +90,7 @@ extension DataService {
switch payload.data {
case let .success(result: result):
var items = [InternalLinkedItem]()
var items = [InternalLibraryItem]()
var deletedItemIDs = [String]()
for edge in result.edges {
@ -186,13 +186,13 @@ extension DataService {
/// - itemID: id of the item being requested
/// - Returns: Returns an `InternalLinkedItem` or throws a `ContentFetchError` if
/// request could not be completed
func fetchLinkedItem(username: String, itemID: String) async throws -> InternalLinkedItem {
func fetchLinkedItem(username: String, itemID: String) async throws -> InternalLibraryItem {
struct ArticleProps {
let item: InternalLinkedItem
let item: InternalLibraryItem
}
enum QueryResult {
case success(result: InternalLinkedItem)
case success(result: InternalLibraryItem)
case error(error: String)
}
@ -252,13 +252,14 @@ let recommendationSelection = Selection.Recommendation {
}
private let libraryArticleSelection = Selection.Article {
InternalLinkedItem(
InternalLibraryItem(
id: try $0.id(),
title: try $0.title(),
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
readAt: try $0.readAt()?.value,
updatedAt: try $0.updatedAt()?.value ?? Date(),
folder: try $0.folder(),
state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded,
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
@ -292,13 +293,14 @@ private let syncItemEdgeSelection = Selection.SyncUpdatedItemEdge {
}
private let searchItemSelection = Selection.SearchItem {
InternalLinkedItem(
InternalLibraryItem(
id: try $0.id(),
title: try $0.title(),
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
readAt: try $0.readAt()?.value,
updatedAt: try $0.updatedAt()?.value ?? Date(),
folder: try $0.folder(),
state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded,
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),

View file

@ -6,6 +6,7 @@ let filterSelection = Selection.Filter {
InternalFilter(
id: try $0.id(),
name: try $0.name(),
folder: try $0.folder(),
filter: try $0.filter(),
visible: try $0.visible() ?? true,
position: try $0.position(),

View file

@ -2,18 +2,24 @@ import CoreData
import Foundation
import Models
public struct InternalFilter: Encodable, Identifiable, Hashable {
public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
public let id: String
public let name: String
public let folder: String
public let filter: String
public let visible: Bool
public let position: Int
public let defaultFilter: Bool
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
}
public static var DownloadedFilter: InternalFilter {
InternalFilter(
id: "downloaded",
name: "Downloaded",
folder: "inbox",
filter: "",
visible: true,
position: -1,
@ -25,6 +31,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
InternalFilter(
id: "deleted",
name: "Deleted",
folder: "inbox",
filter: "in:trash",
visible: true,
position: -1,
@ -32,11 +39,12 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
)
}
public static var DefaultFilters: [InternalFilter] {
public static var DefaultInboxFilters: [InternalFilter] {
[
InternalFilter(
id: "inbox",
name: "Inbox",
folder: "inbox",
filter: "",
visible: true,
position: 0,
@ -45,6 +53,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
InternalFilter(
id: "non-feed-items",
name: "Non-Feed Items",
folder: "inbox",
filter: "",
visible: true,
position: 1,
@ -53,6 +62,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
InternalFilter(
id: "newsletters",
name: "Newsletters",
folder: "inbox",
filter: "",
visible: true,
position: 2,
@ -61,6 +71,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
InternalFilter(
id: "feeds",
name: "Feeds",
folder: "inbox",
filter: "",
visible: true,
position: 3,
@ -69,6 +80,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
InternalFilter(
id: "archived",
name: "Archived",
folder: "inbox",
filter: "is:archived",
visible: true,
position: 4,
@ -77,6 +89,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
InternalFilter(
id: "files",
name: "Files",
folder: "inbox",
filter: "type:file",
visible: true,
position: 5,
@ -85,6 +98,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
InternalFilter(
id: "highlighted",
name: "Highlights",
folder: "inbox",
filter: "has:highlights",
visible: true,
position: 6,
@ -93,6 +107,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
InternalFilter(
id: "all",
name: "All",
folder: "inbox",
filter: "in:all",
visible: true,
position: 7,
@ -101,6 +116,20 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
]
}
public static var DefaultFollowingFilters: [InternalFilter] {
[
InternalFilter(
id: "following",
name: "RSS",
folder: "following",
filter: "in:following label:RSS",
visible: true,
position: 1,
defaultFilter: true
)
]
}
public var shouldRemoteSearch: Bool {
id != "downloaded"
}
@ -120,14 +149,19 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
let undeletedPredicate = NSPredicate(
format: "%K != %i AND %K != \"DELETED\"",
#keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
#keyPath(LinkedItem.state)
#keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
#keyPath(Models.LibraryItem.state)
)
let notInArchivePredicate = NSPredicate(
format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber
format: "%K == %@", #keyPath(Models.LibraryItem.isArchived), Int(truncating: false) as NSNumber
)
switch name {
case "RSS":
let feedLabelPredicate = NSPredicate(
format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, undeletedPredicate, feedLabelPredicate])
case "Inbox":
// non-archived items
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate])
@ -148,47 +182,49 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
format: "htmlContent.length > 0"
)
let isPDFPredicate = NSPredicate(
format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF"
format: "%K == %@", #keyPath(Models.LibraryItem.contentReader), "PDF"
)
let localPDFURL = NSPredicate(
format: "localPDF.length > 0"
)
let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [isPDFPredicate, localPDFURL])
let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate, localPDFURL])
return NSCompoundPredicate(orPredicateWithSubpredicates: [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: [notInArchivePredicate, newsletterLabelPredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, newsletterLabelPredicate])
case "Feeds":
let feedLabelPredicate = NSPredicate(
format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, feedLabelPredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, feedLabelPredicate])
case "Recommended":
// non-archived or deleted items with the Newsletter label
let recommendedPredicate = NSPredicate(
format: "recommendations.@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, recommendedPredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, recommendedPredicate])
case "All":
// include everything undeleted
return undeletedPredicate
case "Archived":
let inArchivePredicate = NSPredicate(
format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: true) as NSNumber
format: "%K == %@", #keyPath(Models.LibraryItem.isArchived), Int(truncating: true) as NSNumber
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate])
case "Deleted":
let deletedPredicate = NSPredicate(
format: "%K == %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
format: "%K == %i OR %K == \"DELETED\"",
#keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
#keyPath(Models.LibraryItem.state)
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate])
case "Files":
// include pdf only
let isPDFPredicate = NSPredicate(
format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF"
format: "%K == %@", #keyPath(Models.LibraryItem.contentReader), "PDF"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate])
case "Highlights":
@ -196,6 +232,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
format: "highlights.@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
undeletedPredicate,
hasHighlightsPredicate
])
default:
@ -227,6 +264,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
let newFilter = existing ?? Filter(entity: Filter.entity(), insertInto: context)
newFilter.id = id
newFilter.name = name
newFilter.folder = folder
newFilter.filter = filter
newFilter.visible = visible
newFilter.position = Int64(position)
@ -238,11 +276,13 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
filters.compactMap { filter in
if let id = filter.id,
let name = filter.name,
let folder = filter.folder,
let filterStr = filter.filter
{
return InternalFilter(
id: id,
name: name,
folder: folder,
filter: filterStr,
visible: filter.visible,
position: Int(filter.position),
@ -264,7 +304,6 @@ public extension Filter {
)
var filter: Filter?
context.performAndWait {
filter = (try? context.fetch(fetchRequest))?.first
}

View file

@ -91,7 +91,7 @@ struct InternalHighlight: Encodable {
let highlight = asManagedObject(context: context)
if let associatedItemID = associatedItemID {
let linkedItem = LinkedItem.lookup(byID: associatedItemID, inContext: context)
let linkedItem = LibraryItem.lookup(byID: associatedItemID, inContext: context)
linkedItem?.addToHighlights(highlight)
}

View file

@ -2,13 +2,14 @@ import CoreData
import Foundation
import Models
struct InternalLinkedItem {
struct InternalLibraryItem {
let id: String
let title: String
let createdAt: Date
let savedAt: Date
let readAt: Date?
let updatedAt: Date
let folder: String
let state: ArticleContentStatus
var readingProgress: Double
var readingProgressAnchor: Int
@ -38,9 +39,9 @@ struct InternalLinkedItem {
return pageURLString.hasSuffix("pdf")
}
func asManagedObject(inContext context: NSManagedObjectContext) -> LinkedItem {
let existingItem = LinkedItem.lookup(byID: id, inContext: context)
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: context)
func asManagedObject(inContext context: NSManagedObjectContext) -> LibraryItem {
let existingItem = LibraryItem.lookup(byID: id, inContext: context)
let linkedItem = existingItem ?? LibraryItem(entity: LibraryItem.entity(), insertInto: context)
linkedItem.id = id
linkedItem.title = title
@ -48,6 +49,7 @@ struct InternalLinkedItem {
linkedItem.savedAt = savedAt
linkedItem.updatedAt = updatedAt
linkedItem.readAt = readAt
linkedItem.folder = folder
linkedItem.state = state.rawValue
linkedItem.readingProgress = readingProgress
linkedItem.readingProgressAnchor = Int64(readingProgressAnchor)
@ -89,9 +91,9 @@ struct InternalLinkedItem {
}
}
extension Sequence where Element == InternalLinkedItem {
extension Sequence where Element == InternalLibraryItem {
func persist(context: NSManagedObjectContext) -> [NSManagedObjectID]? {
var linkedItems: [LinkedItem]?
var linkedItems: [LibraryItem]?
context.performAndWait {
linkedItems = map { $0.asManagedObject(inContext: context) }
@ -123,13 +125,14 @@ extension JSONArticle {
func persistAsLinkedItem(context: NSManagedObjectContext) -> NSManagedObjectID? {
var objectID: NSManagedObjectID?
let internalLinkedItem = InternalLinkedItem(
let internalLinkedItem = InternalLibraryItem(
id: id,
title: title,
createdAt: createdAt,
savedAt: savedAt,
readAt: readAt,
updatedAt: updatedAt,
folder: folder,
state: .succeeded,
readingProgress: readingProgressPercent,
readingProgressAnchor: readingProgressAnchorIndex,

View file

@ -12,6 +12,7 @@ public extension NSNotification {
static let SpeakingReaderItem = Notification.Name("SpeakingReaderItem")
static let DisplayProfile = Notification.Name("DisplayProfile")
static let Logout = Notification.Name("Logout")
static let ScrollToTop = Notification.Name("ScrollToTop")
static var pushFeedItemPublisher: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: PushJSONArticle)
@ -49,6 +50,10 @@ public extension NSNotification {
NotificationCenter.default.publisher(for: Logout)
}
static var scrollToTopPublisher: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: ScrollToTop)
}
internal var operationMessage: String? {
if let message = userInfo?["message"] as? String {
return message

View file

@ -11,7 +11,9 @@ public enum UserDefaultKey: String {
case userHasDeniedPushPrimer
case firebasePushToken
case homeFeedlayoutPreference
case lastSelectedLinkedItemFilter
case lastSelectedTabItem
case lastSelectedInboxFilter
case lastSelectedFollowingFilter
case lastSelectedFeaturedItemFilter
case lastUsedAppVersion
case lastUsedAppBuildNumber

View file

@ -43,6 +43,7 @@ public extension Color {
static var themeDisabledBG: Color { Color("_themeDisabledBG", bundle: .module) }
static var themeSolidBackground: Color { Color("_themeSolidBackground", bundle: .module) }
static var thBorderColor: Color { Color("thBorderColor", bundle: .module) }
static var thLibrarySeparator: Color { Color("thLibrarySeparator", bundle: .module) }
static var thFeatureSeparator: Color { Color("featureSeparator", bundle: .module) }
@ -54,6 +55,8 @@ public extension Color {
static var noteContainer: Color { Color("_noteContainer", bundle: .module) }
static var textFieldBackground: Color { Color("_textFieldBackground", bundle: .module) }
static var themeTabBarColor: Color { Color("_themeTabBarColor", bundle: .module) }
static var themeTabButtonColor: Color { Color("_themeTabButtonColor", bundle: .module) }
// Apple system UIColor equivalents
#if os(iOS)

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

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x93",
"green" : "0x8E",
"red" : "0x8E"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x93",
"green" : "0x8E",
"red" : "0x8E"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,27 @@
import Foundation
func cardShouldHideUrl(_ url: String?) -> Bool {
if let url = url, let origin = URL(string: url)?.host {
let hideHosts = ["storage.googleapis.com", "omnivore.app"]
if hideHosts.contains(origin) {
return true
}
}
return false
}
func cardSiteName(_ originalArticleUrl: String?) -> String? {
if cardShouldHideUrl(originalArticleUrl) {
return nil
}
if let url = originalArticleUrl,
let originalHost = URL(string: url)?.host?.replacingOccurrences(of: "^www\\.", with: "", options: .regularExpression)
{
return originalHost
}
return nil
}

View file

@ -12,12 +12,12 @@ public enum GridCardAction {
public struct GridCard: View {
@Binding var isContextMenuOpen: Bool
let item: LinkedItem
let item: Models.LibraryItem
let actionHandler: (GridCardAction) -> Void
// let tapAction: () -> Void
public init(
item: LinkedItem,
item: Models.LibraryItem,
isContextMenuOpen: Binding<Bool>,
actionHandler: @escaping (GridCardAction) -> Void
) {
@ -123,6 +123,34 @@ public struct GridCard: View {
}
}
var bylineStr: String {
// It seems like it could be cleaner just having author, instead of
// concating, maybe we fall back
if let author = item.author {
return author
} else if let publisherDisplayName = item.publisherDisplayName {
return publisherDisplayName
}
return ""
}
var byLine: some View {
if let origin = cardSiteName(item.pageURLString) {
Text(bylineStr + " | " + origin)
.font(.caption2)
.foregroundColor(Color.themeLibraryItemSubtle)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
} else {
Text(bylineStr)
.font(.caption2)
.foregroundColor(Color.themeLibraryItemSubtle)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
}
}
public var body: some View {
GeometryReader { geo in
VStack(alignment: .leading, spacing: 0) {
@ -138,23 +166,7 @@ public struct GridCard: View {
.lineLimit(1)
}
HStack {
if let author = item.author {
Text("by \(author)")
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
.lineLimit(1)
}
if let publisherDisplayName = item.publisherDisplayName {
Text(publisherDisplayName)
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
.lineLimit(1)
}
Spacer()
}
byLine
}
.frame(height: 30)
.padding(.horizontal, 10)

View file

@ -5,9 +5,9 @@ import Utils
public struct LibraryFeatureCard: View {
let viewer: Viewer?
let tapHandler: () -> Void
@ObservedObject var item: LinkedItem
@ObservedObject var item: Models.LibraryItem
public init(item: LinkedItem, viewer: Viewer?, tapHandler: @escaping () -> Void = {}) {
public init(item: Models.LibraryItem, viewer: Viewer?, tapHandler: @escaping () -> Void = {}) {
self.item = item
self.viewer = viewer
self.tapHandler = tapHandler

View file

@ -32,7 +32,7 @@ enum FlairLabels: String {
}
public extension View {
func draggableItem(item: LinkedItem) -> some View {
func draggableItem(item: Models.LibraryItem) -> some View {
#if os(iOS)
if #available(iOS 16.0, *), let url = item.deepLink {
return AnyView(self.draggable(url) {
@ -46,10 +46,10 @@ public extension View {
public struct LibraryItemCard: View {
let viewer: Viewer?
@ObservedObject var item: LinkedItem
@ObservedObject var item: Models.LibraryItem
@State var noteLineLimit: Int? = 3
public init(item: LinkedItem, viewer: Viewer?) {
public init(item: Models.LibraryItem, viewer: Viewer?) {
self.item = item
self.viewer = viewer
}
@ -256,27 +256,16 @@ public struct LibraryItemCard: View {
}
}
} else {
fallbackImage
Color.clear
.frame(width: 50, height: 75)
.cornerRadius(5)
.padding(.top, 2)
}
}
.padding(.top, 10)
.cornerRadius(5)
}
var fallbackImage: some View {
HStack {
Text(item.unwrappedTitle.prefix(1))
.font(Font.system(size: 32, weight: .bold))
.frame(alignment: .bottomLeading)
.foregroundColor(Gradient.randomColor(str: item.unwrappedTitle, offset: 1))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Gradient.randomColor(str: item.unwrappedTitle, offset: 0))
.background(LinearGradient(gradient: Gradient(fromStr: item.unwrappedTitle)!, startPoint: .top, endPoint: .bottom))
.cornerRadius(5)
.frame(width: 50, height: 75)
}
var bylineStr: String {
// It seems like it could be cleaner just having author, instead of
// concating, maybe we fall back
@ -290,11 +279,19 @@ public struct LibraryItemCard: View {
}
var byLine: some View {
Text(bylineStr)
.font(.caption2)
.foregroundColor(Color.themeLibraryItemSubtle)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
if let origin = cardSiteName(item.pageURLString) {
Text(bylineStr + " | " + origin)
.font(.caption2)
.foregroundColor(Color.themeLibraryItemSubtle)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
} else {
Text(bylineStr)
.font(.caption2)
.foregroundColor(Color.themeLibraryItemSubtle)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
}
}
public var articleInfo: some View {

View file

@ -5,16 +5,13 @@ public extension Image {
static var omnivoreTitleLogo: Image { Image("_omnivoreTitleLogo", bundle: .module) }
static var googleIcon: Image { Image("_googleIcon", bundle: .module) }
static var homeTab: Image { Image("BookmarksSimple", bundle: .module) }
static var homeTabSelected: Image { Image("_homeTabSelected", bundle: .module) }
static var profileTab: Image { Image("_profileTab", bundle: .module) }
static var profileTabSelected: Image { Image("_profileTabSelected", bundle: .module) }
static var dotsThree: Image { Image("_dots-three", bundle: .module) }
static var tabSubscriptions: Image { Image("_tab_subscriptions", bundle: .module).renderingMode(.template) }
static var tabFollowing: Image { Image("_tab_following", bundle: .module).renderingMode(.template) }
static var tabLibrary: Image { Image("_tab_library", bundle: .module).renderingMode(.template) }
static var tabBriefing: Image { Image("_tab_briefing", bundle: .module).renderingMode(.template) }
static var tabSearch: Image { Image("_tab_search", bundle: .module).renderingMode(.template) }
static var tabHighlights: Image { Image("_tab_highlights", bundle: .module).renderingMode(.template) }
static var tabProfile: Image { Image("_tab_profile", bundle: .module).renderingMode(.template) }
static var pinRotated: Image { Image("pin-rotated", bundle: .module) }

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "profile-tab.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="#9D9B95" stroke-width="2" stroke-miterlimit="10"/>
<path d="M12 15C14.0711 15 15.75 13.3211 15.75 11.25C15.75 9.17893 14.0711 7.5 12 7.5C9.92893 7.5 8.25 9.17893 8.25 11.25C8.25 13.3211 9.92893 15 12 15Z" stroke="#9D9B95" stroke-width="2" stroke-miterlimit="10"/>
<path d="M5.98114 18.6913C6.54609 17.5806 7.40738 16.6478 8.46967 15.9963C9.53196 15.3448 10.7538 15 12 15C13.2461 15 14.468 15.3448 15.5303 15.9963C16.5926 16.6478 17.4539 17.5806 18.0188 18.6913" stroke="#9D9B95" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 780 B

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "profile-tab-selected.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,6 +0,0 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="15" cy="15" r="15" fill="#F4DA7B"/>
<path d="M15 24C19.9706 24 24 19.9706 24 15C24 10.0294 19.9706 6 15 6C10.0294 6 6 10.0294 6 15C6 19.9706 10.0294 24 15 24Z" stroke="#382D05" stroke-width="2" stroke-miterlimit="10"/>
<path d="M15 18C17.0711 18 18.75 16.3211 18.75 14.25C18.75 12.1789 17.0711 10.5 15 10.5C12.9289 10.5 11.25 12.1789 11.25 14.25C11.25 16.3211 12.9289 18 15 18Z" stroke="#382D05" stroke-width="2" stroke-miterlimit="10"/>
<path d="M8.98114 21.6913C9.54609 20.5806 10.4074 19.6478 11.4697 18.9963C12.532 18.3448 13.7538 18 15 18C16.2461 18 17.468 18.3448 18.5303 18.9963C19.5926 19.6478 20.4539 20.5806 21.0188 21.6913" stroke="#382D05" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -5,7 +5,7 @@
"scale" : "1x"
},
{
"filename" : "BookmarksSimple.png",
"filename" : "Frame.svg",
"idiom" : "universal",
"scale" : "2x"
},

View file

@ -0,0 +1,12 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5638_26950)">
<path d="M12.5 4.56641L4.5 8.56641L12.5 12.5664L20.5 8.56641L12.5 4.56641Z" fill="#8E8E93" stroke="#8E8E93" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.5 12.5664L12.5 16.5664L20.5 12.5664" stroke="#8E8E93" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.5 16.5664L12.5 20.5664L20.5 16.5664" stroke="#8E8E93" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_5638_26950">
<rect width="24" height="24" fill="white" transform="translate(0.5 0.566406)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 B

View file

@ -5,7 +5,7 @@
"scale" : "1x"
},
{
"filename" : "BookOpen.png",
"filename" : "Frame.svg",
"idiom" : "universal",
"scale" : "2x"
},

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_5638_26956)">
<path d="M13.5 6.56641H7.5C6.70435 6.56641 5.94129 6.88248 5.37868 7.44509C4.81607 8.0077 4.5 8.77076 4.5 9.56641V21.5664L4.506 21.6804C4.52514 21.8466 4.58565 22.0053 4.68199 22.142C4.77833 22.2787 4.90743 22.3891 5.05746 22.463C5.20749 22.5369 5.37366 22.572 5.54077 22.5651C5.70788 22.5582 5.87059 22.5095 6.014 22.4234L10.5 19.7324L14.986 22.4234C15.1377 22.5143 15.3108 22.5634 15.4876 22.5655C15.6645 22.5677 15.8387 22.523 15.9926 22.4359C16.1465 22.3488 16.2746 22.2224 16.3637 22.0696C16.4528 21.9169 16.4999 21.7433 16.5 21.5664V9.56641C16.5 8.77076 16.1839 8.0077 15.6213 7.44509C15.0587 6.88248 14.2956 6.56641 13.5 6.56641Z" fill="#8E8E93"/>
<path d="M17.5 2.56641C18.2652 2.56636 19.0015 2.85873 19.5582 3.38369C20.115 3.90865 20.4501 4.62651 20.495 5.39041L20.5 5.56641V17.5664C20.5 17.7337 20.4581 17.8983 20.3781 18.0452C20.298 18.192 20.1824 18.3165 20.0419 18.4071C19.9013 18.4978 19.7402 18.5517 19.5734 18.564C19.4066 18.5763 19.2393 18.5465 19.087 18.4774L18.986 18.4234L17.986 17.8234C17.8004 17.712 17.656 17.5433 17.5744 17.3428C17.4929 17.1423 17.4786 16.9208 17.5337 16.7114C17.5888 16.5021 17.7104 16.3163 17.8801 16.182C18.0499 16.0476 18.2586 15.972 18.475 15.9664L18.5 15.9674V5.56641C18.5 5.32147 18.41 5.08507 18.2473 4.90203C18.0845 4.719 17.8602 4.60206 17.617 4.57341L17.5 4.56641H11.499C11.3432 4.56606 11.1894 4.60213 11.05 4.67175C10.9106 4.74138 10.7893 4.84262 10.696 4.96741L10.566 5.16441C10.4124 5.36587 10.1881 5.50152 9.9383 5.54397C9.68854 5.58641 9.43199 5.53248 9.22046 5.39307C9.00893 5.25366 8.8582 5.03916 8.79871 4.79291C8.73923 4.54666 8.77542 4.287 8.89999 4.06641C9.14644 3.63932 9.49437 3.27957 9.91298 3.01899C10.3316 2.7584 10.808 2.60502 11.3 2.57241L11.5 2.56641H17.5Z" fill="#8E8E93"/>
</g>
<defs>
<clipPath id="clip0_5638_26956">
<rect width="24" height="24" fill="white" transform="translate(0.5 0.566406)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -5,7 +5,7 @@
"scale" : "1x"
},
{
"filename" : "Group 1000002644.png",
"filename" : "Frame.svg",
"idiom" : "universal",
"scale" : "2x"
},

View file

@ -0,0 +1,11 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_6482_4724)">
<path d="M5.82823 22.4403H18.1683C19.4433 22.4403 20.4604 21.3206 20.2787 20.0572C19.9504 17.7944 18.7779 16.0269 16.5415 15.0128C13.0857 13.4446 9.47455 13.3919 6.27083 15.7778C4.78474 16.8828 4.00506 18.3308 3.72954 19.9898C3.51263 21.2737 4.5268 22.4403 5.82823 22.4403Z" fill="#8E8E93"/>
<path d="M12.0715 12.8117C14.6626 12.7824 16.8639 10.5899 16.82 8.08376C16.776 5.54541 14.5337 3.40568 11.9601 3.44086C9.34851 3.47896 7.24103 5.60696 7.26448 8.18929C7.28499 10.7745 9.43351 12.841 12.0715 12.8117Z" fill="#8E8E93"/>
</g>
<defs>
<clipPath id="clip0_6482_4724">
<rect width="24" height="24" fill="white" transform="translate(0 0.94043)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 807 B

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