Merge remote-tracking branch 'origin/main' into feat/android-label-chips-m3-update

This commit is contained in:
Stefano Sansone 2024-02-09 00:33:04 +00:00
commit 61ed6589bb
86 changed files with 28562 additions and 27633 deletions

File diff suppressed because one or more lines are too long

View file

@ -23,6 +23,7 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.viewinterop.AndroidView
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.reader.OmnivoreWebView.Direction
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -160,6 +161,24 @@ fun WebReader(
"utf-8",
null
)
requestFocus()
setOnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN) {
when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
scrollVertically(Direction.UP)
return@setOnKeyListener true
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
scrollVertically(Direction.DOWN)
return@setOnKeyListener true
}
}
}
// default value
false
}
}
}, update = {
if (javascriptActionLoopUUID != webReaderViewModel.lastJavascriptActionLoopUUID) {
@ -194,6 +213,18 @@ class OmnivoreWebView(context: Context) : WebView(context), OnScrollChangeListen
setOnScrollChangeListener(this)
}
enum class Direction(val value: Int) {
UP(-1),
DOWN(1)
}
fun scrollVertically(direction: Direction, heightFactor: Int = 10) {
if (canScrollVertically(direction.value)) {
val scrollByValue = height.div(heightFactor)
scrollBy(0, direction.value.times(scrollByValue))
}
}
private val actionModeCallback = object : ActionMode.Callback2() {
// Called when the action mode is created; startActionMode() was called
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {

View file

@ -1389,7 +1389,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.43.0;
MARKETING_VERSION = 1.44.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
@ -1424,7 +1424,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.43.0;
MARKETING_VERSION = 1.44.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -1479,7 +1479,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.43.0;
MARKETING_VERSION = 1.44.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1820,7 +1820,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.43.0;
MARKETING_VERSION = 1.44.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -144,15 +144,6 @@
"version" : "2.30908.0"
}
},
{
"identity" : "popupview",
"kind" : "remoteSourceControl",
"location" : "https://github.com/exyte/PopupView.git",
"state" : {
"revision" : "68349a0ae704b9a7041f756f3f4f460ddbf7ba8d",
"version" : "2.6.0"
}
},
{
"identity" : "posthog-ios",
"kind" : "remoteSourceControl",

View file

@ -27,7 +27,6 @@ let package = Package(
"Models",
.product(name: "Introspect", package: "SwiftUI-Introspect"),
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.productItem(name: "PopupView", package: "PopupView"),
.product(name: "Transmission", package: "Transmission")
],
resources: [.process("Resources")]
@ -40,8 +39,7 @@ let package = Package(
"Valet",
.product(name: "SwiftGraphQL", package: "swift-graphql"),
"Models",
"Utils",
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms")
"Utils"
]
),
.testTarget(name: "ServicesTests", dependencies: ["Services"]),
@ -72,7 +70,6 @@ var dependencies: [Package.Dependency] {
.package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.4"),
.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/nathantannar4/Transmission", from: "1.0.1"),
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0")

View file

@ -17,16 +17,5 @@ struct MiniShareExtensionView: View {
var body: some View {
ProgressView()
.popup(isPresented: $showToast) {
Text("Saving to Omnivore")
.padding(20)
} customize: {
$0
.type(.toast)
.position(.bottom)
.animation(.spring())
.closeOnTapOutside(true)
.backgroundColor(.black.opacity(0.5))
}
}
}

View file

@ -332,7 +332,7 @@ import Utils
if let customHighlight = annotation.customData?["omnivoreHighlight"] as? [String: String] {
if customHighlight["id"]?.lowercased() == highlightId {
if !document.remove(annotations: [annotation]) {
viewModel.snackbar(message: "Error removing highlight")
Snackbar.show(message: "Error removing highlight", dismissAfter: 2000)
}
}
}

View file

@ -8,9 +8,6 @@ final class PDFViewerViewModel: ObservableObject {
@Published var errorMessage: String?
@Published var readerView: Bool = false
@Published var showSnackbar: Bool = false
var snackbarMessage: String?
let pdfItem: PDFItem
var highlights: [Highlight]
@ -19,11 +16,6 @@ final class PDFViewerViewModel: ObservableObject {
self.highlights = pdfItem.highlights
}
func snackbar(message: String) {
snackbarMessage = message
showSnackbar = true
}
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 }

View file

@ -3,7 +3,7 @@ import Services
import Views
extension Snackbar {
static func showInLibrary(message: String, undoAction: (() -> Void)? = nil) {
NSNotification.librarySnackBar(message: message, undoAction: undoAction)
static func show(message: String, undoAction: (() -> Void)? = nil, dismissAfter: Int?) {
NSNotification.snackBar(message: message, undoAction: undoAction, dismissAfter: dismissAfter)
}
}

View file

@ -24,9 +24,9 @@
@State var showLabelsModal = false
@State var showNotebookView = false
@State var showOperationToast = false
@State var showSnackbar = false
@State var operationStatus: OperationStatus = .none
@State var operationMessage: String?
@State var snackbarMessage: String?
var playPauseButtonImage: String {
switch audioController.state {
@ -384,8 +384,8 @@
func playerContent(_: LinkedItemAudioProperties) -> some View {
ZStack {
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showOperationToast) {
OperationToast(operationMessage: $operationMessage, showOperationToast: $showOperationToast, operationStatus: $operationStatus)
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showSnackbar) {
OperationToast(operationMessage: $snackbarMessage, showOperationToast: $showSnackbar, operationStatus: $operationStatus)
.offset(y: -90)
} label: {
EmptyView()

View file

@ -35,7 +35,7 @@
pasteBoard.writeObjects([highlightParams.quote as NSString])
#endif
// Snackbar.show(message: "Highlight copied")
Snackbar.show(message: "Highlight copied", dismissAfter: 2000)
},
label: { Label("Copy", systemImage: "doc.on.doc") }
)

View file

@ -21,23 +21,11 @@ struct LibraryFeatureCardNavigationLink: View {
@State var showFeatureActions = false
var body: some View {
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)
}
)
Button(action: {
viewModel.presentItem(item: item)
}, label: {
LibraryFeatureCard(item: item, viewer: dataService.currentViewer)
})
.buttonStyle(.plain)
.confirmationDialog("", isPresented: $showFeatureActions) {
if FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) == .pinned {

View file

@ -100,6 +100,11 @@ import Views
}
func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool, forceRemote: Bool = false) async {
if isRefresh {
cursor = nil
limit = 5
}
await withTaskGroup(of: Void.self) { group in
group.addTask { await self.loadCurrentViewer(dataService: dataService) }
group.addTask { await self.loadLabels(dataService: dataService) }
@ -165,7 +170,7 @@ import Views
var subPredicates = [NSPredicate]()
// TODO: FOLLOWING MIGRATION: invert this once the following migration has completed
if !UserDefaults.standard.bool(forKey: "LibraryTabView::hideFollowingTab") {
if !(filterState.appliedFilter?.ignoreFolders ?? false), !UserDefaults.standard.bool(forKey: "LibraryTabView::hideFollowingTab") {
let folderPredicate = NSPredicate(
format: "%K == %@", #keyPath(Models.LibraryItem.folder), filterState.folder
)

View file

@ -29,29 +29,15 @@ struct LibraryItemListNavigationLink: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@ObservedObject var item: Models.LibraryItem
@ObservedObject var viewModel: HomeFeedViewModel
let item: Models.LibraryItem
let viewModel: HomeFeedViewModel
var body: some View {
ZStack {
Button(action: {
viewModel.presentItem(item: item)
}, label: {
LibraryItemCard(item: LibraryItemData.make(from: 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
)
}, label: {
EmptyView()
}
)
}
})
}
}
@ -65,22 +51,11 @@ struct LibraryItemGridCardNavigationLink: View {
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
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: LibraryItemData.make(from: item))
}
)
Button(action: {
viewModel.presentItem(item: item)
}, label: {
GridCard(item: LibraryItemData.make(from: item))
})
.buttonStyle(.plain)
.aspectRatio(1.0, contentMode: .fill)
.background(Color.systemBackground)

View file

@ -222,7 +222,7 @@ struct AnimatingCellHeight: AnimatableModifier {
}
var body: some View {
ZStack {
ZStack {
HomeFeedView(
listTitle: $listTitle,
isListScrolled: $isListScrolled,
@ -369,58 +369,74 @@ struct AnimatingCellHeight: AnimatableModifier {
}
ToolbarItemGroup(placement: .barTrailing) {
if isEditMode == .active {
Button(action: { isEditMode = .inactive }, label: { Text("Cancel") })
} else {
if prefersListLayout {
if viewModel.appliedFilter?.name == "Deleted" {
if viewModel.isEmptyingTrash {
ProgressView()
} else {
Button(
action: {
viewModel.emptyTrash(dataService: dataService)
},
label: {
Text("Empty trash").tint(Color.blue)
})
.buttonStyle(.plain)
.foregroundColor(Color.blue)
}
} else {
if isEditMode == .active {
Button(action: { isEditMode = .inactive }, label: { Text("Cancel") })
} else {
if prefersListLayout {
Button(
action: { isEditMode = isEditMode == .active ? .inactive : .active },
label: {
Image
.selectMultiple
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
}
if enableGrid {
Button(
action: { prefersListLayout.toggle() },
label: {
Image(systemName: prefersListLayout ? "square.grid.2x2" : "list.bullet")
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
}
Button(
action: { isEditMode = isEditMode == .active ? .inactive : .active },
action: {
if viewModel.currentFolder == "inbox" {
showAddLinkView = true
} else if viewModel.currentFolder == "following" {
viewModel.showAddFeedView = true
}
},
label: {
Image
.selectMultiple
Image.addLink
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
Button(
action: {
searchPresented = true
isEditMode = .inactive
},
label: {
Image
.magnifyingGlass
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
}
if enableGrid {
Button(
action: { prefersListLayout.toggle() },
label: {
Image(systemName: prefersListLayout ? "square.grid.2x2" : "list.bullet")
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
}
Button(
action: {
if viewModel.currentFolder == "inbox" {
showAddLinkView = true
} else if viewModel.currentFolder == "following" {
viewModel.showAddFeedView = true
}
},
label: {
Image.addLink
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
Button(
action: {
searchPresented = true
isEditMode = .inactive
},
label: {
Image
.magnifyingGlass
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
}
}
@ -430,19 +446,17 @@ struct AnimatingCellHeight: AnimatableModifier {
viewModel.bulkAction(dataService: dataService, action: .delete, items: Array(selection))
isEditMode = .inactive
}, label: { Image.toolbarTrash })
.disabled(selection.count < 1)
.padding(.horizontal, UIDevice.isIPad ? 10 : 5)
.disabled(selection.count < 1)
.padding(.horizontal, UIDevice.isIPad ? 10 : 5)
Spacer()
Text("\(selection.count) selected").font(.footnote)
Spacer()
Button(action: {
viewModel.bulkAction(dataService: dataService, action: .archive, items: Array(selection))
isEditMode = .inactive
}, label: { Image.toolbarArchive })
.disabled(selection.count < 1)
.padding(.horizontal, UIDevice.isIPad ? 10 : 5)
.disabled(selection.count < 1)
.padding(.horizontal, UIDevice.isIPad ? 10 : 5)
}
}
}
@ -461,6 +475,15 @@ struct AnimatingCellHeight: AnimatableModifier {
@ObservedObject var viewModel: HomeFeedViewModel
let showFeatureCards: Bool
var slideTransition: PresentationLinkTransition {
PresentationLinkTransition.slide(
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
options:
PresentationLinkTransition.Options(
modalPresentationCapturesStatusBarAppearance: true
)
))
}
var body: some View {
VStack(spacing: 0) {
@ -481,6 +504,20 @@ struct AnimatingCellHeight: AnimatableModifier {
}
)
}
PresentationLink(transition: slideTransition, isPresented: $viewModel.linkIsActive) {
if let presentingItem = viewModel.selectedItem {
if presentingItem.isPDF {
PDFContainerView(item: presentingItem)
} else {
WebReaderContainerView(item: presentingItem)
}
} else {
EmptyView()
}
} label: {
EmptyView()
}.buttonStyle(.plain)
if prefersListLayout || !enableGrid {
HomeFeedListView(
listTitle: $listTitle,
@ -506,29 +543,6 @@ struct AnimatingCellHeight: AnimatableModifier {
viewModel.negatedLabels = $1
}
}
.popup(isPresented: $viewModel.showSnackbar) {
if let operation = viewModel.snackbarOperation {
Snackbar(isShowing: $viewModel.showSnackbar, operation: operation)
} else {
EmptyView()
}
} customize: {
$0
.type(.toast)
.autohideIn(2)
.position(.bottom)
.animation(.spring())
.isOpaque(false)
}
.onReceive(NSNotification.librarySnackBarPublisher) { notification in
if !viewModel.showSnackbar {
if let message = notification.userInfo?["message"] as? String {
viewModel.snackbarOperation = SnackbarOperation(message: message,
undoAction: notification.userInfo?["undoAction"] as? SnackbarUndoAction)
viewModel.showSnackbar = true
}
}
}
}
}
@ -809,6 +823,15 @@ struct AnimatingCellHeight: AnimatableModifier {
.frame(maxWidth: .infinity)
.padding()
.listRowSeparator(.hidden, edges: .all)
} else if viewModel.isEmptyingTrash {
VStack {
Text("Emptying trash")
ProgressView()
}
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
.padding()
.listRowSeparator(.hidden, edges: .all)
} else if viewModel.fetcher.items.isEmpty {
EmptyState(viewModel: viewModel)
.listRowSeparator(.hidden, edges: .all)
@ -1142,6 +1165,8 @@ struct BottomView: View {
var innerBody: some View {
if viewModel.fetcher.items.count < 3 {
AnyView(Color.clear)
} else if viewModel.appliedFilter?.name == "Deleted" {
AnyView(Color.clear)
} else {
AnyView(HStack {
if let totalCount = viewModel.fetcher.totalCount {

View file

@ -13,7 +13,7 @@ enum LoadingBarStyle {
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject {
let filterKey: String
@ObservedObject var fetcher: LibraryItemFetcher
@Published var fetcher: LibraryItemFetcher
let folderConfigs: [String: LibraryListConfig]
@Published var isLoading = false
@ -28,10 +28,9 @@ enum LoadingBarStyle {
@Published var linkIsActive = false
@Published var showLabelsSheet = false
@Published var showSnackbar = false
@Published var showAddFeedView = false
@Published var showHideFollowingAlert = false
@Published var snackbarOperation: SnackbarOperation?
@Published var filters = [InternalFilter]()
@ -68,6 +67,13 @@ enum LoadingBarStyle {
super.init()
}
func presentItem(item: Models.LibraryItem) {
withAnimation {
self.selectedItem = item
self.linkIsActive = true
}
}
private var filterState: FetcherFilterState? {
if let appliedFilter = appliedFilter {
return FetcherFilterState(
@ -235,8 +241,7 @@ enum LoadingBarStyle {
}
func snackbar(_ message: String, undoAction: SnackbarUndoAction? = nil) {
snackbarOperation = SnackbarOperation(message: message, undoAction: undoAction)
showSnackbar = true
Snackbar.show(message: message, undoAction: undoAction, dismissAfter: 2000)
}
func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) {
@ -309,7 +314,7 @@ enum LoadingBarStyle {
Task {
do {
try await dataService.moveItem(itemID: item.unwrappedID, folder: folder)
snackbar("Item moved")
snackbar("Moved to library")
} catch {
snackbar("Error moving item to \(folder)")
}
@ -372,4 +377,18 @@ enum LoadingBarStyle {
fetcher.updateFeatureFilter(context: context, filter: filter)
}
}
@Published var isEmptyingTrash = false
func emptyTrash(dataService: DataService) {
self.isEmptyingTrash = true
Task {
if !(await dataService.emptyTrash()) {
snackbar("Error emptying trash")
} else {
snackbar("Trash emptied")
}
isEmptyingTrash = false
}
}
}

View file

@ -3,6 +3,7 @@ import Models
import Services
import SwiftUI
import Utils
import Views
@MainActor
public class LibraryAddFeedViewModel: NSObject, ObservableObject {
@ -101,9 +102,9 @@ public class LibraryAddFeedViewModel: NSObject, ObservableObject {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(4000)) {
if failureCount > 0 {
showInLibrarySnackbar("Failed to add \(failureCount) feeds")
Snackbar.show(message: "Failed to add \(failureCount) feeds", dismissAfter: 3000)
} else {
showInLibrarySnackbar("Added \(successCount) feed\(successCount == 0 ? "" : "s")")
Snackbar.show(message: "Added \(successCount) feed\(successCount == 0 ? "" : "s")", dismissAfter: 3000)
}
}
}
@ -211,7 +212,7 @@ public struct LibraryScanFeedView: View {
if viewModel.selected.count > 0 {
Button(action: {
dismiss()
showInLibrarySnackbar("Adding feeds...")
Snackbar.show(message: "Adding feeds...", dismissAfter: 2000)
Task {
await viewModel.addFeeds()
}

View file

@ -0,0 +1,39 @@
import SwiftUI
import Views
struct InformationalSnackbar: View {
let message: String?
let undoAction: (() -> Void)?
var body: some View {
VStack {
HStack {
if let message = message {
Text(message)
}
Spacer()
if let undoAction = self.undoAction {
Button(action: {
undoAction()
}, label: {
Text("Undo")
.bold()
.foregroundColor(.blue)
})
.padding(.trailing, 2)
}
}
.padding(10)
.frame(height: 50)
.frame(maxWidth: 380)
.background(Color(hex: "2A2A2A"))
.foregroundColor(Color(hex: "EBEBEB"))
.cornerRadius(4.0)
}
.padding(.bottom, 60)
.padding(.horizontal, 10)
.ignoresSafeArea(.all, edges: .bottom)
}
}

View file

@ -7,7 +7,6 @@
import Foundation
import Models
import PopupView
import Services
import SwiftUI
import Transmission
@ -71,8 +70,20 @@ struct LibraryTabView: View {
}
}
@State var showOperationToast = false
@State var operationStatus: OperationStatus = .none
@State var operationMessage: String?
var body: some View {
VStack(spacing: 0) {
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showOperationToast) {
OperationToast(operationMessage: $operationMessage,
showOperationToast: $showOperationToast,
operationStatus: $operationStatus)
} label: {
EmptyView()
}.buttonStyle(.plain)
TabView(selection: $selectedTab) {
if !hideFollowingTab {
NavigationView {

View file

@ -71,7 +71,7 @@ struct LinkItemDetailView: View {
}
.navigationViewStyle(.stack)
} else if let item = viewModel.item {
WebReaderContainerView(item: item, pop: { dismiss() })
WebReaderContainerView(item: item)
.background(ThemeManager.currentBgColor)
}
}

View file

@ -0,0 +1,66 @@
import Combine
import Models
import SwiftUI
import Utils
import Services
@MainActor final class PDFContainerViewModel: ObservableObject {
func trackReadEvent(item: Models.LibraryItem, reader: String) {
let itemID = item.unwrappedID
let slug = item.unwrappedSlug
let originalArticleURL = item.unwrappedPageURLString
EventTracker.track(
.linkRead(
linkID: itemID,
slug: slug,
reader: reader,
originalArticleURL: originalArticleURL
)
)
}
}
struct PDFContainerView: View {
let item: Models.LibraryItem
let pdfItem: PDFItem?
@EnvironmentObject var dataService: DataService
@StateObject private var viewModel = PDFContainerViewModel()
init(item: Models.LibraryItem) {
self.item = item
self.pdfItem = PDFItem.make(item: item)
}
var body: some View {
NavigationView {
pdfContainerView
.navigationBarBackButtonHidden(false)
}
.navigationViewStyle(.stack)
.ignoresSafeArea(.all, edges: .bottom)
.onAppear {
viewModel.trackReadEvent(item: item, reader: "PDF")
}
}
@ViewBuilder private var pdfContainerView: some View {
if let pdfItem = pdfItem, let pdfURL = pdfItem.pdfURL {
#if os(iOS)
PDFViewer(viewModel: PDFViewerViewModel(pdfItem: pdfItem))
.navigationBarTitleDisplayMode(.inline)
#elseif os(macOS)
PDFWrapperView(pdfURL: pdfURL)
#endif
} else {
HStack(alignment: .center) {
Spacer()
Text("Loading")
Spacer()
}
}
}
}

View file

@ -2,12 +2,39 @@ import Models
import Services
import SwiftUI
import Views
import Transmission
@MainActor public struct PrimaryContentView: View {
@State var searchTerm: String = ""
@State var showSnackbar = false
@State var snackbarMessage: String?
@State var snackbarUndoAction: (() -> Void)?
@State private var snackbarTimer: Timer?
public var body: some View {
innerBody
ZStack {
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showSnackbar) {
InformationalSnackbar(message: snackbarMessage, undoAction: snackbarUndoAction)
} label: {
EmptyView()
}.buttonStyle(.plain)
innerBody
}
.onReceive(NSNotification.snackBarPublisher) { notification in
if let message = notification.userInfo?["message"] as? String {
snackbarUndoAction = notification.userInfo?["undoAction"] as? (() -> Void)
snackbarMessage = message
showSnackbar = true
let dismissAfter = notification.userInfo?["dismissAfter"] as? Int ?? 2000
if snackbarTimer == nil {
startTimer(amount: dismissAfter)
} else {
increaseTimeout(amount: dismissAfter)
}
}
}
}
public var innerBody: some View {
@ -25,4 +52,21 @@ import Views
return AnyView(splitView)
#endif
}
func startTimer(amount: Int) {
self.snackbarTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(amount / 1000), repeats: false) { _ in
DispatchQueue.main.async {
self.showSnackbar = false
}
}
}
func stopTimer() {
snackbarTimer?.invalidate()
}
func increaseTimeout(amount: Int) {
stopTimer()
startTimer(amount: amount)
}
}

View file

@ -1,5 +1,4 @@
import Models
import PopupView
import Services
import SwiftUI
import Transmission
@ -7,7 +6,6 @@ import Views
@MainActor final class NewsletterEmailsViewModel: ObservableObject {
@Published var isLoading = false
@Published var showAddressCopied = false
@Published var emails = [NewsletterEmail]()
@Published var showOperationToast = false
@ -78,12 +76,6 @@ struct NewsletterEmailsView: View {
EmptyView()
}.buttonStyle(.plain)
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showAddressCopied) {
MessageToast()
} label: {
EmptyView()
}.buttonStyle(.plain)
#if os(iOS)
Form {
innerBody
@ -162,10 +154,7 @@ struct NewsletterEmailRow: View {
pasteBoard.writeObjects([newsletterEmail.unwrappedEmail as NSString])
#endif
viewModel.showAddressCopied = true
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) {
viewModel.showAddressCopied = false
}
Snackbar.show(message: "Address copied", undoAction: nil, dismissAfter: 2000)
},
label: {
Text("Copy")
@ -191,22 +180,3 @@ struct NewsletterEmailRow: View {
}
}
struct MessageToast: View {
var body: some View {
VStack {
HStack {
Text("Address copied")
Spacer()
}
.padding(10)
.frame(minHeight: 50)
.frame(maxWidth: 380)
.background(Color(hex: "2A2A2A"))
.cornerRadius(4.0)
.tint(Color.green)
}
.padding(.bottom, 70)
.padding(.horizontal, 10)
.ignoresSafeArea(.all, edges: .bottom)
}
}

View file

@ -29,7 +29,7 @@
do {
try await dataService.leaveGroup(groupID: recommendationGroup.id)
// Snackbar.show(message: "You have left the club.")
Snackbar.show(message: "You have left the club.", dismissAfter: 2000)
} catch {
return false
}
@ -182,7 +182,7 @@
pasteBoard.writeObjects([highlightParams.quote as NSString])
#endif
// Snackbar.show(message: "Invite link copied")
Snackbar.show(message: "Invite link copied", dismissAfter: 2000)
}, label: {
Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))")
.font(.appCaption)

View file

@ -33,7 +33,7 @@ func removeLibraryItemAction(dataService: DataService, objectID: NSManagedObject
print("checking if task is canceled: ", Task.isCancelled)
}
Snackbar.showInLibrary(message: "Item removed", undoAction: {
Snackbar.show(message: "Item removed", undoAction: {
print("canceling task", syncTask)
syncTask.cancel()
dataService.viewContext.performAndWait {
@ -42,5 +42,5 @@ func removeLibraryItemAction(dataService: DataService, objectID: NSManagedObject
try? dataService.viewContext.save()
}
}
})
}, dismissAfter: 2000)
}

View file

@ -95,7 +95,7 @@
return AnyView(Button(action: {
Task {
if await viewModel.recommend(dataService: dataService) {
// Snackbar.show(message: "Recommendation sent")
Snackbar.show(message: "Recommendation sent", dismissAfter: 2000)
dismiss()
}
}

View file

@ -20,6 +20,7 @@ struct WebReader: PlatformViewRepresentable {
@Binding var showNavBarActionID: UUID?
@Binding var shareActionID: UUID?
@Binding var annotation: String
@Binding var showBottomBar: Bool
@Binding var showHighlightAnnotationModal: Bool
func makeCoordinator() -> WebReaderCoordinator {
@ -90,6 +91,9 @@ struct WebReader: PlatformViewRepresentable {
context.coordinator.webViewActionHandler = webViewActionHandler
context.coordinator.updateNavBarVisibility = navBarVisibilityUpdater
context.coordinator.scrollPercentHandler = scrollPercentHandler
context.coordinator.updateShowBottomBar = { newValue in
self.showBottomBar = newValue
}
context.coordinator.articleContentID = articleContent.id
loadContent(webView: webView)
@ -103,7 +107,7 @@ struct WebReader: PlatformViewRepresentable {
do {
try (webView as? OmnivoreWebView)?.dispatchEvent(.saveAnnotation(annotation: annotation))
} catch {
showInLibrarySnackbar("Error saving note.")
Snackbar.show(message: "Error saving note.", dismissAfter: 2000)
}
}

View file

@ -1,6 +1,5 @@
import AVFoundation
import Models
import PopupView
import Services
import SwiftUI
import Transmission
@ -10,8 +9,8 @@ import WebKit
// swiftlint:disable file_length type_body_length
struct WebReaderContainerView: View {
let item: Models.LibraryItem
let pop: () -> Void
@State var item: Models.LibraryItem
@Environment(\.dismiss) private var dismiss
@State private var showPreferencesPopover = false
@State private var showPreferencesFormsheet = false
@ -22,6 +21,7 @@ struct WebReaderContainerView: View {
@State private var hasPerformedHighlightMutations = false
@State var showHighlightAnnotationModal = false
@State private var navBarVisible = true
@State var showBottomBar = true
@State private var progressViewOpacity = 0.0
@State var readerSettingsChangedTransactionID: UUID?
@State var annotationSaveTransactionID: UUID?
@ -45,7 +45,6 @@ struct WebReaderContainerView: View {
@EnvironmentObject var audioController: AudioController
@Environment(\.openURL) var openURL
@StateObject var viewModel = WebReaderViewModel()
@Environment(\.dismiss) var dismiss
@AppStorage(UserDefaultKey.prefersHideStatusBarInReader.rawValue) var prefersHideStatusBarInReader = false
@ -88,6 +87,7 @@ struct WebReaderContainerView: View {
private func tapHandler() {
withAnimation(.easeIn(duration: 0.08)) {
navBarVisible = !navBarVisible
showBottomBar = navBarVisible
showNavBarActionID = UUID()
}
}
@ -116,6 +116,7 @@ struct WebReaderContainerView: View {
case "dismissNavBars":
withAnimation {
navBarVisible = false
showBottomBar = false
showNavBarActionID = UUID()
}
default:
@ -233,6 +234,10 @@ struct WebReaderContainerView: View {
action: copyDeeplink,
label: { Label("Copy Deeplink", systemImage: "link") }
)
// Button(
// action: print,
// label: { Label("Print", systemImage: "printer") }
// )
Button(
action: delete,
label: { Label("Remove", systemImage: "trash") }
@ -253,7 +258,7 @@ struct WebReaderContainerView: View {
#if os(iOS)
Button(
action: {
pop()
dismiss()
},
label: {
Image.chevronRight
@ -355,12 +360,6 @@ struct WebReaderContainerView: View {
var body: some View {
ZStack {
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showOperationToast) {
OperationToast(operationMessage: $viewModel.operationMessage, showOperationToast: $viewModel.showOperationToast, operationStatus: $viewModel.operationStatus)
} label: {
EmptyView()
}.buttonStyle(.plain)
if let articleContent = viewModel.articleContent {
WebReader(
item: item,
@ -384,6 +383,7 @@ struct WebReaderContainerView: View {
navBarVisibilityUpdater: { visible in
withAnimation {
navBarVisible = visible
showBottomBar = visible
}
},
readerSettingsChangedTransactionID: $readerSettingsChangedTransactionID,
@ -391,6 +391,7 @@ struct WebReaderContainerView: View {
showNavBarActionID: $showNavBarActionID,
shareActionID: $shareActionID,
annotation: $annotation,
showBottomBar: $showBottomBar,
showHighlightAnnotationModal: $showHighlightAnnotationModal
)
.background(ThemeManager.currentBgColor)
@ -407,6 +408,7 @@ struct WebReaderContainerView: View {
Task {
await audioController.preload(itemIDs: [item.unwrappedID])
}
viewModel.trackReadEvent(item: item)
}
.confirmationDialog(linkToOpen?.absoluteString ?? "", isPresented: $displayLinkSheet,
titleVisibility: .visible) {
@ -421,7 +423,7 @@ struct WebReaderContainerView: View {
#else
// Pasteboard.general.string = item.unwrappedPageURLString TODO: fix for mac
#endif
showInLibrarySnackbar("Link Copied")
Snackbar.show(message: "Link copied", dismissAfter: 2000)
}, label: { Text(LocalText.readerCopyLink) })
Button(action: {
if let linkToOpen = linkToOpen {
@ -499,13 +501,6 @@ struct WebReaderContainerView: View {
}
}
}
.sheet(isPresented: $showLabelsModal) {
ApplyLabelsView(mode: .item(item), onSave: { labels in
showLabelsModal = false
item.labels = NSSet(array: labels)
readerSettingsChangedTransactionID = UUID()
})
}
.sheet(isPresented: $showTitleEdit) {
LinkedItemMetadataEditView(item: item, onSave: { title, _ in
item.title = title
@ -513,6 +508,13 @@ struct WebReaderContainerView: View {
readerSettingsChangedTransactionID = UUID()
})
}
.sheet(isPresented: $showLabelsModal) {
ApplyLabelsView(mode: .item(item), onSave: { labels in
showLabelsModal = false
item.labels = NSSet(array: labels)
readerSettingsChangedTransactionID = UUID()
})
}
#if os(iOS)
.sheet(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
NotebookView(
@ -533,7 +535,7 @@ struct WebReaderContainerView: View {
self.isRecovering = true
Task {
if !(await dataService.recoverItem(itemID: item.unwrappedID)) {
viewModel.snackbar(message: "Error recovering item")
Snackbar.show(message: "Error recoviering item", dismissAfter: 2000)
} else {
await viewModel.loadContent(
dataService: dataService,
@ -589,13 +591,13 @@ struct WebReaderContainerView: View {
if let audioProperties = audioController.itemAudioProperties {
MiniPlayerViewer(itemAudioProperties: audioProperties)
.padding(.top, 10)
.padding(.bottom, navBarVisible ? 10 : 40)
.padding(.bottom, showBottomBar ? 10 : 40)
.background(Color.themeTabBarColor)
.onTapGesture {
showExpandedAudioPlayer = true
}
}
if navBarVisible {
if showBottomBar {
CustomToolBar(
isFollowing: item.folder == "following",
isArchived: item.isArchived,
@ -623,47 +625,18 @@ struct WebReaderContainerView: View {
WebViewManager.shared().loadHTMLString(WebReaderContent.emptyContent(isDark: Color.isDarkMode), baseURL: nil)
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PopToRoot"))) { _ in
pop()
}
.popup(isPresented: $viewModel.showSnackbar) {
if let operation = viewModel.snackbarOperation {
Snackbar(isShowing: $viewModel.showSnackbar, operation: operation)
} else {
EmptyView()
}
} customize: {
$0
.type(.toast)
.autohideIn(2)
.position(.bottom)
.animation(.spring())
.isOpaque(false)
dismiss()
}
.ignoresSafeArea(.all, edges: .bottom)
.onReceive(NSNotification.readerSnackBarPublisher) { notification in
if let message = notification.userInfo?["message"] as? String {
viewModel.snackbarOperation = SnackbarOperation(message: message,
undoAction: notification.userInfo?["undoAction"] as? SnackbarUndoAction)
viewModel.showSnackbar = true
}
}
}
func moveToInbox() {
Task {
viewModel.showOperationToast = true
viewModel.operationMessage = "Moving to library..."
viewModel.operationStatus = .isPerforming
do {
try await dataService.moveItem(itemID: item.unwrappedID, folder: "inbox")
viewModel.operationMessage = "Moved to library"
viewModel.operationStatus = .success
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
viewModel.showOperationToast = false
}
Snackbar.show(message: "Moved to library", dismissAfter: 2000)
} catch {
viewModel.operationMessage = "Error moving"
viewModel.operationStatus = .failure
Snackbar.show(message: "Error moving item to inbox", dismissAfter: 2000)
}
}
}
@ -672,7 +645,12 @@ struct WebReaderContainerView: View {
let isArchived = item.isArchived
dataService.archiveLink(objectID: item.objectID, archived: !isArchived)
#if os(iOS)
pop()
dismiss()
Snackbar.show(message: isArchived ? "Unarchived" : "Archived", undoAction: {
dataService.archiveLink(objectID: item.objectID, archived: isArchived)
Snackbar.show(message: isArchived ? "Archived" : "Unarchived", dismissAfter: 2000)
}, dismissAfter: 2000)
#endif
}
@ -683,6 +661,10 @@ struct WebReaderContainerView: View {
func share() {
shareActionID = UUID()
}
func print() {
shareActionID = UUID()
}
func copyDeeplink() {
if let deepLink = item.deepLink {
@ -693,14 +675,15 @@ struct WebReaderContainerView: View {
pasteBoard.clearContents()
pasteBoard.writeObjects([deepLink.absoluteString as NSString])
#endif
showInLibrarySnackbar("Deeplink Copied")
Snackbar.show(message: "Deeplink Copied", dismissAfter: 2000)
} else {
showInLibrarySnackbar("Error copying deeplink")
Snackbar.show(message: "Error copying deeplink", dismissAfter: 2000)
}
}
func delete() {
pop()
dismiss()
#if os(iOS)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
removeLibraryItemAction(dataService: dataService, objectID: item.objectID)

View file

@ -20,6 +20,7 @@ final class WebReaderCoordinator: NSObject {
var previousShowNavBarActionID: UUID?
var previousShareActionID: UUID?
var updateNavBarVisibility: (Bool) -> Void = { _ in }
var updateShowBottomBar: (Bool) -> Void = { _ in }
var articleContentID = UUID()
private var yOffsetAtStartOfDrag: Double?
private var lastYOffset: Double = 0
@ -123,8 +124,9 @@ extension WebReaderCoordinator: WKNavigationDelegate {
// if at bottom show the controls
if yOffset + scrollView.visibleSize.height > scrollView.contentSize.height - 140 {
navBarVisible = true
scrollView.contentInset.top = navBarVisible ? readerViewNavBarHeight : 0
updateShowBottomBar(true)
} else {
updateShowBottomBar(false)
}
let percent = Int(((yOffset + scrollView.visibleSize.height) / scrollView.contentSize.height) * 100)

View file

@ -45,11 +45,13 @@ public struct WebReaderLoadingContainer: View {
if let item = viewModel.item {
if let pdfItem = PDFItem.make(item: item) {
#if os(iOS)
NavigationView {
PDFViewer(viewModel: PDFViewerViewModel(pdfItem: pdfItem))
.navigationBarHidden(true)
.navigationViewStyle(.stack)
.accentColor(.appGrayTextContrast)
.onAppear { viewModel.trackReadEvent() }
}
#else
if let pdfURL = pdfItem.pdfURL {
PDFWrapperView(pdfURL: pdfURL)
@ -58,7 +60,7 @@ public struct WebReaderLoadingContainer: View {
} else if item.state == "CONTENT_NOT_FETCHED" {
ProgressView()
} else {
WebReaderContainerView(item: item, pop: { dismiss() })
WebReaderContainerView(item: item)
#if os(iOS)
.navigationViewStyle(.stack)
#endif

View file

@ -3,6 +3,7 @@ import Services
import SwiftUI
import Views
import WebKit
import Utils
struct SafariWebLink: Identifiable {
let id: UUID
@ -20,14 +21,6 @@ struct SafariWebLink: Identifiable {
@Published var showOperationToast: Bool = false
@Published var operationStatus: OperationStatus = .none
@Published var showSnackbar: Bool = false
var snackbarOperation: SnackbarOperation?
func snackbar(message: String) {
snackbarOperation = SnackbarOperation(message: message, undoAction: nil)
showSnackbar = true
}
func hasOriginalUrl(_ item: Models.LibraryItem) -> Bool {
if let pageURLString = item.pageURLString, let host = URL(string: pageURLString)?.host {
if host == "omnivore.app" {
@ -39,7 +32,7 @@ struct SafariWebLink: Identifiable {
}
func downloadAudio(audioController: AudioController, item: Models.LibraryItem) {
snackbar(message: "Downloading Offline Audio")
Snackbar.show(message: "Downloading Offline Audio", dismissAfter: 2000)
isDownloadingAudio = true
if let audioDownloadTask = audioDownloadTask {
@ -53,7 +46,7 @@ struct SafariWebLink: Identifiable {
DispatchQueue.main.async {
self.isDownloadingAudio = false
if !canceled {
self.snackbar(message: downloaded ? "Audio file downloaded" : "Error downloading audio")
Snackbar.show(message: downloaded ? "Audio file downloaded" : "Error downloading audio", dismissAfter: 2000)
}
}
}
@ -214,12 +207,11 @@ struct SafariWebLink: Identifiable {
func saveLink(dataService: DataService, url: URL) {
Task {
do {
snackbar(message: "Saving link")
print("SAVING: ", url.absoluteString)
Snackbar.show(message: "Saving link", dismissAfter: 5000)
_ = try await dataService.createPageFromUrl(id: UUID().uuidString, url: url.absoluteString)
snackbar(message: "Link saved")
Snackbar.show(message: "Link saved", dismissAfter: 2000)
} catch {
snackbar(message: "Error saving link")
Snackbar.show(message: "Error saving link", dismissAfter: 2000)
}
}
}
@ -227,15 +219,30 @@ struct SafariWebLink: Identifiable {
func saveLinkAndFetch(dataService: DataService, username: String, url: URL) {
Task {
do {
snackbar(message: "Saving link")
Snackbar.show(message: "Saving link", dismissAfter: 5000)
let requestId = UUID().uuidString
_ = try await dataService.createPageFromUrl(id: requestId, url: url.absoluteString)
snackbar(message: "Link saved")
Snackbar.show(message: "Link saved", dismissAfter: 2000)
await loadContent(dataService: dataService, username: username, itemID: requestId, retryCount: 0)
} catch {
snackbar(message: "Error saving link")
Snackbar.show(message: "Error saving link", dismissAfter: 2000)
}
}
}
func trackReadEvent(item: Models.LibraryItem) {
let itemID = item.unwrappedID
let slug = item.unwrappedSlug
let originalArticleURL = item.unwrappedPageURLString
EventTracker.track(
.linkRead(
linkID: itemID,
slug: slug,
reader: "WEB",
originalArticleURL: originalArticleURL
)
)
}
}

View file

@ -80,7 +80,12 @@
startAudio(atIndex: itemAudioProperties.startIndex, andOffset: itemAudioProperties.startOffset)
EventTracker.track(
.audioSessionStart(linkID: itemAudioProperties.itemID)
.audioSessionStart(
linkID: itemAudioProperties.itemID,
voice: currentVoice.lowercased(),
voiceProvider: Voices.isUltraRealisticVoice(currentVoice) ? "ultra" :
Voices.isOpenAIVoice(currentVoice) ? "openai" : "default"
)
)
}

View file

@ -1,4 +1,3 @@
import AsyncAlgorithms
import CoreData
import CoreImage
import Foundation
@ -26,7 +25,6 @@ public final class DataService: ObservableObject {
public let networker: Networker
public let prefetchQueue = OperationQueue()
public let itemLoaderChannel = AsyncChannel<String>()
var persistentContainer: PersistentContainer
public var backgroundContext: NSManagedObjectContext

File diff suppressed because it is too large Load diff

View file

@ -14,11 +14,6 @@ extension DataService {
// Send update to server
self.syncLinkArchiveStatus(itemID: linkedItem.unwrappedID, archived: archived)
let message = archived ? "Link archived" : "Link unarchived"
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
showInLibrarySnackbar(message)
}
}
}

View file

@ -0,0 +1,79 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func emptyTrash() async -> Bool {
enum MutationResult {
case result(success: Bool)
case error(errorMessage: String)
}
let selection = Selection<MutationResult, Unions.EmptyTrashResult> {
try $0.on(
emptyTrashError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") },
emptyTrashSuccess: .init {
.result(success: try $0.success() ?? false)
}
)
}
let mutation = Selection.Mutation {
try $0.emptyTrash(selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
let context = backgroundContext
return await withCheckedContinuation { continuation in
send(mutation, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
print("network error emptying trash")
continuation.resume(returning: false)
return
}
switch (payload.data) {
case let .result(success):
if !success {
print("server did not return success for emptying trash")
continuation.resume(returning: false)
return
}
default:
print("server did not return success for emptying trash")
continuation.resume(returning: false)
return
}
do {
try context.performAndWait {
let fetchRequest = LibraryItem.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "%K == %i OR %K == \"DELETED\"",
#keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
#keyPath(Models.LibraryItem.state)
)
for object in try context.fetch(fetchRequest) {
context.delete(object)
}
do {
try context.save()
logger.debug("Empty trash completed")
continuation.resume(returning: true)
} catch {
context.rollback()
logger.debug("Failed to sync library item move: \(error.localizedDescription)")
continuation.resume(returning: false)
}
}
} catch {
print("error emptying trash", error)
continuation.resume(returning: false)
}
}
}
}
}

View file

@ -309,7 +309,7 @@ private let syncItemEdgeSelection = Selection.SyncUpdatedItemEdge {
}
private let searchItemSelection = Selection.SearchItem {
InternalLibraryItem(
return InternalLibraryItem(
id: try $0.id(),
title: try $0.title(),
createdAt: try $0.createdAt().value ?? Date(),

View file

@ -208,6 +208,10 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
predicate != nil
}
public var ignoreFolders: Bool {
return name == "Deleted"
}
public var predicate: NSPredicate? {
let undeletedPredicate = NSPredicate(
format: "%K != %i AND %K != \"DELETED\"",

View file

@ -5,8 +5,7 @@ import Models
public extension NSNotification {
static let PushJSONArticle = Notification.Name("PushJSONArticle")
static let PushReaderItem = Notification.Name("PushReaderItem")
static let LibrarySnackBar = Notification.Name("LibrarySnackBar")
static let ReaderSnackBar = Notification.Name("ReaderSnackBar")
static let SnackBar = Notification.Name("SnackBar")
static let OperationFailure = Notification.Name("OperationFailure")
static let ReaderSettingsChanged = Notification.Name("ReaderSettingsChanged")
static let SpeakingReaderItem = Notification.Name("SpeakingReaderItem")
@ -27,12 +26,8 @@ public extension NSNotification {
NotificationCenter.default.publisher(for: PushReaderItem)
}
static var readerSnackBarPublisher: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: ReaderSnackBar)
}
static var librarySnackBarPublisher: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: LibrarySnackBar)
static var snackBarPublisher: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: SnackBar)
}
static var operationFailedPublisher: NotificationCenter.Publisher {
@ -82,10 +77,12 @@ public extension NSNotification {
)
}
static func librarySnackBar(message: String, undoAction: (() -> Void)?) {
NotificationCenter.default.post(name: NSNotification.LibrarySnackBar,
static func snackBar(message: String, undoAction: (() -> Void)?, dismissAfter: Int?) {
NotificationCenter.default.post(name: NSNotification.SnackBar,
object: nil,
userInfo: ["message": message, "undoAction": undoAction as Any])
userInfo: ["message": message,
"undoAction": undoAction as Any,
"dismissAfter": dismissAfter as Any])
}
static func operationFailed(message: String) {

View file

@ -4,7 +4,7 @@ public enum TrackableEvent {
case linkRead(linkID: String, slug: String, reader: String, originalArticleURL: String)
case debugMessage(message: String)
case backgroundFetch(jobStatus: BackgroundFetchJobStatus, itemCount: Int, secondsElapsed: Int)
case audioSessionStart(linkID: String)
case audioSessionStart(linkID: String, voice: String, voiceProvider: String)
case audioSessionEnd(linkID: String, timeElapsed: Double)
}
@ -48,9 +48,11 @@ public extension TrackableEvent {
"seconds_elapsed": String(secondsElapsed),
"fetched_item_count": String(itemCount)
]
case let .audioSessionStart(linkID: linkID):
case let .audioSessionStart(linkID: linkID, voice: voice, voiceProvider: voiceProvider):
return [
"link": linkID
"link": linkID,
"voice": voice,
"voiceProvider": voiceProvider
]
case let .audioSessionEnd(linkID: linkID, timeElapsed: timeElapsed):
return [

View file

@ -1,18 +0,0 @@
//
// ShowInSnackbar.swift
//
//
// Created by Jackson Harper on 11/1/22.
//
import Foundation
public func showInLibrarySnackbar(_ message: String) {
let nname = Notification.Name("LibrarySnackBar")
NotificationCenter.default.post(name: nname, object: nil, userInfo: ["message": message])
}
public func showInReaderSnackbar(_ message: String) {
let nname = Notification.Name("ReaderSnackBar")
NotificationCenter.default.post(name: nname, object: nil, userInfo: ["message": message])
}

View file

@ -1,6 +1,7 @@
import Models
import Utils
import WebKit
// swiftlint:disable file_length
/// Describes actions that can be sent from the WebView back to native views.
@ -191,6 +192,14 @@ public final class OmnivoreWebView: WKWebView {
}
}
#endif
// Because all the snackbar stuff lives in app we just use notifications here
func showInReaderSnackbar(_ message: String) {
NotificationCenter.default.post(name: Notification.Name("SnackBar"),
object: nil,
userInfo: ["message": message,
"dismissAfter": 2000 as Any])
}
}
#if os(iOS)

File diff suppressed because one or more lines are too long

View file

@ -9,7 +9,6 @@ import {
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm'
import { Highlight } from './highlight'

View file

@ -1,7 +1,7 @@
import { Job } from 'bullmq'
import { DataSource } from 'typeorm'
import { v4 as uuid } from 'uuid'
import { getBackendQueue } from '../../queue-processor'
import { getBackendQueue, JOB_VERSION } from '../../queue-processor'
import { validateUrl } from '../../services/create_page_save_request'
import { RssSubscriptionGroup } from '../../utils/createTask'
import { stringToHash } from '../../utils/helpers'
@ -136,9 +136,9 @@ export const queueRSSRefreshFeedJob = async (
return undefined
}
return queue.add('refresh-feed', payload, {
jobId: jobid,
jobId: `${jobid}_${JOB_VERSION}`,
priority: options.priority == 'low' ? 10 : 50,
removeOnComplete: true,
removeOnFail: true,
priority: options.priority == 'low' ? 10 : 50,
})
}

View file

@ -129,6 +129,9 @@ export const isContentFetchBlocked = (feedUrl: string) => {
if (feedUrl.startsWith('https://rss.arxiv.org')) {
return true
}
if (feedUrl.startsWith('https://rsshub.app')) {
return true
}
if (feedUrl.startsWith('https://xkcd.com')) {
return true
}

View file

@ -24,6 +24,7 @@ const REQUEST_TIMEOUT = 30000 // 30 seconds
interface Data {
userId: string
url: string
finalUrl: string
articleSavingRequestId: string
state?: string
labels?: CreateLabelInput[]
@ -175,16 +176,21 @@ export const savePageJob = async (data: Data, attemptsMade: number) => {
publishedAt,
taskId,
url,
finalUrl,
} = data
let isImported,
isSaved,
state = data.state
try {
logger.info(`savePageJob: ${userId} ${url}`)
logger.info('savePageJob', {
userId,
url,
finalUrl,
})
// get the fetch result from cache
const fetchedResult = await getCachedFetchResult(url)
const fetchedResult = await getCachedFetchResult(finalUrl)
const { title, contentType } = fetchedResult
let content = fetchedResult.content
@ -200,11 +206,15 @@ export const savePageJob = async (data: Data, attemptsMade: number) => {
// for pdf content, we need to upload the pdf
if (contentType === 'application/pdf') {
const uploadResult = await uploadPdf(url, userId, articleSavingRequestId)
const uploadResult = await uploadPdf(
finalUrl,
userId,
articleSavingRequestId
)
const result = await saveFile(
{
url,
url: finalUrl,
uploadFileId: uploadResult.uploadFileId,
state: state ? (state as ArticleSavingRequestStatus) : undefined,
labels,
@ -227,7 +237,7 @@ export const savePageJob = async (data: Data, attemptsMade: number) => {
}
if (!content) {
logger.info('content is not fetched', url)
logger.info(`content is not fetched: ${finalUrl}`)
// set the state to failed if we don't have content
content = 'Failed to fetch content'
state = ArticleSavingRequestStatus.Failed
@ -237,6 +247,7 @@ export const savePageJob = async (data: Data, attemptsMade: number) => {
const result = await savePage(
{
url,
finalUrl,
clientRequestId: articleSavingRequestId,
title,
originalContent: content,

View file

@ -37,6 +37,7 @@ import { CACHED_READING_POSITION_PREFIX } from './services/cached_reading_positi
import { CustomTypeOrmLogger, logger } from './utils/logger'
export const QUEUE_NAME = 'omnivore-backend-queue'
export const JOB_VERSION = 'v001'
let backendQueue: Queue | undefined
export const getBackendQueue = async (): Promise<Queue | undefined> => {
@ -49,6 +50,18 @@ export const getBackendQueue = async (): Promise<Queue | undefined> => {
}
backendQueue = new Queue(QUEUE_NAME, {
connection: redisDataSource.workerRedisClient,
defaultJobOptions: {
backoff: {
type: 'exponential',
delay: 2000, // 2 seconds
},
removeOnComplete: {
age: 24 * 3600, // keep up to 24 hours
},
removeOnFail: {
age: 7 * 24 * 3600, // keep up to 7 days
},
},
})
await backendQueue.waitUntilReady()
return backendQueue
@ -111,7 +124,6 @@ const setupCronJobs = async () => {
priority: 1,
repeat: {
every: 60_000,
limit: 100,
},
}
)

View file

@ -17,6 +17,10 @@ export const getColumns = <T>(repository: Repository<T>): (keyof T)[] => {
) as (keyof T)[]
}
export const getColumnsDbName = <T>(repository: Repository<T>): string[] => {
return repository.metadata.columns.map((col) => col.databaseName)
}
export const setClaims = async (
manager: EntityManager,
uid = '00000000-0000-0000-0000-000000000000',

View file

@ -1,5 +1,15 @@
import { DeepPartial } from 'typeorm'
import { getColumns, getColumnsDbName } from '.'
import { appDataSource } from '../data_source'
import { LibraryItem } from '../entity/library_item'
import { keysToCamelCase, wordsCount } from '../utils/helpers'
const convertToLibraryItem = (item: DeepPartial<LibraryItem>) => {
return {
...item,
wordCount: item.wordCount ?? wordsCount(item.readableContent || ''),
}
}
export const libraryItemRepository = appDataSource
.getRepository(LibraryItem)
@ -20,6 +30,42 @@ export const libraryItemRepository = appDataSource
return this.countBy({ createdAt })
},
async upsertLibraryItem(item: DeepPartial<LibraryItem>, finalUrl?: string) {
const columns = getColumnsDbName(this)
// overwrites columns except id and slug
const overwrites = columns.filter(
(column) => !['id', 'slug'].includes(column)
)
const hashedUrl = 'md5(original_url)'
let conflictColumns = ['user_id', hashedUrl]
if (item.id && finalUrl && finalUrl !== item.originalUrl) {
// update the original url if it's different from the current one in the database
conflictColumns = ['id']
item.originalUrl = finalUrl
}
const [query, params] = this.createQueryBuilder()
.insert()
.into(LibraryItem)
.values(convertToLibraryItem(item))
.orUpdate(overwrites, conflictColumns, {
skipUpdateIfNoValuesChanged: true,
})
.returning(getColumns(this))
.getQueryAndParameters()
// this is a workaround for the typeorm bug which quotes the md5 function
const newQuery = query.replace(`"${hashedUrl}"`, hashedUrl)
const results = (await this.query(newQuery, params)) as never[]
// convert to camel case
const newItem = keysToCamelCase(results[0]) as LibraryItem
return newItem
},
createByPopularRead(name: string, userId: string) {
return this.query(
`

View file

@ -6,7 +6,6 @@
import { Readability } from '@omnivore/readability'
import graphqlFields from 'graphql-fields'
import { IsNull } from 'typeorm'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
import { env } from '../../env'
import {
@ -59,7 +58,7 @@ import {
UpdatesSinceError,
UpdatesSinceSuccess,
} from '../../generated/graphql'
import { authTrx, getColumns } from '../../repository'
import { getColumns } from '../../repository'
import { getInternalLabelWithColor } from '../../repository/label'
import { libraryItemRepository } from '../../repository/library_item'
import { userRepository } from '../../repository/user'
@ -74,9 +73,7 @@ import {
import {
batchDelete,
batchUpdateLibraryItems,
createLibraryItem,
findLibraryItemById,
findLibraryItemByUrl,
createOrUpdateLibraryItem,
findLibraryItemsByPrefix,
searchLibraryItems,
sortParamsToSort,
@ -261,7 +258,7 @@ export const createArticleResolver = authorized<
FORCE_PUPPETEER_URLS.some((regex) => regex.test(url))
) {
await createPageSaveRequest({
userId: uid,
user: userData,
url,
state: state || undefined,
labels: inputLabels || undefined,
@ -286,7 +283,7 @@ export const createArticleResolver = authorized<
// We have a URL but no document, so we try to send this to puppeteer
// and return a dummy response.
await createPageSaveRequest({
userId: uid,
user: userData,
url,
state: state || undefined,
labels: inputLabels || undefined,
@ -340,29 +337,12 @@ export const createArticleResolver = authorized<
}
}
let libraryItemToReturn: LibraryItem
const existingLibraryItem = await findLibraryItemByUrl(
libraryItemToSave.originalUrl,
uid
// create new item in database
const libraryItemToReturn = await createOrUpdateLibraryItem(
libraryItemToSave,
uid,
pubsub
)
articleSavingRequestId = existingLibraryItem?.id || articleSavingRequestId
if (articleSavingRequestId) {
// update existing item's state from processing to succeeded
libraryItemToReturn = await updateLibraryItem(
articleSavingRequestId,
libraryItemToSave as QueryDeepPartialEntity<LibraryItem>,
uid,
pubsub
)
} else {
// create new item in database
libraryItemToReturn = await createLibraryItem(
libraryItemToSave,
uid,
pubsub
)
}
await createAndSaveLabelsInLibraryItem(
libraryItemToReturn.id,
@ -371,14 +351,6 @@ export const createArticleResolver = authorized<
rssFeedUrl
)
log.info(
'item created in database',
libraryItemToReturn.id,
libraryItemToReturn.originalUrl,
libraryItemToReturn.slug,
libraryItemToReturn.title
)
return {
user,
created: true,
@ -607,7 +579,7 @@ export const saveArticleReadingProgressResolver = authorized<
force,
},
},
{ log, pubsub, uid, dataSources }
{ authTrx, pubsub, uid, dataSources }
) => {
if (
readingProgressPercent < 0 ||
@ -619,13 +591,50 @@ export const saveArticleReadingProgressResolver = authorized<
) {
return { errorCodes: [SaveArticleReadingProgressErrorCode.BadData] }
}
try {
// We don't need to update the values of reading progress here
// because the function resolver will handle that for us when
// it resolves the properties of the Article object
let updatedItem = await authTrx((tx) =>
tx.getRepository(LibraryItem).findOne({
where: {
id,
},
relations: ['user'],
})
)
if (!updatedItem) {
return {
errorCodes: [SaveArticleReadingProgressErrorCode.Unauthorized],
}
}
if (env.redis.cache && env.redis.mq) {
if (force) {
// update reading progress without checking the current value, also
// clear any cached values.
await clearCachedReadingPosition(uid, id)
}
const updatedItem = await updateLibraryItem(
// If redis caching and queueing are available we delay this write
const updatedProgress =
await dataSources.readingProgress.updateReadingProgress(uid, id, {
readingProgressPercent,
readingProgressTopPercent: readingProgressTopPercent ?? undefined,
readingProgressAnchorIndex: readingProgressAnchorIndex ?? undefined,
})
if (updatedProgress) {
updatedItem.readAt = new Date()
updatedItem.readingProgressBottomPercent =
updatedProgress.readingProgressPercent
updatedItem.readingProgressTopPercent =
updatedProgress.readingProgressTopPercent || 0
updatedItem.readingProgressHighestReadAnchor =
updatedProgress.readingProgressAnchorIndex || 0
}
} else {
if (force) {
// update reading progress without checking the current value
updatedItem = await updateLibraryItem(
id,
{
readingProgressBottomPercent: readingProgressPercent,
@ -637,39 +646,6 @@ export const saveArticleReadingProgressResolver = authorized<
uid,
pubsub
)
return {
updatedArticle: libraryItemToArticle(updatedItem),
}
}
let updatedItem: LibraryItem | null
if (env.redis.cache && env.redis.mq) {
// If redis caching and queueing are available we delay this write
const updatedProgress =
await dataSources.readingProgress.updateReadingProgress(uid, id, {
readingProgressPercent,
readingProgressTopPercent: readingProgressTopPercent ?? undefined,
readingProgressAnchorIndex: readingProgressAnchorIndex ?? undefined,
})
// We don't need to update the values of reading progress here
// because the function resolver will handle that for us when
// it resolves the properties of the Article object
updatedItem = await authTrx(
async (t) => {
return t.getRepository(LibraryItem).findOne({
where: {
id,
},
})
},
undefined,
uid
)
if (updatedItem) {
updatedItem.readAt = new Date()
}
} else {
updatedItem = await updateLibraryItemReadingProgress(
id,
@ -678,19 +654,17 @@ export const saveArticleReadingProgressResolver = authorized<
readingProgressTopPercent,
readingProgressAnchorIndex
)
}
if (!updatedItem) {
return { errorCodes: [SaveArticleReadingProgressErrorCode.BadData] }
if (!updatedItem) {
return {
errorCodes: [SaveArticleReadingProgressErrorCode.BadData],
}
}
}
}
return {
updatedArticle: libraryItemToArticle(updatedItem),
}
} catch (error) {
log.error('saveArticleReadingProgressResolver error', error)
return { errorCodes: [SaveArticleReadingProgressErrorCode.Unauthorized] }
return {
updatedArticle: libraryItemToArticle(updatedItem),
}
}
)
@ -1007,7 +981,7 @@ export const moveToFolderResolver = authorized<
if (item.state === LibraryItemState.ContentNotFetched) {
try {
await createPageSaveRequest({
userId: uid,
user: item.user,
url: item.originalUrl,
articleSavingRequestId: id,
priority: 'high',
@ -1034,7 +1008,7 @@ export const fetchContentResolver = authorized<
FetchContentSuccess,
FetchContentError,
MutationFetchContentArgs
>(async (_, { id }, { uid, log, pubsub }) => {
>(async (_, { id }, { authTrx, uid, log, pubsub }) => {
analytics.track({
userId: uid,
event: 'fetch_content',
@ -1043,7 +1017,14 @@ export const fetchContentResolver = authorized<
},
})
const item = await findLibraryItemById(id, uid)
const item = await authTrx((tx) =>
tx.getRepository(LibraryItem).findOne({
where: {
id,
},
relations: ['user'],
})
)
if (!item) {
return {
errorCodes: [FetchContentErrorCode.Unauthorized],
@ -1054,7 +1035,7 @@ export const fetchContentResolver = authorized<
if (item.state === LibraryItemState.ContentNotFetched) {
try {
await createPageSaveRequest({
userId: uid,
user: item.user,
url: item.originalUrl,
articleSavingRequestId: id,
priority: 'high',

View file

@ -42,9 +42,14 @@ export const createArticleSavingRequestResolver = authorized<
},
})
const user = await userRepository.findById(uid)
if (!user) {
return { errorCodes: [CreateArticleSavingRequestErrorCode.Unauthorized] }
}
try {
const articleSavingRequest = await createPageSaveRequest({
userId: uid,
user,
url,
pubsub,
})

View file

@ -7,6 +7,7 @@ import * as jwt from 'jsonwebtoken'
import { Speech } from '../entity/speech'
import { env } from '../env'
import { CreateArticleErrorCode } from '../generated/graphql'
import { userRepository } from '../repository/user'
import { Claims } from '../resolvers/types'
import { createPageSaveRequest } from '../services/create_page_save_request'
import { findLibraryItemById } from '../services/library_item'
@ -32,6 +33,9 @@ export function articleRouter() {
const { url } = req.body as {
url?: string
}
if (!url) {
return res.status(400).send({ errorCode: 'BAD_DATA' })
}
const token = req?.cookies?.auth || req?.headers?.authorization
const claims = await getClaimsByToken(token)
@ -40,20 +44,12 @@ export function articleRouter() {
}
const { uid } = claims
logger.info('Article saving request', {
body: req.body,
labels: {
source: 'SaveEndpoint',
userId: uid,
},
})
if (!url) {
return res.status(400).send({ errorCode: 'BAD_DATA' })
const user = await userRepository.findById(uid)
if (!user) {
return res.status(400).send('Bad Request')
}
const result = await createPageSaveRequest({ userId: uid, url })
const result = await createPageSaveRequest({ user, url })
if (isSiteBlockedForParse(url)) {
return res

View file

@ -13,7 +13,7 @@ import { PageType, UploadFileStatus } from '../generated/graphql'
import { authTrx } from '../repository'
import { Claims } from '../resolvers/types'
import {
createLibraryItem,
createOrUpdateLibraryItem,
findLibraryItemById,
findLibraryItemByUrl,
restoreLibraryItem,
@ -101,7 +101,7 @@ export function pageRouter() {
if (item) {
await restoreLibraryItem(item.id, claims.uid)
} else {
await createLibraryItem(
await createOrUpdateLibraryItem(
{
originalUrl: signedUrl,
id: clientRequestId,

View file

@ -9,7 +9,7 @@ import { UploadFile } from '../../entity/upload_file'
import { env } from '../../env'
import { PageType, UploadFileStatus } from '../../generated/graphql'
import { authTrx } from '../../repository'
import { createLibraryItem } from '../../services/library_item'
import { createOrUpdateLibraryItem } from '../../services/library_item'
import { findNewsletterEmailByAddress } from '../../services/newsletters'
import { updateReceivedEmail } from '../../services/received_emails'
import {
@ -170,7 +170,7 @@ export function emailAttachmentRouter() {
: ContentReaderType.EPUB,
}
const item = await createLibraryItem(itemToCreate, user.id)
const item = await createOrUpdateLibraryItem(itemToCreate, user.id)
// update received email type
await updateReceivedEmail(receivedEmailId, 'article', user.id)

View file

@ -6,7 +6,7 @@ import {
PreparedDocumentInput,
} from '../../generated/graphql'
import { createAndSaveLabelsInLibraryItem } from '../../services/labels'
import { createLibraryItem } from '../../services/library_item'
import { createOrUpdateLibraryItem } from '../../services/library_item'
import { parsedContentToLibraryItem } from '../../services/save_page'
import { cleanUrl, generateSlug } from '../../utils/helpers'
import { createThumbnailUrl } from '../../utils/imageproxy'
@ -123,7 +123,7 @@ export function followingServiceRouter() {
state: ArticleSavingRequestStatus.ContentNotFetched,
})
const newItem = await createLibraryItem(itemToSave, userId)
const newItem = await createOrUpdateLibraryItem(itemToSave, userId)
logger.info('feed item saved in following')
// save RSS label in the item

View file

@ -5,6 +5,7 @@ import express from 'express'
import { LessThan } from 'typeorm'
import { LibraryItemState } from '../../entity/library_item'
import { readPushSubscription } from '../../pubsub'
import { userRepository } from '../../repository/user'
import { createPageSaveRequest } from '../../services/create_page_save_request'
import { deleteLibraryItemsByAdmin } from '../../services/library_item'
import { logger } from '../../utils/logger'
@ -65,9 +66,14 @@ export function linkServiceRouter() {
}
const msg = data as CreateLinkRequestMessage
const user = await userRepository.findById(msg.userId)
if (!user) {
return res.status(400).send('Bad Request')
}
try {
const request = await createPageSaveRequest({
userId: msg.userId,
user,
url: msg.url,
})

View file

@ -1,5 +1,6 @@
import * as privateIpLib from 'private-ip'
import { LibraryItemState } from '../entity/library_item'
import { User } from '../entity/user'
import {
ArticleSavingRequest,
ArticleSavingRequestStatus,
@ -8,7 +9,6 @@ import {
PageType,
} from '../generated/graphql'
import { createPubSubClient, PubsubClient } from '../pubsub'
import { userRepository } from '../repository/user'
import { enqueueParseRequest } from '../utils/createTask'
import {
cleanUrl,
@ -16,15 +16,10 @@ import {
libraryItemToArticleSavingRequest,
} from '../utils/helpers'
import { logger } from '../utils/logger'
import {
countByCreatedAt,
createLibraryItem,
findLibraryItemByUrl,
updateLibraryItem,
} from './library_item'
import { countByCreatedAt, createOrUpdateLibraryItem } from './library_item'
interface PageSaveRequest {
userId: string
user: User
url: string
pubsub?: PubsubClient
articleSavingRequestId?: string
@ -80,7 +75,7 @@ export const validateUrl = (url: string): URL => {
}
export const createPageSaveRequest = async ({
userId,
user,
url,
pubsub = createPubSubClient(),
articleSavingRequestId,
@ -102,52 +97,29 @@ export const createPageSaveRequest = async ({
errorCode: CreateArticleSavingRequestErrorCode.BadData,
})
}
// if user is not specified, get it from the database
const user = await userRepository.findById(userId)
if (!user) {
logger.info(`User not found: ${userId}`)
return Promise.reject({
errorCode: CreateArticleSavingRequestErrorCode.BadData,
})
}
const userId = user.id
url = cleanUrl(url)
// look for existing library item
let libraryItem = await findLibraryItemByUrl(url, userId)
if (!libraryItem) {
logger.info('libraryItem does not exist', { url })
// create processing item
libraryItem = await createLibraryItem(
{
id: articleSavingRequestId,
user: { id: userId },
readableContent: SAVING_CONTENT,
itemType: PageType.Unknown,
slug: generateSlug(url),
title: url,
originalUrl: url,
state: LibraryItemState.Processing,
publishedAt,
folder,
subscription,
savedAt,
},
userId,
pubsub
)
}
// reset state to processing
if (libraryItem.state !== LibraryItemState.Processing) {
libraryItem = await updateLibraryItem(
libraryItem.id,
{
state: LibraryItemState.Processing,
},
userId,
pubsub
)
}
// create processing item
const libraryItem = await createOrUpdateLibraryItem(
{
id: articleSavingRequestId || undefined,
user: { id: userId },
readableContent: SAVING_CONTENT,
itemType: PageType.Unknown,
slug: generateSlug(url),
title: url,
originalUrl: url,
state: LibraryItemState.Processing,
publishedAt,
folder,
subscription,
savedAt,
},
userId,
pubsub
)
// get priority by checking rate limit if not specified
priority = priority || (await getPriorityByRateLimit(userId))

View file

@ -180,7 +180,7 @@ export const findHighlightById = async (
return authTrx(
async (tx) => {
const highlightRepo = tx.withRepository(highlightRepository)
return highlightRepo.findOneByOrFail({
return highlightRepo.findOneBy({
id: highlightId,
})
},

View file

@ -187,10 +187,12 @@ export const saveLabelsInHighlight = async (
)
const highlight = await findHighlightById(highlightId, userId)
// update labels in library item
await bulkEnqueueUpdateLabels([
{ libraryItemId: highlight.libraryItemId, userId },
])
if (highlight) {
// update labels in library item
await bulkEnqueueUpdateLabels([
{ libraryItemId: highlight.libraryItemId, userId },
])
}
}
export const findLabelsByIds = async (

View file

@ -14,15 +14,9 @@ import { LibraryItem, LibraryItemState } from '../entity/library_item'
import { BulkActionType, InputMaybe, SortParams } from '../generated/graphql'
import { createPubSubClient, EntityType } from '../pubsub'
import { redisDataSource } from '../redis_data_source'
import {
authTrx,
getColumns,
isUniqueViolation,
queryBuilderToRawSql,
} from '../repository'
import { authTrx, getColumns, queryBuilderToRawSql } from '../repository'
import { libraryItemRepository } from '../repository/library_item'
import { setRecentlySavedItemInRedis, wordsCount } from '../utils/helpers'
import { logger } from '../utils/logger'
import { setRecentlySavedItemInRedis } from '../utils/helpers'
import { parseSearchQuery } from '../utils/search'
import { addLabelsToLibraryItem } from './labels'
@ -831,74 +825,47 @@ export const createLibraryItems = async (
)
}
export const createLibraryItem = async (
export const createOrUpdateLibraryItem = async (
libraryItem: DeepPartial<LibraryItem>,
userId: string,
pubsub = createPubSubClient(),
skipPubSub = false
skipPubSub = false,
finalUrl?: string
): Promise<LibraryItem> => {
if (!libraryItem.originalUrl) {
throw new Error('Original url is required')
const newLibraryItem = await authTrx(
async (tx) =>
tx
.withRepository(libraryItemRepository)
.upsertLibraryItem(libraryItem, finalUrl),
undefined,
userId
)
// set recently saved item in redis if redis is enabled
if (redisDataSource.redisClient) {
await setRecentlySavedItemInRedis(
redisDataSource.redisClient,
userId,
newLibraryItem.originalUrl
)
}
try {
const newLibraryItem = await authTrx(
async (tx) =>
tx.withRepository(libraryItemRepository).save({
...libraryItem,
wordCount:
libraryItem.wordCount ??
wordsCount(libraryItem.readableContent || ''),
}),
undefined,
userId
)
logger.info('item created', { url: libraryItem.originalUrl })
// set recently saved item in redis if redis is enabled
if (redisDataSource.redisClient) {
await setRecentlySavedItemInRedis(
redisDataSource.redisClient,
userId,
newLibraryItem.originalUrl
)
}
if (skipPubSub) {
return newLibraryItem
}
await pubsub.entityCreated<DeepPartial<LibraryItem>>(
EntityType.PAGE,
{
...newLibraryItem,
// don't send original content and readable content
originalContent: undefined,
readableContent: undefined,
},
userId
)
if (skipPubSub) {
return newLibraryItem
} catch (error) {
if (isUniqueViolation(error)) {
logger.info('item already created', { url: libraryItem.originalUrl })
const existingItem = await findLibraryItemByUrl(
libraryItem.originalUrl,
userId
)
if (!existingItem) {
throw new Error(`Item not found for url: ${libraryItem.originalUrl}`)
}
return existingItem
}
logger.error('error creating item', error)
throw error
}
await pubsub.entityCreated<DeepPartial<LibraryItem>>(
EntityType.PAGE,
{
...newLibraryItem,
// don't send original content and readable content
originalContent: undefined,
readableContent: undefined,
},
userId
)
return newLibraryItem
}
export const findLibraryItemsByPrefix = async (
@ -1067,7 +1034,8 @@ export const deleteLibraryItems = async (
userId?: string
) => {
return authTrx(
async (tx) => tx.withRepository(libraryItemRepository).remove(items),
async (tx) =>
tx.withRepository(libraryItemRepository).delete(items.map((i) => i.id)),
undefined,
userId
)

View file

@ -5,7 +5,7 @@ import { Recommendation } from '../entity/recommendation'
import { authTrx } from '../repository'
import { logger } from '../utils/logger'
import { createHighlights } from './highlights'
import { createLibraryItem, findLibraryItemByUrl } from './library_item'
import { createOrUpdateLibraryItem, findLibraryItemByUrl } from './library_item'
export const addRecommendation = async (
item: LibraryItem,
@ -39,7 +39,7 @@ export const addRecommendation = async (
publishedAt: item.publishedAt,
}
recommendedItem = await createLibraryItem(newItem, userId)
recommendedItem = await createOrUpdateLibraryItem(newItem, userId)
const highlights = item.highlights
?.filter((highlight) => highlightIds?.includes(highlight.id))

View file

@ -17,7 +17,7 @@ import {
} from '../utils/parser'
import { createAndSaveLabelsInLibraryItem } from './labels'
import {
createLibraryItem,
createOrUpdateLibraryItem,
findLibraryItemByUrl,
restoreLibraryItem,
} from './library_item'
@ -80,7 +80,7 @@ export const saveEmail = async (
}
// start a transaction to create the library item and update the received email
const newLibraryItem = await createLibraryItem(
const newLibraryItem = await createOrUpdateLibraryItem(
{
user: { id: input.userId },
slug,

View file

@ -1,6 +1,5 @@
import { Readability } from '@omnivore/readability'
import { DeepPartial } from 'typeorm'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { Highlight } from '../entity/highlight'
import { LibraryItem, LibraryItemState } from '../entity/library_item'
import { User } from '../entity/user'
@ -12,8 +11,6 @@ import {
SavePageInput,
SaveResult,
} from '../generated/graphql'
import { authTrx } from '../repository'
import { libraryItemRepository } from '../repository/library_item'
import { enqueueThumbnailJob } from '../utils/createTask'
import {
cleanUrl,
@ -28,7 +25,7 @@ import { contentReaderForLibraryItem } from '../utils/uploads'
import { createPageSaveRequest } from './create_page_save_request'
import { createHighlight } from './highlights'
import { createAndSaveLabelsInLibraryItem } from './labels'
import { createLibraryItem, updateLibraryItem } from './library_item'
import { createOrUpdateLibraryItem } from './library_item'
// where we can use APIs to fetch their underlying content.
const FORCE_PUPPETEER_URLS = [
@ -65,9 +62,39 @@ const shouldParseInBackend = (input: SavePageInput): boolean => {
}
export const savePage = async (
input: SavePageInput,
input: SavePageInput & {
finalUrl?: string
},
user: User
): Promise<SaveResult> => {
const [slug, croppedPathname] = createSlug(input.url, input.title)
let clientRequestId = input.clientRequestId
// always parse in backend if the url is in the force puppeteer list
if (shouldParseInBackend(input)) {
try {
await createPageSaveRequest({
user,
url: input.url,
articleSavingRequestId: clientRequestId || undefined,
state: input.state || undefined,
labels: input.labels || undefined,
folder: input.folder || undefined,
})
} catch (e) {
return {
__typename: 'SaveError',
errorCodes: [SaveErrorCode.Unknown],
message: 'Failed to create page save request',
}
}
return {
clientRequestId,
url: `${homePageURL()}/${user.profile.username}/${slug}`,
}
}
const parseResult = await parsePreparedContent(input.url, {
document: input.originalContent,
pageInfo: {
@ -75,9 +102,6 @@ export const savePage = async (
canonicalUrl: input.url,
},
})
const [newSlug, croppedPathname] = createSlug(input.url, input.title)
let slug = newSlug
let clientRequestId = input.clientRequestId
const itemToSave = parsedContentToLibraryItem({
itemId: clientRequestId,
@ -99,71 +123,22 @@ export const savePage = async (
const isImported =
input.source === 'csv-importer' || input.source === 'pocket'
// always parse in backend if the url is in the force puppeteer list
if (shouldParseInBackend(input)) {
try {
await createPageSaveRequest({
userId: user.id,
url: itemToSave.originalUrl,
articleSavingRequestId: clientRequestId || undefined,
state: input.state || undefined,
labels: input.labels || undefined,
folder: input.folder || undefined,
})
} catch (e) {
return {
__typename: 'SaveError',
errorCodes: [SaveErrorCode.Unknown],
message: 'Failed to create page save request',
}
}
} else {
// check if the item already exists
const existingLibraryItem = await authTrx((t) =>
t
.withRepository(libraryItemRepository)
.findByUserIdAndUrl(user.id, input.url)
)
if (existingLibraryItem) {
clientRequestId = existingLibraryItem.id
slug = existingLibraryItem.slug
// do not publish a pubsub event if the item is imported
const newItem = await createOrUpdateLibraryItem(
itemToSave,
user.id,
undefined,
isImported,
input.finalUrl
)
clientRequestId = newItem.id
// we don't want to update an rss feed item if rss-feeder is tring to re-save it
if (existingLibraryItem.subscription === input.rssFeedUrl) {
return {
clientRequestId,
url: `${homePageURL()}/${user.profile.username}/${slug}`,
}
}
// update the item except for id and slug
await updateLibraryItem(
clientRequestId,
{
...itemToSave,
id: undefined,
slug: undefined,
} as QueryDeepPartialEntity<LibraryItem>,
user.id
)
} else {
// do not publish a pubsub event if the item is imported
const newItem = await createLibraryItem(
itemToSave,
user.id,
undefined,
isImported
)
clientRequestId = newItem.id
}
await createAndSaveLabelsInLibraryItem(
clientRequestId,
user.id,
input.labels,
input.rssFeedUrl
)
}
await createAndSaveLabelsInLibraryItem(
clientRequestId,
user.id,
input.labels,
input.rssFeedUrl
)
// we don't want to create thumbnail for imported pages and pages that already have thumbnail
if (!isImported && !parseResult.parsedContent?.previewImage) {

View file

@ -12,7 +12,7 @@ export const saveUrl = async (
try {
const pageSaveRequest = await createPageSaveRequest({
...input,
userId: user.id,
user,
articleSavingRequestId: input.clientRequestId,
state: input.state || undefined,
labels: input.labels || undefined,

View file

@ -17,7 +17,7 @@ import {
generateUploadSignedUrl,
} from '../utils/uploads'
import { validateUrl } from './create_page_save_request'
import { createLibraryItem } from './library_item'
import { createOrUpdateLibraryItem } from './library_item'
const isFileUrl = (url: string): boolean => {
const parsedUrl = new URL(url)
@ -122,7 +122,7 @@ export const uploadFile = async (
// If we have a file:// URL, don't try to match it
// and create a copy of the item, just create a
// new item.
const item = await createLibraryItem(
const item = await createOrUpdateLibraryItem(
{
id: input.clientRequestId || undefined,
originalUrl: isFileUrl(input.url) ? attachmentUrl : input.url,

View file

@ -25,7 +25,7 @@ import {
UPDATE_HIGHLIGHT_JOB,
UPDATE_LABELS_JOB,
} from '../jobs/update_db'
import { getBackendQueue } from '../queue-processor'
import { getBackendQueue, JOB_VERSION } from '../queue-processor'
import { redisDataSource } from '../redis_data_source'
import { signFeatureToken } from '../services/features'
import { OmnivoreAuthorizationHeader } from './auth'
@ -666,8 +666,6 @@ export const enqueueTriggerRuleJob = async (data: TriggerRuleJobData) => {
return queue.add(TRIGGER_RULE_JOB_NAME, data, {
priority: 5,
attempts: 1,
removeOnComplete: true,
removeOnFail: true,
})
}
@ -680,8 +678,6 @@ export const enqueueWebhookJob = async (data: CallWebhookJobData) => {
return queue.add(CALL_WEBHOOK_JOB_NAME, data, {
priority: 5,
attempts: 1,
removeOnComplete: true,
removeOnFail: true,
})
}
@ -695,12 +691,11 @@ export const bulkEnqueueUpdateLabels = async (data: UpdateLabelsData[]) => {
name: UPDATE_LABELS_JOB,
data: d,
opts: {
attempts: 3,
jobId: `${UPDATE_LABELS_JOB}_${d.libraryItemId}_${JOB_VERSION}`,
attempts: 6,
priority: 1,
backoff: {
type: 'exponential',
delay: 1000,
},
removeOnComplete: true,
removeOnFail: true,
},
}))
@ -720,12 +715,11 @@ export const enqueueUpdateHighlight = async (data: UpdateHighlightData) => {
try {
return queue.add(UPDATE_HIGHLIGHT_JOB, data, {
attempts: 3,
jobId: `${UPDATE_HIGHLIGHT_JOB}_${data.libraryItemId}_${JOB_VERSION}`,
attempts: 6,
priority: 1,
backoff: {
type: 'exponential',
delay: 1000,
},
removeOnComplete: true,
removeOnFail: true,
})
} catch (error) {
logger.error('error enqueuing update highlight job', error)
@ -738,7 +732,7 @@ export const enqueueBulkAction = async (data: BulkActionData) => {
return undefined
}
const jobId = `${BULK_ACTION_JOB_NAME}-${data.userId}`
const jobId = `${BULK_ACTION_JOB_NAME}_${data.userId}_${JOB_VERSION}`
try {
return queue.add(BULK_ACTION_JOB_NAME, data, {

View file

@ -12,7 +12,7 @@ import { authTrx, getRepository, setClaims } from '../src/repository'
import { highlightRepository } from '../src/repository/highlight'
import { userRepository } from '../src/repository/user'
import { createUser } from '../src/services/create_user'
import { createLibraryItem } from '../src/services/library_item'
import { createOrUpdateLibraryItem } from '../src/services/library_item'
import { createDeviceToken } from '../src/services/user_device_tokens'
import {
bulkEnqueueUpdateLabels,
@ -120,7 +120,7 @@ export const createTestLibraryItem = async (
slug: 'test-with-omnivore',
}
const createdItem = await createLibraryItem(item, userId)
const createdItem = await createOrUpdateLibraryItem(item, userId)
if (labels) {
await saveLabelsInLibraryItem(labels, createdItem.id, userId)
}

View file

@ -23,7 +23,7 @@ import { getRepository } from '../../src/repository'
import { createGroup, deleteGroup } from '../../src/services/groups'
import { createLabel, deleteLabels } from '../../src/services/labels'
import {
createLibraryItem,
createOrUpdateLibraryItem,
createLibraryItems,
deleteLibraryItemById,
deleteLibraryItemByUrl,
@ -408,7 +408,7 @@ describe('Article API', () => {
document = '<p>test</p>'
title = 'new title'
const item = await createLibraryItem(
const item = await createOrUpdateLibraryItem(
{
readableContent: document,
slug: 'test saving an archived article slug',
@ -450,17 +450,22 @@ describe('Article API', () => {
readingProgressTopPercent: 100,
user,
originalUrl: 'https://blog.omnivore.app/test-with-omnivore',
highlights: [
{
shortId: 'test short id',
patch: 'test patch',
quote: 'test quote',
user,
},
],
}
const item = await createLibraryItem(itemToCreate, user.id)
const item = await createOrUpdateLibraryItem(itemToCreate, user.id)
itemId = item.id
// save highlights
await createHighlight(
{
shortId: 'test short id',
patch: 'test patch',
quote: 'test quote',
user,
libraryItem: item,
},
itemId,
user.id
)
})
after(async () => {
@ -629,7 +634,7 @@ describe('Article API', () => {
const savedItem = await findLibraryItemByUrl(url, user.id)
expect(savedItem?.archivedAt).to.not.be.null
expect(savedItem?.labels?.map((l) => l.name)).to.eql(labels)
expect(savedItem?.labels?.map((l) => l.name)).to.include.members(labels)
})
})
@ -698,7 +703,7 @@ describe('Article API', () => {
originalUrl: 'https://blog.omnivore.app/setBookmarkArticle',
slug: 'test-with-omnivore',
}
const item = await createLibraryItem(itemToSave, user.id)
const item = await createOrUpdateLibraryItem(itemToSave, user.id)
itemId = item.id
})
@ -802,7 +807,7 @@ describe('Article API', () => {
context('when force is true', () => {
before(async () => {
itemId = (
await createLibraryItem(
await createOrUpdateLibraryItem(
{
user: { id: user.id },
originalUrl: 'https://blog.omnivore.app/setBookmarkArticle',
@ -843,7 +848,7 @@ describe('Article API', () => {
let itemId = ''
before(async () => {
const item = await createLibraryItem(
const item = await createOrUpdateLibraryItem(
{
user: { id: user.id },
originalUrl: 'https://blog.omnivore.app/setBookmarkArticle',
@ -928,7 +933,7 @@ describe('Article API', () => {
siteName: 'Example',
readingProgressBottomPercent: readingProgressArray[i],
}
const item = await createLibraryItem(itemToSave, user.id)
const item = await createOrUpdateLibraryItem(itemToSave, user.id)
items.push(item)
// Create some test highlights
@ -1973,7 +1978,7 @@ describe('Article API', () => {
slug: '',
originalUrl: `https://blog.omnivore.app/p/typeahead-search-${i}`,
}
const item = await createLibraryItem(itemToSave, user.id)
const item = await createOrUpdateLibraryItem(itemToSave, user.id)
items.push(item)
}
})
@ -2047,7 +2052,7 @@ describe('Article API', () => {
originalUrl: `https://blog.omnivore.app/p/updates-since-${i}`,
user,
}
const item = await createLibraryItem(itemToSave, user.id)
const item = await createOrUpdateLibraryItem(itemToSave, user.id)
items.push(item)
}
@ -2163,7 +2168,7 @@ describe('Article API', () => {
before(async () => {
// Create some test items
for (let i = 0; i < 5; i++) {
await createLibraryItem(
await createOrUpdateLibraryItem(
{
user,
itemType: i == 0 ? PageType.Article : PageType.File,
@ -2256,7 +2261,7 @@ describe('Article API', () => {
before(async () => {
// Create some test items
for (let i = 0; i < 5; i++) {
const item = await createLibraryItem(
const item = await createOrUpdateLibraryItem(
{
user,
itemType: i == 0 ? PageType.Article : PageType.File,
@ -2318,7 +2323,7 @@ describe('Article API', () => {
readableContent: '<p>test</p>',
originalUrl: `https://blog.omnivore.app/p/setFavoriteArticle`,
}
const item = await createLibraryItem(itemToSave, user.id)
const item = await createOrUpdateLibraryItem(itemToSave, user.id)
articleId = item.id
})
@ -2365,7 +2370,7 @@ describe('Article API', () => {
deletedAt: new Date(),
state: LibraryItemState.Deleted,
}
const item = await createLibraryItem(itemToSave, user.id)
const item = await createOrUpdateLibraryItem(itemToSave, user.id)
items.push(item)
}
})

View file

@ -295,8 +295,8 @@ describe('Highlights API', () => {
expect(res.body.data.mergeHighlight.highlight.id).to.eq(newHighlightId)
const highlight = await findHighlightById(newHighlightId, user.id)
expect(highlight.labels).to.have.lengthOf(1)
expect(highlight.labels?.[0]?.name).to.eq(labelName)
expect(highlight?.labels).to.have.lengthOf(1)
expect(highlight?.labels?.[0]?.name).to.eq(labelName)
highlightId = newHighlightId
})

View file

@ -38,7 +38,7 @@ const getAttempts = (job: savePageJob): number => {
return 1
}
return 2
return 3
}
const getOpts = (job: savePageJob): BulkJobOptions => {
@ -48,6 +48,10 @@ const getOpts = (job: savePageJob): BulkJobOptions => {
// removeOnFail: true,
attempts: getAttempts(job),
priority: getPriority(job),
backoff: {
type: 'exponential',
delay: 2000,
},
}
}

View file

@ -116,7 +116,8 @@ export const contentFetchRequestHandler: RequestHandler = async (req, res) => {
userId: user.id,
data: {
userId: user.id,
url: finalUrl,
url,
finalUrl,
articleSavingRequestId,
state,
labels,

View file

@ -0,0 +1,7 @@
-- Type: DO
-- Name: add_subscriptions_name_index
-- Description: Add an index to the subscriptions name column
CREATE INDEX CONCURRENTLY IF NOT EXISTS subscriptions_user_id_name_index ON omnivore.subscriptions (user_id, name);

View file

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: add_subscriptions_name_index
-- Description: Add an index to the subscriptions name column
BEGIN;
DROP INDEX IF EXISTS omnivore.subscriptions_user_id_name_index;
COMMIT;

View file

@ -17,5 +17,12 @@ type UpdatePageJobData = {
}
export const queueUpdatePageJob = async (data: UpdatePageJobData) => {
return queue.add(JOB_NAME, data)
return queue.add(JOB_NAME, data, {
priority: 5,
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
})
}

View file

@ -5,7 +5,7 @@ import { isOldItem, RssFeedItem } from '../src'
describe('isOldItem', () => {
it('returns true if item is older than 1 day', () => {
const item = {
pubDate: '2020-01-01',
isoDate: '2020-01-01',
} as RssFeedItem
const mostRecentItemTimestamp = Date.now()
@ -15,7 +15,7 @@ describe('isOldItem', () => {
it('returns true if item was published at the last fetched time', () => {
const mostRecentItemTimestamp = Date.now()
const item = {
pubDate: new Date(mostRecentItemTimestamp).toISOString(),
isoDate: new Date(mostRecentItemTimestamp).toISOString(),
} as RssFeedItem
expect(isOldItem(item, mostRecentItemTimestamp)).to.be.true

View file

@ -0,0 +1,67 @@
import { ReactNode, useEffect, useMemo, useRef } from 'react'
import { styled } from '../tokens/stitches.config'
import { Box, HStack, VStack } from './LayoutPrimitives'
import { Button } from './Button'
import { DropdownMenu } from '@radix-ui/react-dropdown-menu'
import { ArrowDown } from 'phosphor-react'
import { Dropdown, DropdownOption } from './DropdownElements'
import { CaretDownIcon } from './icons/CaretDownIcon'
type SplitButtonProps = {
title: string
}
const CaretButton = (): JSX.Element => {
return (
<VStack
css={{
width: '20px',
height: '100%',
alignItems: 'center',
bg: '#6A6968',
border: '0px solid transparent',
borderTopRightRadius: '5px',
borderBottomRightRadius: '5px',
borderTopLeftRadius: '0px',
borderBottomLeftRadius: '0px',
}}
>
<CaretDownIcon size={8} color="#EDEDED" />
</VStack>
)
}
export const SplitButton = (props: SplitButtonProps): JSX.Element => {
return (
<HStack css={{ height: '27px', gap: '1px' }}>
<Button
css={{
display: 'flex',
minWidth: '70px',
bg: '#6A6968',
fontSize: '12px',
fontFamily: '$inter',
border: '0px solid transparent',
borderTopLeftRadius: '5px',
borderBottomLeftRadius: '5px',
borderTopRightRadius: '0px',
borderBottomRightRadius: '0px',
'&:hover': {
opacity: 0.7,
border: '0px solid transparent',
},
'&:focus': {
outline: 'none',
border: '0px solid transparent',
},
}}
>
{props.title}
</Button>
{/* <Divider></Divider> */}
<Dropdown triggerElement={<CaretButton />}>
<DropdownOption onSelect={() => console.log()} title="Archive (e)" />
</Dropdown>
</HStack>
)
}

View file

@ -0,0 +1,44 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class FollowingIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
width={size}
height={size}
viewBox="0 0 25 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M12.5 4.5L4.5 8.5L12.5 12.5L20.5 8.5L12.5 4.5Z"
fill={color}
stroke={color}
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4.5 12.5L12.5 16.5L20.5 12.5"
stroke={color}
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4.5 16.5L12.5 20.5L20.5 16.5"
stroke={color}
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -0,0 +1,40 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class HeaderCheckboxIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.5"
y="0.5"
width="39"
height="39"
rx="19.5"
stroke="#3D3D3D"
/>
<g>
<path
d="M12.5 14.1667C12.5 13.7246 12.6756 13.3007 12.9882 12.9882C13.3007 12.6756 13.7246 12.5 14.1667 12.5H25.8333C26.2754 12.5 26.6993 12.6756 27.0118 12.9882C27.3244 13.3007 27.5 13.7246 27.5 14.1667V25.8333C27.5 26.2754 27.3244 26.6993 27.0118 27.0118C26.6993 27.3244 26.2754 27.5 25.8333 27.5H14.1667C13.7246 27.5 13.3007 27.3244 12.9882 27.0118C12.6756 26.6993 12.5 26.2754 12.5 25.8333V14.1667Z"
stroke="#D9D9D9"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -0,0 +1,47 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class HeaderSearchIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.5"
y="0.5"
width="39"
height="39"
rx="19.5"
stroke="#3D3D3D"
/>
<g>
<path
d="M12.5 18.3333C12.5 19.0994 12.6509 19.8579 12.944 20.5657C13.2372 21.2734 13.6669 21.9164 14.2085 22.4581C14.7502 22.9998 15.3933 23.4295 16.101 23.7226C16.8087 24.0158 17.5673 24.1667 18.3333 24.1667C19.0994 24.1667 19.8579 24.0158 20.5657 23.7226C21.2734 23.4295 21.9164 22.9998 22.4581 22.4581C22.9998 21.9164 23.4295 21.2734 23.7226 20.5657C24.0158 19.8579 24.1667 19.0994 24.1667 18.3333C24.1667 17.5673 24.0158 16.8087 23.7226 16.101C23.4295 15.3933 22.9998 14.7502 22.4581 14.2085C21.9164 13.6669 21.2734 13.2372 20.5657 12.944C19.8579 12.6509 19.0994 12.5 18.3333 12.5C17.5673 12.5 16.8087 12.6509 16.101 12.944C15.3933 13.2372 14.7502 13.6669 14.2085 14.2085C13.6669 14.7502 13.2372 15.3933 12.944 16.101C12.6509 16.8087 12.5 17.5673 12.5 18.3333Z"
stroke="#D9D9D9"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M27.5 27.5L22.5 22.5"
stroke="#D9D9D9"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -0,0 +1,61 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class HeaderToggleGridIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.5"
y="0.5"
width="39"
height="39"
rx="19.5"
stroke="#3D3D3D"
/>
<g>
<path
d="M13.3333 14.1654C13.3333 13.9444 13.4211 13.7324 13.5774 13.5761C13.7337 13.4198 13.9457 13.332 14.1667 13.332H17.5C17.721 13.332 17.933 13.4198 18.0893 13.5761C18.2455 13.7324 18.3333 13.9444 18.3333 14.1654V17.4987C18.3333 17.7197 18.2455 17.9317 18.0893 18.088C17.933 18.2442 17.721 18.332 17.5 18.332H14.1667C13.9457 18.332 13.7337 18.2442 13.5774 18.088C13.4211 17.9317 13.3333 17.7197 13.3333 17.4987V14.1654Z"
stroke="#D9D9D9"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21.6667 14.1654C21.6667 13.9444 21.7545 13.7324 21.9107 13.5761C22.067 13.4198 22.279 13.332 22.5 13.332H25.8333C26.0543 13.332 26.2663 13.4198 26.4226 13.5761C26.5789 13.7324 26.6667 13.9444 26.6667 14.1654V17.4987C26.6667 17.7197 26.5789 17.9317 26.4226 18.088C26.2663 18.2442 26.0543 18.332 25.8333 18.332H22.5C22.279 18.332 22.067 18.2442 21.9107 18.088C21.7545 17.9317 21.6667 17.7197 21.6667 17.4987V14.1654Z"
stroke="#D9D9D9"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13.3333 22.5013C13.3333 22.2803 13.4211 22.0683 13.5774 21.912C13.7337 21.7558 13.9457 21.668 14.1667 21.668H17.5C17.721 21.668 17.933 21.7558 18.0893 21.912C18.2455 22.0683 18.3333 22.2803 18.3333 22.5013V25.8346C18.3333 26.0556 18.2455 26.2676 18.0893 26.4239C17.933 26.5802 17.721 26.668 17.5 26.668H14.1667C13.9457 26.668 13.7337 26.5802 13.5774 26.4239C13.4211 26.2676 13.3333 26.0556 13.3333 25.8346V22.5013Z"
stroke="#D9D9D9"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21.6667 22.5013C21.6667 22.2803 21.7545 22.0683 21.9107 21.912C22.067 21.7558 22.279 21.668 22.5 21.668H25.8333C26.0543 21.668 26.2663 21.7558 26.4226 21.912C26.5789 22.0683 26.6667 22.2803 26.6667 22.5013V25.8346C26.6667 26.0556 26.5789 26.2676 26.4226 26.4239C26.2663 26.5802 26.0543 26.668 25.8333 26.668H22.5C22.279 26.668 22.067 26.5802 21.9107 26.4239C21.7545 26.2676 21.6667 26.0556 21.6667 25.8346V22.5013Z"
stroke="#D9D9D9"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -0,0 +1,47 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class HeaderToggleListIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.5"
y="0.5"
width="39"
height="39"
rx="19.5"
stroke="#3D3D3D"
/>
<g>
<path
d="M13.3334 14.9987C13.3334 14.5567 13.509 14.1327 13.8215 13.8202C14.1341 13.5076 14.558 13.332 15 13.332H25C25.4421 13.332 25.866 13.5076 26.1786 13.8202C26.4911 14.1327 26.6667 14.5567 26.6667 14.9987V16.6654C26.6667 17.1074 26.4911 17.5313 26.1786 17.8439C25.866 18.1564 25.4421 18.332 25 18.332H15C14.558 18.332 14.1341 18.1564 13.8215 17.8439C13.509 17.5313 13.3334 17.1074 13.3334 16.6654V14.9987Z"
stroke="#D9D9D9"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13.3334 23.3346C13.3334 22.8926 13.509 22.4687 13.8215 22.1561C14.1341 21.8436 14.558 21.668 15 21.668H25C25.4421 21.668 25.866 21.8436 26.1786 22.1561C26.4911 22.4687 26.6667 22.8926 26.6667 23.3346V25.0013C26.6667 25.4433 26.4911 25.8673 26.1786 26.1798C25.866 26.4924 25.4421 26.668 25 26.668H15C14.558 26.668 14.1341 26.4924 13.8215 26.1798C13.509 25.8673 13.3334 25.4433 13.3334 25.0013V23.3346Z"
stroke="#D9D9D9"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -0,0 +1,44 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class FollowingIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
width={size}
height={size}
viewBox="0 0 25 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M12.5 4.5L4.5 8.5L12.5 12.5L20.5 8.5L12.5 4.5Z"
fill={color}
stroke={color}
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4.5 12.5L12.5 16.5L20.5 12.5"
stroke={color}
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4.5 16.5L12.5 20.5L20.5 16.5"
stroke={color}
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -0,0 +1,33 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class LibraryIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
width={size}
height={size}
viewBox="0 0 25 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M13.5 6.5H7.5C6.70435 6.5 5.94129 6.81607 5.37868 7.37868C4.81607 7.94129 4.5 8.70435 4.5 9.5V21.5L4.506 21.614C4.52514 21.7802 4.58565 21.9389 4.68199 22.0756C4.77833 22.2123 4.90743 22.3227 5.05746 22.3966C5.20749 22.4705 5.37366 22.5056 5.54077 22.4987C5.70788 22.4918 5.87059 22.4431 6.014 22.357L10.5 19.666L14.986 22.357C15.1377 22.4479 15.3108 22.4969 15.4876 22.4991C15.6645 22.5013 15.8387 22.4566 15.9926 22.3695C16.1465 22.2823 16.2746 22.156 16.3637 22.0032C16.4528 21.8505 16.4999 21.6768 16.5 21.5V9.5C16.5 8.70435 16.1839 7.94129 15.6213 7.37868C15.0587 6.81607 14.2956 6.5 13.5 6.5Z"
fill={color}
/>
<path
d="M17.5 2.5C18.2652 2.49996 19.0015 2.79233 19.5582 3.31728C20.115 3.84224 20.4501 4.56011 20.495 5.324L20.5 5.5V17.5C20.5 17.6673 20.4581 17.8319 20.3781 17.9788C20.298 18.1256 20.1824 18.2501 20.0419 18.3407C19.9013 18.4314 19.7402 18.4853 19.5734 18.4976C19.4066 18.5098 19.2393 18.4801 19.087 18.411L18.986 18.357L17.986 17.757C17.8004 17.6456 17.656 17.4769 17.5744 17.2764C17.4929 17.0759 17.4786 16.8543 17.5337 16.645C17.5888 16.4357 17.7104 16.2499 17.8801 16.1156C18.0499 15.9812 18.2586 15.9056 18.475 15.9L18.5 15.901V5.5C18.5 5.25507 18.41 5.01866 18.2473 4.83563C18.0845 4.65259 17.8602 4.53566 17.617 4.507L17.5 4.5H11.499C11.3432 4.49965 11.1894 4.53572 11.05 4.60535C10.9106 4.67497 10.7893 4.77621 10.696 4.901L10.566 5.098C10.4124 5.29946 10.1881 5.43512 9.9383 5.47756C9.68854 5.52001 9.43199 5.46608 9.22046 5.32667C9.00893 5.18726 8.8582 4.97276 8.79871 4.7265C8.73923 4.48025 8.77542 4.2206 8.89999 4C9.14644 3.57292 9.49437 3.21316 9.91298 2.95258C10.3316 2.69199 10.808 2.53861 11.3 2.506L11.5 2.5H17.5Z"
fill={color}
/>
</g>
</svg>
)
}
}

View file

@ -22,8 +22,9 @@ export type Subscription = {
createdAt: string
updatedAt: string
lastFetchedAt?: string
autoAddToLibrary?: boolean
isPrivate?: boolean
mostRecentItemDate?: string
fetchContent?: boolean
}
type SubscriptionsQueryResponse = {
@ -63,8 +64,8 @@ export function useGetSubscriptionsQuery(
createdAt
updatedAt
lastFetchedAt
autoAddToLibrary
isPrivate
fetchContent
mostRecentItemDate
}
}
... on SubscriptionsError {

View file

@ -2,7 +2,11 @@ import { useRouter } from 'next/router'
import { FloppyDisk, Pencil, XCircle } from 'phosphor-react'
import { useMemo, useState } from 'react'
import { FormInput } from '../../../components/elements/FormElements'
import { HStack, SpanBox } from '../../../components/elements/LayoutPrimitives'
import {
HStack,
SpanBox,
VStack,
} from '../../../components/elements/LayoutPrimitives'
import { ConfirmationModal } from '../../../components/patterns/ConfirmationModal'
import {
EmptySettingsRow,
@ -200,19 +204,25 @@ export default function Rss(): JSX.Element {
}}
deleteTitle="Unsubscribe"
sublineElement={
<SpanBox
<VStack
css={{
my: '8px',
fontSize: '11px',
}}
>
{`URL: ${subscription.url}, `}
{`Last fetched: ${
<SpanBox>{`URL: ${subscription.url}`}</SpanBox>
<SpanBox>{`Last refreshed: ${
subscription.lastFetchedAt
? formattedDateTime(subscription.lastFetchedAt)
: 'Never'
}`}
</SpanBox>
}`}</SpanBox>
<SpanBox>
{subscription.mostRecentItemDate &&
`Most recent item: ${formattedDateTime(
subscription.mostRecentItemDate
)}`}
</SpanBox>
</VStack>
}
onClick={() => {
router.push(`/home?q=in:inbox rss:"${subscription.url}"`)