mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge remote-tracking branch 'origin/main' into feat/android-label-chips-m3-update
This commit is contained in:
commit
61ed6589bb
86 changed files with 28562 additions and 27633 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
apple/OmnivoreKit/Sources/App/Views/PDFContainerView.swift
Normal file
66
apple/OmnivoreKit/Sources/App/Views/PDFContainerView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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\"",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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
|
|
@ -9,7 +9,6 @@ import {
|
|||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { Highlight } from './highlight'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
7
packages/db/migrations/0161.do.add_subscriptions_name_index.sql
Executable file
7
packages/db/migrations/0161.do.add_subscriptions_name_index.sql
Executable 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);
|
||||
|
||||
9
packages/db/migrations/0161.undo.add_subscriptions_name_index.sql
Executable file
9
packages/db/migrations/0161.undo.add_subscriptions_name_index.sql
Executable 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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
67
packages/web/components/elements/SplitButton.tsx
Normal file
67
packages/web/components/elements/SplitButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
packages/web/components/elements/icons/FollowingIcon.tsx
Normal file
44
packages/web/components/elements/icons/FollowingIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
47
packages/web/components/elements/icons/HeaderSearchIcon.tsx
Normal file
47
packages/web/components/elements/icons/HeaderSearchIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
44
packages/web/components/elements/icons/HighlightsIcon.tsx
Normal file
44
packages/web/components/elements/icons/HighlightsIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
33
packages/web/components/elements/icons/LibraryIcon.tsx
Normal file
33
packages/web/components/elements/icons/LibraryIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}"`)
|
||||
|
|
|
|||
Loading…
Reference in a new issue