Merge commit 'd33d383891c7b68ae5bc9e0f8922004af7556f44' into OMN-382

This commit is contained in:
gitstart-omnivore 2022-04-14 23:29:30 +00:00
commit ecdbed4a5c
87 changed files with 3742 additions and 1076 deletions

View file

@ -1156,7 +1156,7 @@
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.3;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.UnitTests-MacApp";
@ -1182,7 +1182,7 @@
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.3;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.UnitTests-MacApp";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -1200,7 +1200,7 @@
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = QJF2XZ86HB;
INFOPLIST_FILE = InfoPlists/iOSUITests.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.5;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1225,7 +1225,7 @@
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = QJF2XZ86HB;
INFOPLIST_FILE = InfoPlists/iOSUITests.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.5;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1260,7 +1260,7 @@
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.4.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@ -1291,7 +1291,7 @@
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.4.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac";
@ -1316,7 +1316,7 @@
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.3;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.UITests-Mac";
@ -1341,7 +1341,7 @@
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.3;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.UITests-Mac";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -1373,7 +1373,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.4.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@ -1407,7 +1407,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.4.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
@ -1458,7 +1458,7 @@
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = InfoPlists/Omnivore.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1572,7 +1572,7 @@
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.4.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@ -1610,7 +1610,7 @@
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.4.0;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@ -1674,8 +1674,8 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
MACOSX_DEPLOYMENT_TARGET = 10.15;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
PRODUCT_NAME = "$(TARGET_NAME)";
VALIDATE_PRODUCT = YES;
@ -1690,7 +1690,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QJF2XZ86HB;
INFOPLIST_FILE = InfoPlists/ShareExtension.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1745,7 +1745,7 @@
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = InfoPlists/Omnivore.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1772,7 +1772,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QJF2XZ86HB;
INFOPLIST_FILE = InfoPlists/ShareExtension.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1843,8 +1843,8 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
MACOSX_DEPLOYMENT_TARGET = 10.15;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -6,8 +6,8 @@ let package = Package(
name: "OmnivoreKit",
defaultLocalization: "en",
platforms: [
.iOS(.v14),
.macOS(.v11)
.iOS(.v15),
.macOS(.v12)
],
products: [
.library(name: "App", targets: ["App"]),
@ -17,7 +17,7 @@ let package = Package(
.library(name: "Utils", targets: ["Utils"])
],
dependencies: [
.package(name: "AppAuth", url: "https://github.com/openid/AppAuth-iOS.git", .upToNextMajor(from: "1.4.0")),
.package(url: "https://github.com/openid/AppAuth-iOS.git", .upToNextMajor(from: "1.4.0")),
.package(url: "https://github.com/Square/Valet", from: "4.1.2"),
.package(url: "https://github.com/maticzav/swift-graphql", from: "2.3.1"),
.package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.4")
@ -37,7 +37,7 @@ let package = Package(
.target(
name: "Services",
dependencies: [
"AppAuth",
.product(name: "AppAuth", package: "AppAuth-iOS"),
"Valet",
.product(name: "SwiftGraphQL", package: "swift-graphql"),
"Models",

View file

@ -14,63 +14,43 @@ import Views
var body: some View {
Group {
if #available(iOS 15.0, *) {
HomeFeedView(
prefersListLayout: $prefersListLayout,
viewModel: viewModel
)
.refreshable {
viewModel.loadItems(dataService: dataService, isRefresh: true)
HomeFeedView(
prefersListLayout: $prefersListLayout,
viewModel: viewModel
)
.refreshable {
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.searchable(
text: $viewModel.searchTerm,
placement: .navigationBarDrawer
) {
if viewModel.searchTerm.isEmpty {
Text("Inbox").searchCompletion("in:inbox ")
Text("All").searchCompletion("in:all ")
Text("Archived").searchCompletion("in:archive ")
Text("Files").searchCompletion("type:file ")
}
.searchable(
text: $viewModel.searchQuery,
placement: .sidebar
) {
if viewModel.searchQuery.isEmpty {
Text("Inbox").searchCompletion("in:inbox ")
Text("All").searchCompletion("in:all ")
Text("Archived").searchCompletion("in:archive ")
Text("Files").searchCompletion("type:file ")
}
}
.onChange(of: viewModel.searchQuery) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.onSubmit(of: .search) {
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(item: item) { labels in
viewModel.updateLabels(itemID: item.id, labels: labels)
}
}
} else {
HomeFeedView(
prefersListLayout: $prefersListLayout,
viewModel: viewModel
)
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(item: item) { labels in
viewModel.updateLabels(itemID: item.id, labels: labels)
}
}
.toolbar {
ToolbarItem {
if viewModel.isLoading {
Button(action: {}, label: { ProgressView() })
} else {
Button(
action: { viewModel.loadItems(dataService: dataService, isRefresh: true) },
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
)
}
}
}
.onChange(of: viewModel.searchTerm) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.onChange(of: viewModel.selectedLabels) { _ in
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.onSubmit(of: .search) {
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(mode: .item(item)) { labels in
viewModel.updateLabels(itemID: item.id, labels: labels)
}
}
}
.navigationTitle("Home")
.navigationBarTitleDisplayMode(.inline)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
// Don't refresh the list if the user is currently reading an article
if viewModel.selectedLinkItem == nil {
@ -99,26 +79,44 @@ import Views
}
}
.onChange(of: viewModel.selectedLinkItem) { _ in
viewModel.commitProgressUpdates()
viewModel.commitItemUpdates()
}
}
}
struct HomeFeedView: View {
@EnvironmentObject var dataService: DataService
@Binding var prefersListLayout: Bool
@State private var showLabelsSheet = false
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
if prefersListLayout {
HomeFeedListView(prefersListLayout: $prefersListLayout, viewModel: viewModel)
} else {
HomeFeedGridView(viewModel: viewModel)
.toolbar {
ToolbarItem {
if #available(iOS 15.0, *) {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
TextChipButton.makeAddLabelButton {
showLabelsSheet = true
}
ForEach(viewModel.selectedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label) {
viewModel.selectedLabels.removeAll { $0.id == label.id }
}
}
Spacer()
}
.padding(.horizontal)
.sheet(isPresented: $showLabelsSheet) {
ApplyLabelsView(mode: .list(viewModel.selectedLabels)) { labels in
viewModel.selectedLabels = labels
}
}
}
if prefersListLayout {
HomeFeedListView(prefersListLayout: $prefersListLayout, viewModel: viewModel)
} else {
HomeFeedGridView(viewModel: viewModel)
.toolbar {
ToolbarItem {
Button("", action: {})
.disabled(true)
.overlay {
@ -126,28 +124,19 @@ import Views
ProgressView()
}
}
} else {
if viewModel.isLoading {
Button(action: {}, label: { ProgressView() })
} else {
}
ToolbarItem {
if UIDevice.isIPad {
Button(
action: { viewModel.loadItems(dataService: dataService, isRefresh: true) },
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
action: { prefersListLayout.toggle() },
label: {
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
}
)
}
}
}
ToolbarItem {
if UIDevice.isIPad {
Button(
action: { prefersListLayout.toggle() },
label: {
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
}
)
}
}
}
}
}
}
}
@ -165,11 +154,15 @@ import Views
List {
Section {
ForEach(viewModel.items) { item in
let link = FeedCardNavigationLink(
FeedCardNavigationLink(
item: item,
viewModel: viewModel
)
.contextMenu {
Button(
action: { viewModel.itemUnderLabelEdit = item },
label: { Label("Edit Labels", systemImage: "tag") }
)
Button(action: {
withAnimation(.linear(duration: 0.4)) {
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived)
@ -196,61 +189,56 @@ import Views
}
}
}
if #available(iOS 15.0, *) {
link
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !item.isArchived {
Button {
withAnimation(.linear(duration: 0.4)) {
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true)
}
} label: {
Label("Archive", systemImage: "archivebox")
}.tint(.green)
} else {
Button {
withAnimation(.linear(duration: 0.4)) {
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false)
}
} label: {
Label("Unarchive", systemImage: "tray.and.arrow.down.fill")
}.tint(.indigo)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !item.isArchived {
Button {
withAnimation(.linear(duration: 0.4)) {
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true)
}
} label: {
Label("Archive", systemImage: "archivebox")
}.tint(.green)
} else {
Button {
withAnimation(.linear(duration: 0.4)) {
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false)
}
} label: {
Label("Unarchive", systemImage: "tray.and.arrow.down.fill")
}.tint(.indigo)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(
role: .destructive,
action: {
itemToRemove = item
confirmationShown = true
},
label: {
Image(systemName: "trash")
}
)
}.alert("Are you sure?", isPresented: $confirmationShown) {
Button("Remove Link", role: .destructive) {
if let itemToRemove = itemToRemove {
withAnimation {
viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(
role: .destructive,
action: {
itemToRemove = item
confirmationShown = true
},
label: {
Image(systemName: "trash")
}
)
}.alert("Are you sure?", isPresented: $confirmationShown) {
Button("Remove Link", role: .destructive) {
if let itemToRemove = itemToRemove {
withAnimation {
viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id)
}
}
self.itemToRemove = nil
}
Button("Cancel", role: .cancel) { self.itemToRemove = nil }
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if FeatureFlag.enableSnooze {
Button {
viewModel.itemToSnooze = item
viewModel.snoozePresented = true
} label: {
Label { Text("Snooze") } icon: { Image.moon }
}.tint(.appYellow48)
}
}
} else {
link
self.itemToRemove = nil
}
Button("Cancel", role: .cancel) { self.itemToRemove = nil }
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if FeatureFlag.enableSnooze {
Button {
viewModel.itemToSnooze = item
viewModel.snoozePresented = true
} label: {
Label { Text("Snooze") } icon: { Image.moon }
}.tint(.appYellow48)
}
}
}
}
@ -303,27 +291,22 @@ import Views
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325), spacing: 24)], spacing: 24) {
ForEach(viewModel.items) { item in
let link = GridCardNavigationLink(
GridCardNavigationLink(
item: item,
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
isContextMenuOpen: $isContextMenuOpen,
viewModel: viewModel
)
if #available(iOS 15.0, *) {
link
.alert("Are you sure?", isPresented: $confirmationShown) {
Button("Remove Link", role: .destructive) {
if let itemToRemove = itemToRemove {
withAnimation {
viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id)
}
}
self.itemToRemove = nil
.alert("Are you sure?", isPresented: $confirmationShown) {
Button("Remove Link", role: .destructive) {
if let itemToRemove = itemToRemove {
withAnimation {
viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id)
}
Button("Cancel", role: .cancel) { self.itemToRemove = nil }
}
} else {
link
self.itemToRemove = nil
}
Button("Cancel", role: .cancel) { self.itemToRemove = nil }
}
}
}

View file

@ -15,15 +15,6 @@ import Views
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
if #available(macOS 12.0, *) {
innerBody
} else {
innerBodyMac11
}
}
@available(macOS 12.0, *)
var innerBody: some View {
List {
Section {
ForEach(viewModel.items) { item in
@ -77,17 +68,17 @@ import Views
.listStyle(PlainListStyle())
.navigationTitle("Home")
.searchable(
text: $viewModel.searchQuery,
text: $viewModel.searchTerm,
placement: .toolbar
) {
if viewModel.searchQuery.isEmpty {
if viewModel.searchTerm.isEmpty {
Text("Inbox").searchCompletion("in:inbox ")
Text("All").searchCompletion("in:all ")
Text("Archived").searchCompletion("in:archive ")
Text("Files").searchCompletion("type:file ")
}
}
.onChange(of: viewModel.searchQuery) { _ in
.onChange(of: viewModel.searchTerm) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
viewModel.loadItems(dataService: dataService, isRefresh: true)
@ -118,40 +109,6 @@ import Views
}
}
}
var innerBodyMac11: some View {
List {
Section {
ForEach(viewModel.items) { item in
FeedCardNavigationLink(
item: item,
viewModel: viewModel
)
}
}
if viewModel.isLoading {
LoadingSection()
}
}
.listStyle(PlainListStyle())
.navigationTitle("Home")
.toolbar {
ToolbarItem {
Button(
action: {
viewModel.loadItems(dataService: dataService, isRefresh: true)
},
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
)
}
}
.onAppear {
if viewModel.items.isEmpty {
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
}
}
}
#endif

View file

@ -11,11 +11,15 @@ final class HomeFeedViewModel: ObservableObject {
/// Track progress updates to be committed when user navigates back to grid view
var uncommittedReadingProgressUpdates = [String: Double]()
/// Track label updates to be committed when user navigates back to grid view
var uncommittedLabelUpdates = [String: [FeedItemLabel]]()
@Published var items = [FeedItem]()
@Published var isLoading = false
@Published var showPushNotificationPrimer = false
@Published var itemUnderLabelEdit: FeedItem?
@Published var searchQuery = ""
@Published var searchTerm = ""
@Published var selectedLabels = [FeedItemLabel]()
@Published var snoozePresented = false
@Published var itemToSnooze: FeedItem?
@Published var selectedLinkItem: FeedItem?
@ -68,7 +72,7 @@ final class HomeFeedViewModel: ObservableObject {
dataService.libraryItemsPublisher(
limit: 10,
sortDescending: true,
searchQuery: searchQuery.isEmpty ? nil : searchQuery,
searchQuery: searchQuery,
cursor: isRefresh ? nil : cursor
)
.sink(
@ -172,14 +176,18 @@ final class HomeFeedViewModel: ObservableObject {
.store(in: &subscriptions)
}
/// Update `FeedItem`s with the cached reading progress values so it can animate when the
/// Update `FeedItem`s with the cached reading progress and label values so it can animate when the
/// user navigates back to the grid view (and also avoid mutations of the grid items
/// that can cause the `NavigationView` to pop.
func commitProgressUpdates() {
func commitItemUpdates() {
for (key, value) in uncommittedReadingProgressUpdates {
updateProgress(itemID: key, progress: value)
}
for (key, value) in uncommittedLabelUpdates {
updateLabels(itemID: key, labels: value)
}
uncommittedReadingProgressUpdates = [:]
uncommittedLabelUpdates = [:]
}
private func updateProgress(itemID: String, progress: Double) {
@ -190,9 +198,31 @@ final class HomeFeedViewModel: ObservableObject {
}
func updateLabels(itemID: String, labels: [FeedItemLabel]) {
// If item is being being displayed then delay the state update of labels until
// user is no longer reading the item.
if selectedLinkItem != nil {
uncommittedLabelUpdates[itemID] = labels
return
}
guard let item = items.first(where: { $0.id == itemID }) else { return }
if let index = items.firstIndex(of: item) {
items[index].labels = labels
}
}
private var searchQuery: String? {
if searchTerm.isEmpty, selectedLabels.isEmpty {
return nil
}
var query = searchTerm
if !selectedLabels.isEmpty {
query.append(" label:")
query.append(selectedLabels.map(\.name).joined(separator: ","))
}
return query
}
}

View file

@ -4,7 +4,30 @@ import SwiftUI
import Views
struct ApplyLabelsView: View {
let item: FeedItem
enum Mode {
case item(FeedItem)
case list([FeedItemLabel])
var navTitle: String {
switch self {
case .item:
return "Assign Labels"
case .list:
return "Apply Label Filters"
}
}
var confirmButtonText: String {
switch self {
case .item:
return "Save"
case .list:
return "Apply"
}
}
}
let mode: Mode
let commitLabelChanges: ([FeedItemLabel]) -> Void
@EnvironmentObject var dataService: DataService
@ -15,10 +38,10 @@ struct ApplyLabelsView: View {
var innerBody: some View {
List {
Section(header: Text("Assigned Labels")) {
if viewModel.selectedLabelsForItemInContext.isEmpty {
if viewModel.selectedLabels.isEmpty {
Text("No labels are currently assigned.")
}
ForEach(viewModel.selectedLabelsForItemInContext.applySearchFilter(labelSearchFilter), id: \.self) { label in
ForEach(viewModel.selectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in
HStack {
TextChip(feedItemLabel: label)
Spacer()
@ -28,13 +51,13 @@ struct ApplyLabelsView: View {
viewModel.removeLabelFromItem(label)
}
},
label: { Image(systemName: "trash").foregroundColor(.appGrayTextContrast) }
label: { Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast) }
)
}
}
}
Section(header: Text("Available Labels")) {
ForEach(viewModel.unselectedLabelsForItemInContext.applySearchFilter(labelSearchFilter), id: \.self) { label in
ForEach(viewModel.unselectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in
HStack {
TextChip(feedItemLabel: label)
Spacer()
@ -63,7 +86,7 @@ struct ApplyLabelsView: View {
.disabled(viewModel.isLoading)
}
}
.navigationTitle("Assign Labels")
.navigationTitle(mode.navTitle)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@ -76,12 +99,18 @@ struct ApplyLabelsView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button(
action: {
viewModel.saveItemLabelChanges(itemID: item.id, dataService: dataService) { labels in
commitLabelChanges(labels)
switch mode {
case let .item(feedItem):
viewModel.saveItemLabelChanges(itemID: feedItem.id, dataService: dataService) { labels in
commitLabelChanges(labels)
presentationMode.wrappedValue.dismiss()
}
case .list:
commitLabelChanges(viewModel.selectedLabels)
presentationMode.wrappedValue.dismiss()
}
},
label: { Text("Save").foregroundColor(.appGrayTextContrast) }
label: { Text(mode.confirmButtonText).foregroundColor(.appGrayTextContrast) }
)
}
}
@ -97,22 +126,23 @@ struct ApplyLabelsView: View {
EmptyView()
} else {
#if os(iOS)
if #available(iOS 15.0, *) {
innerBody
.searchable(
text: $labelSearchFilter,
placement: .navigationBarDrawer(displayMode: .always)
)
} else {
innerBody
}
innerBody
.searchable(
text: $labelSearchFilter,
placement: .navigationBarDrawer(displayMode: .always)
)
#else
innerBody
#endif
}
}
.onAppear {
viewModel.loadLabels(dataService: dataService, item: item)
switch mode {
case let .item(feedItem):
viewModel.loadLabels(dataService: dataService, item: feedItem)
case let .list(labels):
viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels)
}
}
}
}

View file

@ -2,6 +2,7 @@ import Combine
import Models
import Services
import SwiftUI
import Utils
import Views
struct LabelsView: View {
@ -15,25 +16,20 @@ struct LabelsView: View {
var body: some View {
Group {
#if os(iOS)
if #available(iOS 15.0, *) {
Form {
innerBody
.alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) {
Button("Remove Link", role: .destructive) {
if let labelID = labelToRemoveID {
withAnimation {
viewModel.deleteLabel(dataService: dataService, labelID: labelID)
}
Form {
innerBody
.alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) {
Button("Delete Label", role: .destructive) {
if let labelID = labelToRemoveID {
withAnimation {
viewModel.deleteLabel(dataService: dataService, labelID: labelID)
}
self.labelToRemoveID = nil
}
Button("Cancel", role: .cancel) { self.labelToRemoveID = nil }
self.labelToRemoveID = nil
}
}
} else {
Form { innerBody }
Button("Cancel", role: .cancel) { self.labelToRemoveID = nil }
}
}
#elseif os(macOS)
List {
innerBody
@ -92,49 +88,141 @@ struct CreateLabelView: View {
@State private var newLabelName = ""
@State private var newLabelColor = Color.clear
var shouldDisableCreateButton: Bool {
viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear
}
let rows = [
GridItem(.fixed(60)),
GridItem(.fixed(60)),
GridItem(.fixed(70))
]
let swatches: [Color] = {
let webSwatches = webSwatchHexes.map { Color(hex: $0) ?? .clear }
var additionalSwatches = swatchHexes.map { Color(hex: $0) ?? .clear }.shuffled()
let firstSwatch = additionalSwatches.remove(at: 0)
return [firstSwatch] + webSwatches + additionalSwatches
}()
var body: some View {
NavigationView {
VStack(spacing: 16) {
VStack {
HStack {
if !newLabelName.isEmpty, newLabelColor != .clear {
TextChip(text: newLabelName, color: newLabelColor)
} else {
Text("Assign a name and color.").font(.appBody)
}
Spacer()
}
.padding(.bottom, 8)
TextField("Label Name", text: $newLabelName)
#if os(iOS)
.keyboardType(.alphabet)
#endif
.textFieldStyle(StandardTextFieldStyle())
ColorPicker(
newLabelColor == .clear ? "Select Color" : newLabelColor.description,
selection: $newLabelColor
)
Button(
action: {
viewModel.createLabel(
dataService: dataService,
name: newLabelName,
color: newLabelColor,
description: nil
)
},
label: { Text("Create") }
)
.buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300))
.disabled(viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear)
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: rows, alignment: .top, spacing: 20) {
ForEach(swatches, id: \.self) { swatch in
ZStack {
Circle()
.fill(swatch)
.frame(width: 50, height: 50)
.onTapGesture {
newLabelColor = swatch
}
.padding(10)
if newLabelColor == swatch {
Circle()
.stroke(swatch, lineWidth: 5)
.frame(width: 60, height: 60)
}
}
}
}
}
Spacer()
}
.padding()
.toolbar {
ToolbarItem(placement: .automatic) {
ToolbarItem(placement: .barLeading) {
Button(
action: { viewModel.showCreateEmailModal = false },
label: {
Image(systemName: "xmark")
.foregroundColor(.appGrayTextContrast)
}
label: { Text("Cancel").foregroundColor(.appGrayTextContrast) }
)
}
ToolbarItem(placement: .barTrailing) {
Button(
action: {
viewModel.createLabel(
dataService: dataService,
name: newLabelName,
color: newLabelColor,
description: nil
)
},
label: { Text("Create").foregroundColor(.appGrayTextContrast) }
)
.opacity(shouldDisableCreateButton ? 0.2 : 1)
.disabled(shouldDisableCreateButton)
}
}
.navigationTitle("Create New Label")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
newLabelColor = swatches.first ?? .clear
}
}
}
}
private let webSwatchHexes = [
"#FF5D99",
"#7CFF7B",
"#FFD234",
"#7BE4FF",
"#CE88EF",
"#EF8C43"
]
private let swatchHexes = [
"#fff034",
"#efff34",
"#d1ff34",
"#b2ff34",
"#94ff34",
"#75ff34",
"#57ff34",
"#38ff34",
"#34ff4e",
"#34ff6d",
"#34ff8b",
"#34ffa9",
"#34ffc8",
"#34ffe6",
"#34f9ff",
"#34dbff",
"#34bcff",
"#349eff",
"#347fff",
"#3461ff",
"#3443ff",
"#4434ff",
"#6234ff",
"#8134ff",
"#9f34ff",
"#be34ff",
"#dc34ff",
"#fb34ff",
"#ff34e5",
"#ff34c7",
"#ff34a8",
"#ff348a",
"#ff346b"
]

View file

@ -7,14 +7,19 @@ import Views
final class LabelsViewModel: ObservableObject {
private var hasLoadedInitialLabels = false
@Published var isLoading = false
@Published var selectedLabelsForItemInContext = [FeedItemLabel]()
@Published var unselectedLabelsForItemInContext = [FeedItemLabel]()
@Published var selectedLabels = [FeedItemLabel]()
@Published var unselectedLabels = [FeedItemLabel]()
@Published var labels = [FeedItemLabel]()
@Published var showCreateEmailModal = false
var subscriptions = Set<AnyCancellable>()
func loadLabels(dataService: DataService, item: FeedItem?) {
/// Loads initial set of labels when a edit labels list is displayed
/// - Parameters:
/// - dataService: `DataService` reference
/// - item: Optional `FeedItem` for applying labels to a single item
/// - initiallySelectedLabels: Optional `[FeedItemLabel]` for filtering a list of items
func loadLabels(dataService: DataService, item: FeedItem? = nil, initiallySelectedLabels: [FeedItemLabel]? = nil) {
guard !hasLoadedInitialLabels else { return }
isLoading = true
@ -25,11 +30,17 @@ final class LabelsViewModel: ObservableObject {
self?.labels = allLabels
self?.hasLoadedInitialLabels = true
if let item = item {
self?.selectedLabelsForItemInContext = item.labels
self?.unselectedLabelsForItemInContext = allLabels.filter { label in
self?.selectedLabels = item.labels
self?.unselectedLabels = allLabels.filter { label in
!item.labels.contains(where: { $0.id == label.id })
}
}
if let initiallySelectedLabels = initiallySelectedLabels {
self?.selectedLabels = initiallySelectedLabels
self?.unselectedLabels = allLabels.filter { label in
!initiallySelectedLabels.contains(where: { $0.id == label.id })
}
}
}
)
.store(in: &subscriptions)
@ -49,7 +60,7 @@ final class LabelsViewModel: ObservableObject {
receiveValue: { [weak self] result in
self?.isLoading = false
self?.labels.insert(result, at: 0)
self?.unselectedLabelsForItemInContext.insert(result, at: 0)
self?.unselectedLabels.insert(result, at: 0)
self?.showCreateEmailModal = false
}
)
@ -73,7 +84,7 @@ final class LabelsViewModel: ObservableObject {
func saveItemLabelChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) {
isLoading = true
dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabelsForItemInContext.map(\.id)).sink(
dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.id)).sink(
receiveCompletion: { [weak self] _ in
self?.isLoading = false
},
@ -83,12 +94,12 @@ final class LabelsViewModel: ObservableObject {
}
func addLabelToItem(_ label: FeedItemLabel) {
selectedLabelsForItemInContext.insert(label, at: 0)
unselectedLabelsForItemInContext.removeAll { $0.id == label.id }
selectedLabels.insert(label, at: 0)
unselectedLabels.removeAll { $0.id == label.id }
}
func removeLabelFromItem(_ label: FeedItemLabel) {
unselectedLabelsForItemInContext.insert(label, at: 0)
selectedLabelsForItemInContext.removeAll { $0.id == label.id }
unselectedLabels.insert(label, at: 0)
selectedLabels.removeAll { $0.id == label.id }
}
}

View file

@ -157,39 +157,6 @@ struct LinkItemDetailView: View {
#endif
}
var navBariOS14: some View {
HStack(alignment: .center) {
Button(
action: { self.presentationMode.wrappedValue.dismiss() },
label: {
Image(systemName: "chevron.backward")
.font(.appTitleTwo)
.foregroundColor(.appGrayTextContrast)
.padding(.horizontal)
}
)
.scaleEffect(navBarVisibilityRatio)
Spacer()
Button(
action: { showFontSizePopover.toggle() },
label: {
Image(systemName: "textformat.size")
.font(.appTitleTwo)
}
)
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
}
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
.opacity(navBarVisibilityRatio)
.background(Color.systemBackground)
.onTapGesture {
showFontSizePopover = false
}
}
@available(macOS 12.0, *)
@available(iOS 15.0, *)
var navBar: some View {
HStack(alignment: .center) {
Button(
@ -286,47 +253,25 @@ struct LinkItemDetailView: View {
}
)
}
if #available(iOS 15.0, *) {
VStack(spacing: 0) {
navBar
Spacer()
}
.navigationBarHidden(true)
} else {
VStack(spacing: 0) {
navBariOS14
Spacer()
}
.navigationBarHidden(true)
}
}
} else {
if #available(iOS 15.0, *) {
VStack(spacing: 0) {
navBar
Spacer()
}
.onAppear {
viewModel.loadWebAppWrapper(
dataService: dataService,
rawAuthCookie: authenticator.omnivoreAuthCookieString
)
}
.navigationBarHidden(true)
} else {
VStack(spacing: 0) {
navBariOS14
Spacer()
}
.onAppear {
viewModel.loadWebAppWrapper(
dataService: dataService,
rawAuthCookie: authenticator.omnivoreAuthCookieString
)
}
.navigationBarHidden(true)
}
} else {
VStack(spacing: 0) {
navBar
Spacer()
}
.onAppear {
viewModel.loadWebAppWrapper(
dataService: dataService,
rawAuthCookie: authenticator.omnivoreAuthCookieString
)
}
.navigationBarHidden(true)
}
}
#endif

View file

@ -51,10 +51,8 @@ public struct PrimaryContentView: View {
.introspectSplitViewController {
$0.preferredSplitBehavior = .tile
$0.preferredPrimaryColumnWidth = 200
if #available(iOS 14.5, *) {
$0.presentsWithGesture = false
$0.displayModeButtonVisibility = .always
}
$0.presentsWithGesture = false
$0.displayModeButtonVisibility = .always
}
}
#endif

View file

@ -74,39 +74,6 @@ import WebKit
}
}
var navBariOS14: some View {
HStack(alignment: .center) {
Button(
action: { self.presentationMode.wrappedValue.dismiss() },
label: {
Image(systemName: "chevron.backward")
.font(.appTitleTwo)
.foregroundColor(.appGrayTextContrast)
.padding(.horizontal)
}
)
.scaleEffect(navBarVisibilityRatio)
Spacer()
Button(
action: { showFontSizePopover.toggle() },
label: {
Image(systemName: "textformat.size")
.font(.appTitleTwo)
}
)
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
}
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
.opacity(navBarVisibilityRatio)
.background(Color.systemBackground)
.onTapGesture {
showFontSizePopover = false
}
}
@available(macOS 12.0, *)
@available(iOS 15.0, *)
var navBar: some View {
HStack(alignment: .center) {
Button(
@ -132,6 +99,10 @@ import WebKit
Menu(
content: {
Group {
Button(
action: { homeFeedViewModel.itemUnderLabelEdit = item },
label: { Label("Edit Labels", systemImage: "tag") }
)
Button(
action: {
homeFeedViewModel.setLinkArchived(
@ -260,19 +231,11 @@ import WebKit
}
)
}
if #available(iOS 15.0, *) {
VStack(spacing: 0) {
navBar
Spacer()
}
.navigationBarHidden(true)
} else {
VStack(spacing: 0) {
navBariOS14
Spacer()
}
.navigationBarHidden(true)
VStack(spacing: 0) {
navBar
Spacer()
}
.navigationBarHidden(true)
}.onDisappear {
// Clear the shared webview content when exiting

View file

@ -45,7 +45,7 @@ public enum PageScraper {
}
var pageScrapePayload: PageScrapePayload?
let propertyListKey = String(kUTTypePropertyList)
let propertyListKey = UTType.propertyList.identifier
let group = DispatchGroup()
@ -76,7 +76,7 @@ public enum PageScraper {
extensionContext: NSExtensionContext?,
completion: @escaping (Result<PageScrapePayload, PageScrapeError>) -> Void
) {
let urlKey = String(kUTTypeURL)
let urlKey = UTType.url.identifier
// First look for a URL type
let urlFound = extensionContext?.inputItems.first { inputItem in
@ -108,7 +108,7 @@ public enum PageScraper {
return
}
let textKey = String(kUTTypeText)
let textKey = UTType.text.identifier
// We didn't find a URL type, so check for a string that contains a URL
let textUrlFound = extensionContext?.inputItems.first { inputItem in
let itemProvider = (inputItem as? NSExtensionItem)?.attachments?.first(where: { attachment in
@ -150,8 +150,8 @@ public enum PageScraper {
extensionContext: NSExtensionContext?,
completion: @escaping (Result<PageScrapePayload, PageScrapeError>) -> Void
) {
let urlKey = String(kUTTypeURL)
let textKey = String(kUTTypeUTF16PlainText)
let urlKey = UTType.url.identifier
let textKey = UTType.utf16PlainText.identifier
let inputItem = extensionContext?.inputItems.first as? NSExtensionItem
let itemProvider = inputItem?.attachments?.first(where: { attachment in
attachment.hasItemConformingToTypeIdentifier(urlKey)

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,7 @@ public extension DataService {
let mutation = Selection.Mutation {
try $0.setLabels(
input: InputObjects.SetLabelsInput(
linkId: itemID,
pageId: itemID,
labelIds: labelIDs
),
selection: selection

View file

@ -22,18 +22,14 @@ public extension Color {
static var systemBackground: Color { Color(.systemBackground) }
static var systemPlaceholder: Color { Color(.placeholderText) }
static var secondarySystemGroupedBackground: Color { Color(.secondarySystemGroupedBackground) }
static var systemLabel: Color {
if #available(iOS 15.0, *) {
return Color(uiColor: .label)
} else {
return Color.appGrayTextContrast
}
}
static var systemGray6: Color { Color(.systemGray6) }
static var systemLabel: Color { Color(uiColor: .label) }
#elseif os(macOS)
static var systemBackground: Color { Color(.windowBackgroundColor) }
static var systemPlaceholder: Color { Color(.placeholderTextColor) }
static var systemLabel: Color { Color(.labelColor) }
static var systemGray6: Color { Color(NSColor.systemGray) }
// Just for compilation. secondarySystemGroupedBackground shouldn't be used on macOS
static var secondarySystemGroupedBackground: Color { Color(.windowBackgroundColor) }

View file

@ -68,21 +68,9 @@ public struct GridCard: View {
VStack(alignment: .leading, spacing: 16) {
// Progress Bar
Group {
// Remove os check when dropping macOS 11
#if os(iOS)
if #available(iOS 15.0, *) {
ProgressView(value: min(abs(item.readingProgress) / 100, 1))
.tint(.appYellow48)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Color.clear
.contentShape(Rectangle())
.frame(maxWidth: .infinity, alignment: .leading)
}
#else
ProgressView(value: max(abs(item.readingProgress) / 100, 1))
.frame(maxWidth: .infinity, alignment: .leading)
#endif
ProgressView(value: min(abs(item.readingProgress) / 100, 1))
.tint(.appYellow48)
.frame(maxWidth: .infinity, alignment: .leading)
}
.onTapGesture { tapHandler() }

View file

@ -30,14 +30,13 @@ import SwiftUI
let controller = UIHostingController(rootView: content())
if controller.traitCollection.userInterfaceIdiom == .phone {
if #available(iOS 15, *) {
if let sheet = controller.sheetPresentationController {
sheet.preferredCornerRadius = 16
sheet.prefersGrabberVisible = false
sheet.detents = [.medium()]
sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
}
if let sheet = controller.sheetPresentationController {
sheet.preferredCornerRadius = 16
sheet.prefersGrabberVisible = false
sheet.detents = [.medium()]
sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
}
controller.modalPresentationStyle = .pageSheet
} else {
controller.view.sizeToFit()

View file

@ -14,7 +14,7 @@ import SwiftUI
self.denyAction = denyAction
}
var content: some View {
public var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Image
@ -45,14 +45,7 @@ import SwiftUI
.foregroundColor(Color.appTextDefault)
.cornerRadius(8)
.frame(maxWidth: min(UIScreen.main.bounds.width - 20, 320))
}
public var body: some View {
if #available(iOS 15.0, *) {
content.interactiveDismissDisabled()
} else {
content
}
.interactiveDismissDisabled()
}
}

View file

@ -30,3 +30,84 @@ public struct TextChip: View {
.cornerRadius(cornerRadius)
}
}
public struct TextChipButton: View {
public static func makeAddLabelButton(onTap: @escaping () -> Void) -> TextChipButton {
TextChipButton(title: "Labels", color: .systemGray6, actionType: .show, onTap: onTap)
}
public static func makeShowOptionsButton(title: String, onTap: @escaping () -> Void) -> TextChipButton {
TextChipButton(title: title, color: .appButtonBackground, actionType: .add, onTap: onTap)
}
public static func makeRemovableLabelButton(
feedItemLabel: FeedItemLabel,
onTap: @escaping () -> Void
) -> TextChipButton {
TextChipButton(
title: feedItemLabel.name,
color: Color(hex: feedItemLabel.color) ?? .appButtonBackground,
actionType: .remove,
onTap: onTap
)
}
public enum ActionType {
case remove
case add
case show
var systemIconName: String {
switch self {
case .remove:
return "xmark"
case .add:
return "plus"
case .show:
return "chevron.down"
}
}
}
init(title: String, color: Color, actionType: ActionType, onTap: @escaping () -> Void) {
self.text = title
self.color = color
self.onTap = onTap
self.actionType = actionType
self.foregroundColor = {
if actionType == .show {
return .appGrayText
}
return color.isDark ? .white : .black
}()
}
let text: String
let color: Color
let onTap: () -> Void
let actionType: ActionType
let cornerRadius = 20.0
let foregroundColor: Color
public var body: some View {
Button(action: onTap) {
VStack(spacing: 0) {
HStack {
Text(text)
.padding(.leading, 3)
Image(systemName: actionType.systemIconName)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.font(.appFootnote)
.foregroundColor(foregroundColor)
.lineLimit(1)
.background(color)
.cornerRadius(cornerRadius)
Color.clear.contentShape(Rectangle()).frame(height: 15)
}
.contentShape(Rectangle())
}
}
}

View file

@ -0,0 +1,19 @@
import SwiftUI
public extension ToolbarItemPlacement {
static var barLeading: ToolbarItemPlacement {
#if os(iOS)
.navigationBarLeading
#else
.automatic
#endif
}
static var barTrailing: ToolbarItemPlacement {
#if os(iOS)
.navigationBarTrailing
#else
.automatic
#endif
}
}

View file

@ -4,10 +4,12 @@ scalars:
SanitizedString_undefined_15: String
SanitizedString_undefined_40: String
SanitizedString_undefined_50: String
SanitizedString_undefined_64: String
SanitizedString_undefined_95: String
SanitizedString_undefined_100: String
SanitizedString_undefined_300: String
SanitizedString_undefined_400: String
SanitizedString_undefined_2000: String
SanitizedString_undefined_4000: String
SanitizedString_undefined_8000: String
SanitizedString_undefined_undefined: String
SanitizedString_undefined_undefined: String

View file

@ -115,6 +115,7 @@ export function makeApolloServer(): ApolloServer {
schema: schema,
context: contextFunc,
formatError: (err) => {
console.log('server error', err)
Sentry.captureException(err)
// hide error messages from frontend on prod
return new Error('Unexpected server error')

View file

@ -2,7 +2,6 @@ import { PubSub } from '@google-cloud/pubsub'
import { env } from '../env'
import { ReportType } from '../generated/graphql'
import express from 'express'
import { Page } from '../elastic/types'
export const createPubSubClient = (): PubsubClient => {
const client = new PubSub()
@ -37,17 +36,35 @@ export const createPubSubClient = (): PubsubClient => {
Buffer.from(JSON.stringify({ userId, email, name, username }))
)
},
pageUpdated: (page: Partial<Page>, userId: string): Promise<void> => {
entityCreated: <T>(
type: EntityType,
data: T,
userId: string
): Promise<void> => {
return publish(
'pageUpdated',
Buffer.from(JSON.stringify({ ...page, userId }))
'entityCreated',
Buffer.from(JSON.stringify({ type, userId, ...data }))
)
},
pageCreated: (page: Page): Promise<void> => {
return publish('pageCreated', Buffer.from(JSON.stringify(page)))
entityUpdated: <T>(
type: EntityType,
data: T,
userId: string
): Promise<void> => {
return publish(
'entityUpdated',
Buffer.from(JSON.stringify({ type, userId, ...data }))
)
},
pageDeleted: (id: string, userId: string): Promise<void> => {
return publish('pageDeleted', Buffer.from(JSON.stringify({ id, userId })))
entityDeleted: (
type: EntityType,
id: string,
userId: string
): Promise<void> => {
return publish(
'entityDeleted',
Buffer.from(JSON.stringify({ type, id, userId }))
)
},
reportSubmitted: (
submitterId: string,
@ -65,6 +82,12 @@ export const createPubSubClient = (): PubsubClient => {
}
}
export enum EntityType {
PAGE = 'page',
HIGHLIGHT = 'highlight',
LABEL = 'label',
}
export interface PubsubClient {
userCreated: (
userId: string,
@ -72,9 +95,9 @@ export interface PubsubClient {
name: string,
username: string
) => Promise<void>
pageCreated: (page: Page) => Promise<void>
pageUpdated: (page: Partial<Page>, userId: string) => Promise<void>
pageDeleted: (id: string, userId: string) => Promise<void>
entityCreated: <T>(type: EntityType, data: T, userId: string) => Promise<void>
entityUpdated: <T>(type: EntityType, data: T, userId: string) => Promise<void>
entityDeleted: (type: EntityType, id: string, userId: string) => Promise<void>
reportSubmitted(
submitterId: string | undefined,
itemUrl: string,

View file

@ -32,6 +32,7 @@ export interface ReminderData {
createdAt: Date
updatedAt?: Date
remindAt: Date
elasticPageId?: string
}
export const keys = [
@ -46,6 +47,7 @@ export const keys = [
'status',
'createdAt',
'updatedAt',
'elasticPageId',
] as const
export const defaultedKeys = ['id', 'updatedAt', 'status'] as const

View file

@ -1,32 +1,41 @@
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils'
import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'
import { GraphQLNonNull, GraphQLScalarType, GraphQLSchema } from 'graphql'
import { SanitizedString } from './scalars'
export const sanitizeDirectiveTransformer = (schema: GraphQLSchema) => {
return mapSchema(schema, {
[MapperKind.FIELD]: (fieldConfig) => {
const sanitizeDirective = getDirective(schema, fieldConfig, 'sanitize')
if (!sanitizeDirective || sanitizeDirective.length < 1) {
const sanitizeDirective = getDirective(
schema,
fieldConfig,
'sanitize'
)?.[0]
if (!sanitizeDirective) {
return fieldConfig
}
const maxLength = sanitizeDirective[0].maxLength as number | undefined
const allowedTags = sanitizeDirective[0].allowedTags as
| string[]
| undefined
const maxLength = sanitizeDirective.maxLength as number | undefined
const allowedTags = sanitizeDirective.allowedTags as string[] | undefined
const pattern = sanitizeDirective.pattern as string | undefined
if (
fieldConfig.type instanceof GraphQLNonNull &&
fieldConfig.type.ofType instanceof GraphQLScalarType
) {
fieldConfig.type = new GraphQLNonNull(
new SanitizedString(fieldConfig.type.ofType, allowedTags, maxLength)
new SanitizedString(
fieldConfig.type.ofType,
allowedTags,
maxLength,
pattern
)
)
} else if (fieldConfig.type instanceof GraphQLScalarType) {
fieldConfig.type = new SanitizedString(
fieldConfig.type,
allowedTags,
maxLength
maxLength,
pattern
)
} else {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions

View file

@ -8,6 +8,7 @@ import {
import { ResponseError } from '@elastic/elasticsearch/lib/errors'
import { client, INDEX_ALIAS } from './index'
import { SortBy, SortOrder, SortParams } from '../utils/search'
import { EntityType } from '../datalayer/pubsub'
export const addHighlightToPage = async (
id: string,
@ -35,7 +36,15 @@ export const addHighlightToPage = async (
retry_on_conflict: 3,
})
return body.result === 'updated'
if (body.result !== 'updated') return false
await ctx.pubsub.entityCreated<Highlight>(
EntityType.HIGHLIGHT,
highlight,
ctx.uid
)
return true
} catch (e) {
if (
e instanceof ResponseError &&
@ -125,7 +134,11 @@ export const deleteHighlight = async (
refresh: ctx.refresh,
})
return !!body.updated
if (body.updated === 0) return false
await ctx.pubsub.entityDeleted(EntityType.HIGHLIGHT, highlightId, ctx.uid)
return true
} catch (e) {
console.error('failed to delete a highlight in elastic', e)
@ -266,7 +279,15 @@ export const updateHighlight = async (
refresh: ctx.refresh,
})
return !!body.updated
if (body.updated === 0) return false
await ctx.pubsub.entityUpdated<Highlight>(
EntityType.HIGHLIGHT,
highlight,
ctx.uid
)
return true
} catch (e) {
if (
e instanceof ResponseError &&

View file

@ -1,15 +1,16 @@
import { Label, PageContext } from './types'
import { client, INDEX_ALIAS } from './index'
import { EntityType } from '../datalayer/pubsub'
export const addLabelInPage = async (
id: string,
pageId: string,
label: Label,
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.update({
index: INDEX_ALIAS,
id,
id: pageId,
body: {
script: {
source: `if (ctx._source.labels == null) {
@ -27,9 +28,53 @@ export const addLabelInPage = async (
retry_on_conflict: 3,
})
return body.result === 'updated'
if (body.result !== 'updated') return false
await ctx.pubsub.entityCreated<Label & { pageId: string }>(
EntityType.LABEL,
{ pageId, ...label },
ctx.uid
)
return true
} catch (e) {
console.error('failed to update a page in elastic', e)
console.error('failed to add a label in elastic', e)
return false
}
}
export const updateLabelsInPage = async (
pageId: string,
labels: Label[],
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.update({
index: INDEX_ALIAS,
id: pageId,
body: {
doc: {
labels: labels,
updatedAt: new Date(),
},
},
refresh: ctx.refresh,
retry_on_conflict: 3,
})
if (body.result !== 'updated') return false
for (const label of labels) {
await ctx.pubsub.entityCreated<Label & { pageId: string }>(
EntityType.LABEL,
{ pageId, ...label },
ctx.uid
)
}
return true
} catch (e) {
console.error('failed to update labels in elastic', e)
return false
}
}
@ -38,9 +83,9 @@ export const deleteLabelInPages = async (
userId: string,
label: string,
ctx: PageContext
): Promise<void> => {
): Promise<boolean> => {
try {
await client.updateByQuery({
const { body } = await client.updateByQuery({
index: INDEX_ALIAS,
body: {
script: {
@ -75,7 +120,14 @@ export const deleteLabelInPages = async (
},
refresh: ctx.refresh,
})
if (body.updated === 0) return false
await ctx.pubsub.entityDeleted(EntityType.LABEL, label, ctx.uid)
return true
} catch (e) {
console.error('failed to delete a page in elastic', e)
console.error('failed to delete a label in elastic', e)
return false
}
}

View file

@ -18,6 +18,7 @@ import {
SortParams,
} from '../utils/search'
import { client, INDEX_ALIAS } from './index'
import { EntityType } from '../datalayer/pubsub'
const appendQuery = (body: SearchBody, query: string): void => {
body.query.bool.should.push({
@ -189,7 +190,7 @@ export const createPage = async (
refresh: ctx.refresh,
})
await ctx.pubsub.pageCreated(page)
await ctx.pubsub.entityCreated<Page>(EntityType.PAGE, page, ctx.uid)
return body._id as string
} catch (e) {
@ -219,7 +220,11 @@ export const updatePage = async (
if (body.result !== 'updated') return false
await ctx.pubsub.pageUpdated({ ...page, id }, ctx.uid)
await ctx.pubsub.entityUpdated<Partial<Page>>(
EntityType.PAGE,
{ ...page, id },
ctx.uid
)
return true
} catch (e) {
@ -241,7 +246,7 @@ export const deletePage = async (
if (body.deleted === 0) return false
await ctx.pubsub.pageDeleted(id, ctx.uid)
await ctx.pubsub.entityDeleted(EntityType.PAGE, id, ctx.uid)
return true
} catch (e) {

View file

@ -44,4 +44,7 @@ export class Reminder {
@UpdateDateColumn()
updatedAt?: Date
@Column('text')
elasticPageId?: string
}

View file

@ -2501,6 +2501,7 @@ export type ResolversParentTypes = {
export type SanitizeDirectiveArgs = {
allowedTags?: Maybe<Array<Maybe<Scalars['String']>>>;
maxLength?: Maybe<Scalars['Int']>;
pattern?: Maybe<Scalars['String']>;
};
export type SanitizeDirectiveResolver<Result, Parent, ContextType = ResolverContext, Args = SanitizeDirectiveArgs> = DirectiveResolverFn<Result, Parent, ContextType, Args>;

View file

@ -1,4 +1,4 @@
directive @sanitize(allowedTags: [String], maxLength: Int) on INPUT_FIELD_DEFINITION
directive @sanitize(allowedTags: [String], maxLength: Int, pattern: String) on INPUT_FIELD_DEFINITION
type ArchiveLinkError {
errorCodes: [ArchiveLinkErrorCode!]!

View file

@ -28,8 +28,8 @@ import { ILike, In } from 'typeorm'
import { getRepository, setClaims } from '../../entity/utils'
import { createPubSubClient } from '../../datalayer/pubsub'
import { AppDataSource } from '../../server'
import { getPageById, updatePage } from '../../elastic/pages'
import { deleteLabelInPages } from '../../elastic/labels'
import { getPageById } from '../../elastic/pages'
import { deleteLabelInPages, updateLabelsInPage } from '../../elastic/labels'
export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
async (_obj, _params, { claims: { uid }, log }) => {
@ -108,7 +108,7 @@ export const createLabelResolver = authorized<
analytics.track({
userId: uid,
event: 'createLabel',
event: 'label_created',
properties: {
name,
color,
@ -178,7 +178,7 @@ export const deleteLabelResolver = authorized<
analytics.track({
userId: uid,
event: 'deleteLabel',
event: 'label_deleted',
properties: {
labelId,
env: env.server.apiEnv,
@ -231,17 +231,19 @@ export const setLabelsResolver = authorized<
}
// update labels in the page
await updatePage(
pageId,
{
labels,
},
{ pubsub, uid }
)
const updated = await updateLabelsInPage(pageId, labels, {
pubsub,
uid,
})
if (!updated) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
analytics.track({
userId: uid,
event: 'setLabels',
event: 'labels_set',
properties: {
pageId,
labelIds,

View file

@ -23,9 +23,10 @@ import { env } from '../../env'
import { ReminderData } from '../../datalayer/reminders/model'
import { DataModels } from '../types'
import { DateTime } from 'luxon'
import { UserArticleData } from '../../datalayer/links/model'
import { ArticleSavingRequestData } from '../../datalayer/article_saving_request/model'
import { setLinkArchived } from '../../services/archive_link'
import { getPageById } from '../../elastic/pages'
import { Page } from '../../elastic/types'
const validScheduleTime = (str: string): Date | undefined => {
const scheduleTime = DateTime.fromISO(str, { setZone: true }).set({
@ -87,17 +88,25 @@ export const createReminderResolver = authorized<
try {
if (articleId) {
// saving from web
const link = await models.userArticle.getByArticleId(uid, articleId)
if (!link) {
log.error('link not found', articleId)
const page = await getPageById(articleId)
if (!page) {
log.error('page not found', articleId)
return {
errorCodes: [CreateReminderErrorCode.NotFound],
}
}
linkId = link.id
if (page.userId !== uid) {
log.error('user not authorized', uid)
return {
errorCodes: [CreateReminderErrorCode.Unauthorized],
}
}
linkId = page.id
if (archiveUntil) {
await archiveLinkOrRequest(uid, link, undefined, models)
await archiveLinkOrRequest(uid, page, undefined)
}
}
@ -116,7 +125,7 @@ export const createReminderResolver = authorized<
articleSavingRequestId = articleSavingRequest.id
if (articleSavingRequest.articleId) {
await archiveLinkOrRequest(uid, undefined, articleSavingRequest, models)
await archiveLinkOrRequest(uid, undefined, articleSavingRequest)
}
}
@ -126,7 +135,6 @@ export const createReminderResolver = authorized<
// insert reminder to db
const reminder = await models.reminder.create({
userId: uid,
linkId: linkId ? linkId : undefined,
articleSavingRequestId: articleSavingRequestId
? articleSavingRequestId
: undefined,
@ -135,6 +143,7 @@ export const createReminderResolver = authorized<
sendNotification: sendNotification,
createdAt: new Date(),
remindAt: scheduledTime,
elasticPageId: linkId,
})
console.log('created reminder', reminder)
@ -161,16 +170,13 @@ export const createReminderResolver = authorized<
// case it will be archived when the link is created.
const archiveLinkOrRequest = async (
uid: string,
link: UserArticleData | undefined,
request: ArticleSavingRequestData | undefined,
models: DataModels
page: Page | undefined,
request: ArticleSavingRequestData | undefined
) => {
let target: UserArticleData | undefined = link || undefined
if (!link && request && request.articleId) {
let target: Page | undefined = page || undefined
if (!page && request && request.articleId) {
// this linkId is actually an article.id
target =
(await models.userArticle.getByArticleId(uid, request.articleId)) ||
undefined
target = (await getPageById(request.articleId)) || undefined
}
console.log('archiving target', target)
if (target) {
@ -200,9 +206,9 @@ export const reminderResolver = authorized<
try {
let reminder: ReminderData | null
// get link from articleId
const link = await models.userArticle.getByArticleId(uid, articleId)
if (!link) {
// get page from articleId
const page = await getPageById(articleId)
if (!page) {
// link may not be saved yet
// check savingArticleRequest table
const articleSavingRequest =
@ -224,7 +230,7 @@ export const reminderResolver = authorized<
})
} else {
reminder = await models.reminder.getCreatedByParameters(uid, {
linkId: link.id,
elasticPageId: page.id,
})
}

View file

@ -44,7 +44,7 @@ export function pdfAttachmentsRouter() {
analytics.track({
userId: user.id,
event: 'pdf-attachment-upload',
event: 'pdf_attachment_upload',
properties: {
env: env.server.apiEnv,
},
@ -107,7 +107,7 @@ export function pdfAttachmentsRouter() {
analytics.track({
userId: user.id,
event: 'pdf-attachment-create-article',
event: 'pdf_attachment_create_article',
properties: {
env: env.server.apiEnv,
},

View file

@ -6,14 +6,13 @@ import { readPushSubscription } from '../../datalayer/pubsub'
import { generateUploadSignedUrl, uploadToSignedUrl } from '../../utils/uploads'
import { v4 as uuidv4 } from 'uuid'
import { env } from '../../env'
import { Page } from '../../elastic/types'
import { DateTime } from 'luxon'
export function pageServiceRouter() {
export function uploadServiceRouter() {
const router = express.Router()
router.post('/upload/:folder', async (req, res) => {
console.log('upload page data req', req.params.folder)
router.post('/:folder', async (req, res) => {
console.log('upload data to folder', req.params.folder)
const { message: msgStr, expired } = readPushSubscription(req)
if (!msgStr) {
@ -28,9 +27,9 @@ export function pageServiceRouter() {
}
try {
const data: Partial<Page> = JSON.parse(msgStr)
if (!data.userId) {
console.log('No userId found in message')
const data: { userId: string; type: string } = JSON.parse(msgStr)
if (!data.userId || !data.type) {
console.log('No userId or type found in message')
res.status(400).send('Bad Request')
return
}
@ -41,9 +40,9 @@ export function pageServiceRouter() {
console.log('generate upload url')
const uploadUrl = await generateUploadSignedUrl(
`${req.params.folder}/${data.userId}/${DateTime.now().toFormat(
'yyyy-LL-dd'
)}/${uuidv4()}.json`,
`${req.params.folder}/${data.type}/${
data.userId
}/${DateTime.now().toFormat('yyyy-LL-dd')}/${uuidv4()}.json`,
contentType,
bucketName
)

View file

@ -8,10 +8,15 @@ export class SanitizedString extends GraphQLScalarType {
constructor(
type: GraphQLScalarType,
allowedTags?: string[],
maxLength?: number
maxLength?: number,
pattern?: string
) {
super({
name: `SanitizedString_${allowedTags}_${maxLength}`,
// Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ as per graphql-js
name: `SanitizedString_${allowedTags}_${maxLength}_${pattern}`.replace(
/\W/g,
''
),
description: 'Source string that was sanitized',
serialize(value: string) {
@ -25,6 +30,9 @@ export class SanitizedString extends GraphQLScalarType {
`Specified value cannot be longer than ${maxLength} characters`
)
}
if (pattern && !new RegExp(pattern).test(value)) {
throw new Error(`Specified value does not match pattern`)
}
return sanitize(value, { allowedTags: allowedTags || [] })
},
@ -36,6 +44,9 @@ export class SanitizedString extends GraphQLScalarType {
`Specified value cannot be longer than ${maxLength} characters`
)
}
if (pattern && !new RegExp(pattern).test(value)) {
throw new Error(`Specified value does not match pattern`)
}
return sanitize(value, { allowedTags: allowedTags || [] })
},
})

View file

@ -8,6 +8,7 @@ const schema = gql`
directive @sanitize(
allowedTags: [String]
maxLength: Int
pattern: String
) on INPUT_FIELD_DEFINITION
enum SortOrder {
@ -1273,7 +1274,7 @@ const schema = gql`
input CreateLabelInput {
name: String! @sanitize(maxLength: 64)
color: String!
color: String! @sanitize(pattern: "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")
description: String @sanitize(maxLength: 100)
}

View file

@ -40,7 +40,7 @@ import { ApolloServer } from 'apollo-server-express'
import { pdfAttachmentsRouter } from './routers/svc/pdf_attachments'
import { corsConfig } from './utils/corsConfig'
import { initElasticsearch } from './elastic'
import { pageServiceRouter } from './routers/svc/pages'
import { uploadServiceRouter } from './routers/svc/upload'
const PORT = process.env.PORT || 4000
@ -98,7 +98,7 @@ export const createApp = (): {
app.use('/svc/pubsub/links', linkServiceRouter())
app.use('/svc/pubsub/newsletters', newsletterServiceRouter())
app.use('/svc/pubsub/emails', emailsServiceRouter())
app.use('/svc/pubsub/pages', pageServiceRouter())
app.use('/svc/pubsub/upload', uploadServiceRouter())
app.use('/svc/reminders', remindersServiceRouter())
app.use('/svc/pdf-attachments', pdfAttachmentsRouter())

View file

@ -1,16 +1,16 @@
import Postgrator from 'postgrator'
import { User } from '../src/entity/user'
import { Profile } from '../src/entity/profile'
import { Page } from '../src/entity/page'
import { Link } from '../src/entity/link'
import { Reminder } from '../src/entity/reminder'
import { NewsletterEmail } from '../src/entity/newsletter_email'
import { UserDeviceToken } from '../src/entity/user_device_tokens'
import { Label } from '../src/entity/label'
import { AppDataSource } from '../src/server'
import { getRepository } from '../src/entity/utils'
import { createUser } from '../src/services/create_user'
import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
import Postgrator from "postgrator";
import { User } from "../src/entity/user";
import { Profile } from "../src/entity/profile";
import { Page } from "../src/entity/page";
import { Link } from "../src/entity/link";
import { Reminder } from "../src/entity/reminder";
import { NewsletterEmail } from "../src/entity/newsletter_email";
import { UserDeviceToken } from "../src/entity/user_device_tokens";
import { Label } from "../src/entity/label";
import { AppDataSource } from "../src/server";
import { getRepository } from "../src/entity/utils";
import { createUser } from "../src/services/create_user";
import { SnakeNamingStrategy } from "typeorm-naming-strategies";
const runMigrations = async () => {
const migrationDirectory = __dirname + '/../../db/migrations'
@ -122,11 +122,11 @@ export const createTestLink = async (user: User, page: Page): Promise<Link> => {
export const createTestReminder = async (
user: User,
link?: string
pageId?: string
): Promise<Reminder> => {
return getRepository(Reminder).save({
user: user,
link: link,
elasticPageId: pageId,
remindAt: new Date(),
})
}

View file

@ -1,7 +1,10 @@
import { generateFakeUuid, graphqlRequest, request } from '../util'
import {
createTestLink,
createTestPage,
createTestElasticPage,
generateFakeUuid,
graphqlRequest,
request,
} from '../util'
import {
createTestReminder,
createTestUser,
deleteTestUser,
@ -14,17 +17,15 @@ import {
ReminderErrorCode,
UpdateReminderErrorCode,
} from '../../src/generated/graphql'
import { Page } from '../../src/entity/page'
import { Link } from '../../src/entity/link'
import { DateTime } from 'luxon'
import 'mocha'
import { Page } from '../../src/elastic/types'
describe('Reminders API', () => {
const username = 'fakeUser'
let authToken: string
let page: Page
let link: Link
let reminder: Reminder
before(async () => {
@ -37,9 +38,8 @@ describe('Reminders API', () => {
authToken = res.body.authToken
// create page, link and reminders test data
page = await createTestPage()
link = await createTestLink(user, page)
reminder = await createTestReminder(user, link.id)
page = await createTestElasticPage(user)
reminder = await createTestReminder(user, page.id)
})
after(async () => {

View file

@ -1,21 +1,21 @@
import { request } from '../util'
import 'mocha'
describe('Pages Router', () => {
describe('Upload Router', () => {
const token = process.env.PUBSUB_VERIFICATION_TOKEN || ''
describe('upload', () => {
it('upload data to GCS', async () => {
const data = {
message: {
data: Buffer.from(JSON.stringify({ userId: 'userId' })).toString(
data: Buffer.from(JSON.stringify({ userId: 'userId', type: 'page' })).toString(
'base64'
),
publishTime: new Date().toISOString(),
},
}
await request
.post(`/svc/pubsub/pages/upload/createdPage?token=${token}`)
.post(`/svc/pubsub/upload/createdEntity?token=${token}`)
.send(data)
.expect(200)
})

View file

@ -504,15 +504,17 @@ Readability.prototype = {
});
},
/** Creates imageproxy links for all article images */
/** Creates imageproxy links for all article images with href source */
_createImageProxyLinks: function (articleContent) {
if (this.createImageProxyUrl !== undefined) {
// replace all image src's
// replace all images' href source
const images = articleContent.getElementsByTagName('img');
Array.from(images).forEach(image => {
const src = image.getAttribute("src");
const dataUriRegex = /^data:image\/(?:png|jpe?g|gif);base64,/;
if (src) {
// do not proxy data uri
if (src && !dataUriRegex.test(src)) {
const absoluteSrc = this.toAbsoluteURI(src);
const attToNumber = (str) => {
if (!str) { return 0; }

View file

@ -270,6 +270,17 @@ describe("Readability API", function() {
}).parse().content;
expect(content).eql(expected_xhtml);
});
it("should not proxy image with data uri", function() {
var dom = new JSDOM("My cat: <img src=\"data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA" +
"AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==\"" +
" alt=\"Red dot\" />");
var expected_xhtml = "<div id=\"readability-page-1\" class=\"page\">My cat: <img src=\"data:image/png;base64," +
" iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0" +
"Y4OHwAAAABJRU5ErkJggg==\" alt=\"Red dot\"></div>";
var content = new Readability(dom.window.document).parse().content;
expect(content).eql(expected_xhtml);
});
});
});

View file

@ -153,6 +153,14 @@ export const Button = styled('button', {
opacity: 0.8,
},
},
articleActionIcon: {
bg: 'transparent',
border: 'none',
cursor: 'pointer',
'&:hover': {
opacity: 0.8,
},
},
ghost: {
color: 'transparent',
border: 'none',

View file

@ -8,6 +8,7 @@ import {
Arrow,
Label,
} from '@radix-ui/react-dropdown-menu'
import { PopperContentProps } from '@radix-ui/react-popover';
import { CSS } from '@stitches/react';
import { styled } from './../tokens/stitches.config'
@ -49,12 +50,12 @@ export const DropdownContent = styled(Content, {
backgroundColor: '$grayBg',
borderRadius: '0.5em',
padding: 5,
border: '1px solid $grayBorder',
outline: '1px solid $grayBorder',
boxShadow: '$cardBoxShadow',
})
const StyledArrow = styled(Arrow, {
fill: '$grayBase',
fill: '$grayBg',
})
const StyledLabel = styled(Label, {
@ -65,6 +66,7 @@ const StyledLabel = styled(Label, {
})
export type DropdownAlignment = 'start' | 'end' | 'center'
export type DropdownSide = 'top' | 'right' | 'bottom' | 'left'
type DropdownProps = {
labelText?: string
@ -73,8 +75,11 @@ type DropdownProps = {
children: React.ReactNode
styledArrow?: boolean
align?: DropdownAlignment
side?: DropdownSide
sideOffset?: number
disabled?: boolean
css?: CSS
modal?: boolean
}
export const DropdownSeparator = styled(Separator, {
@ -101,17 +106,22 @@ export function DropdownOption(props: DropdownOptionProps): JSX.Element {
)
}
export function Dropdown({
children,
align,
triggerElement,
labelText,
showArrow = true,
disabled = false,
css
}: DropdownProps): JSX.Element {
export function Dropdown(props: DropdownProps & PopperContentProps): JSX.Element {
const {
children,
align,
triggerElement,
labelText,
showArrow = true,
disabled = false,
side = 'bottom',
sideOffset = 0,
alignOffset = 0,
css,
modal
} = props
return (
<Root modal={false}>
<Root modal={modal}>
<DropdownTrigger disabled={disabled}>{triggerElement}</DropdownTrigger>
<DropdownContent
css={css}
@ -119,11 +129,14 @@ export function Dropdown({
// remove focus from dropdown
;(document.activeElement as HTMLElement).blur()
}}
side={side}
sideOffset={sideOffset}
align={align ? align : 'center'}
alignOffset={alignOffset}
>
{labelText && <StyledLabel>{labelText}</StyledLabel>}
{children}
{showArrow && <StyledArrow offset={20} />}
{showArrow && <StyledArrow offset={20} width={20} height={10} />}
</DropdownContent>
</Root>
)

View file

@ -1,3 +1,5 @@
import Link from 'next/link'
import { SpanBox } from './LayoutPrimitives'
import { StyledText } from './StyledText'
type LabelChipProps = {
@ -16,19 +18,25 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
}
const color = hexToRgb(props.color)
return (
<StyledText
css={{
margin: '4px',
borderRadius: '32px',
color: props.color,
fontSize: '12px',
fontWeight: 'bold',
padding: '4px 8px 4px 8px',
border: `1px solid rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.7)`,
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.08)`,
}}
>
{props.text}
</StyledText>
<Link href={`/home?q=label:"${props.text}"`}>
<SpanBox
css={{
display: 'inline-table',
margin: '4px',
borderRadius: '32px',
color: props.color,
fontSize: '12px',
fontWeight: 'bold',
padding: '4px 8px 4px 8px',
whiteSpace: 'nowrap',
cursor: 'pointer',
backgroundClip: 'padding-box',
border: `1px solid rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.7)`,
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.08)`,
}}
>
{props.text}
</SpanBox>
</Link>
)
}

View file

@ -2,21 +2,18 @@ import React, { useState } from 'react'
import { styled } from '../tokens/stitches.config'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { HexColorPicker } from 'react-colorful'
import { Button } from './Button'
import { HStack, SpanBox } from './LayoutPrimitives'
import { CaretDown } from 'phosphor-react'
import { StyledText } from './StyledText'
import {
ColorDetailsProps,
LabelColor,
LabelColorDropdownProps,
LabelColorHex,
LabelColorObject,
LabelOptionProps,
} from '../../utils/settings-page/labels/types'
import { labelColorObjects } from '../../utils/settings-page/labels/labelColorObjects'
import { DropdownOption } from './DropdownElements'
import { isDarkTheme } from '../../lib/themeUpdater'
import { LabelColor } from '../../lib/networking/fragments/labelFragment'
const DropdownMenuContent = styled(DropdownMenuPrimitive.Content, {
maxWidth: 190,

View file

@ -1,5 +1,5 @@
import { Root, Overlay, Content } from '@radix-ui/react-dialog'
import { styled, keyframes } from '../tokens/stitches.config'
import { styled, keyframes, theme } from '../tokens/stitches.config'
export const ModalRoot = styled(Root, {})
@ -25,8 +25,7 @@ const contentShow = keyframes({
const Modal = styled(Content, {
backgroundColor: '$grayBg',
borderRadius: 6,
boxShadow:
'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px',
boxShadow: theme.shadows.cardBoxShadow.toString(),
position: 'fixed',
'&:focus': { outline: 'none' },
})

View file

@ -0,0 +1,39 @@
import { Box, HStack } from './LayoutPrimitives'
import { styled, theme } from '../tokens/stitches.config'
type TickedRangeSliderProps = {
ticks?: number,
value: number,
onChange: (value: number) => void,
min?: number,
max?: number,
step?: number,
}
const Tick = styled(Box, {
background: theme.colors.grayBorderHover,
width: 2,
height: 8,
})
export function TickedRangeSlider({
ticks = 8,
min = 10,
max = 28,
step = 1,
value,
onChange,
} : TickedRangeSliderProps
): JSX.Element {
return (
<Box css={{zIndex: 2}}>
<input onChange={(e) => onChange(e.target.value as any)} value={value} type="range" min={min} max={max} step={step} className='slider'/>
<HStack distribution='between' css={{position: 'relative', bottom: 12.2, left: 2, zIndex: -1}}>
{[...Array(ticks)].map((val, idx) => <Tick key={`ticks-${idx}`} />)}
</HStack>
</Box>
)
}

View file

@ -62,6 +62,15 @@ type TooltipWrappedProps = {
style?: TooltipPrimitive.TooltipContentProps['style']
}
const DefaultTooltipStyle = {
backgroundColor: '#F9D354',
color: '#0A0806',
}
const DefaultArrowStyle = {
fill: '#F9D354'
}
export const TooltipWrapped: FC<TooltipWrappedProps> = ({
children,
active,
@ -73,9 +82,14 @@ export const TooltipWrapped: FC<TooltipWrappedProps> = ({
return (
<Tooltip open={active}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent sideOffset={5} side={tooltipSide} {...props}>
<TooltipContent
sideOffset={5}
side={tooltipSide}
style={DefaultTooltipStyle}
{...props}
>
{tooltipContent}
<TooltipArrow style={arrowStyles} />
<TooltipArrow style={arrowStyles ?? DefaultArrowStyle} />
</TooltipContent>
</Tooltip>
)

View file

@ -0,0 +1,14 @@
type AIconProps = {
size: number
color: string
style?: React.CSSProperties
}
export function AIcon(props: AIconProps): JSX.Element {
return (
<svg style={props.style} width={props.size} height={props.size} viewBox={`0 0 20 20`} fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.5 18L11.75 5.25L5 18" stroke={props.color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16.5133 14.25H6.98828" stroke={props.color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
}

View file

@ -2,6 +2,7 @@ import { config } from '../../tokens/stitches.config'
import Image from 'next/image'
import { StyledText } from '../../elements/StyledText'
import Link from 'next/link'
import { SpanBox } from '../LayoutPrimitives'
export function OmnivoreNameLogoImage(): JSX.Element {
return (
@ -51,7 +52,7 @@ export function OmnivoreNameLogo(props: OmnivoreNameLogoProps): JSX.Element {
<Link passHref href={href}>
<a style={{ textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
<OmnivoreLogoIcon size={27} strokeColor={fillColor}></OmnivoreLogoIcon>
<StyledText style="logoTitle" css={{ color: fillColor, paddingLeft: '12px' }}>Omnivore</StyledText>
{/* <StyledText style="logoTitle" css={{ color: fillColor, paddingLeft: '12px' }}>Omnivore</StyledText> */}
</a>
</Link>
)

View file

@ -16,6 +16,7 @@ export type HeaderDropdownAction =
| 'apply-lighter-theme'
| 'navigate-to-install'
| 'navigate-to-emails'
| 'navigate-to-labels'
| 'navigate-to-profile'
| 'increaseFontSize'
| 'decreaseFontSize'
@ -24,7 +25,6 @@ export type HeaderDropdownAction =
type DropdownMenuProps = {
username?: string
triggerElement: ReactNode
displayFontStepper?: boolean
actionHandler: (action: HeaderDropdownAction) => void
}
@ -53,24 +53,6 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
{ isDark ? '✓' : '' }
</Button>
</HStack>
{props.displayFontStepper && (
<>
<HStack css={{ mt: '8px', width: '100%', height: '26px', gap: '8px', borderRadius: '6px', border: '1px solid $grayTextContrast', }}>
<Button style='plainIcon' css={{ display: 'inline-block', verticalAlign: 'baseline', width: '50%', height: '100%', bg: 'unset' }} onClick={() => {
props.actionHandler('decreaseFontSize')
}}>
<StyledText css={{ fontSize: '14px', m: '0px' }}>A</StyledText>
</Button>
<Box css={{ width: '1px', height: '100%', bg: '$grayTextContrast' }} />
<Button style='plainIcon' css={{ display: 'inline-block', verticalAlign: 'baseline', width: '50%', height: '100%', bg: 'unset' }} onClick={() => {
props.actionHandler('increaseFontSize')
}}>
<StyledText css={{ fontSize: '18px', m: '0px' }}>A</StyledText>
</Button>
</HStack>
</>
)}
</VStack>
<DropdownSeparator />
<DropdownOption
@ -81,6 +63,10 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
onSelect={() => props.actionHandler('navigate-to-emails')}
title="Emails"
/>
<DropdownOption
onSelect={() => props.actionHandler('navigate-to-labels')}
title="Labels"
/>
<DropdownOption
onSelect={() => window.Intercom('show')}
title="Feedback"

View file

@ -44,7 +44,7 @@ export function HighlightBar(props: HighlightBarProps): JSX.Element {
background: '$grayBg',
borderRadius: '4px',
border: '1px solid $grayBorder',
boxShadow: '0px 0px 9px -2px rgba(32, 31, 29, 0.09), 0px 7px 12px rgba(32, 31, 29, 0.07)',
boxShadow: theme.shadows.cardBoxShadow.toString(),
bottom: 'calc(38px + env(safe-area-inset-bottom, 40px))',
'@smDown': {
maxWidth: '80%',

View file

@ -154,11 +154,11 @@ export function GridLinkedItemCard(props: LinkedItemCardProps): JSX.Element {
/>
)}
</HStack>
<HStack css={{ mt: '8px' }}>
{props.item.labels?.map(({ description, color }, index) => (
<LabelChip key={index} text={description || ''} color={color} />
{/* <HStack css={{ mt: '8px' }}>
{props.item.labels?.map(({ name, color }, index) => (
<LabelChip key={index} text={name || ''} color={color} />
))}
</HStack>
</HStack> */}
</VStack>
)
}

View file

@ -17,7 +17,6 @@ import { UserBasicData } from '../../lib/networking/queries/useGetViewerQuery'
import { setupAnalytics } from '../../lib/analytics'
import { Button } from '../elements/Button'
import Link from 'next/link'
import { ArrowSquareOut } from 'phosphor-react'
type HeaderProps = {
user?: UserBasicData
@ -26,7 +25,7 @@ type HeaderProps = {
profileImageURL?: string
isFixedPosition: boolean
scrollElementRef?: React.RefObject<HTMLDivElement>
displayFontStepper?: boolean
toolbarControl?: JSX.Element
setShowLogoutConfirmation: (showShareModal: boolean) => void
setShowKeyboardCommandsModal: (showShareModal: boolean) => void
}
@ -55,9 +54,6 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element {
(changeset: ScrollOffsetChangeset) => {
const isScrolledBeyondMinThreshold = changeset.current.y >= 50
const isScrollingDown = changeset.current.y > changeset.previous.y
// setIsScrolled(isScrolledBeyondMinThreshold)
// setShowHeader(!(isScrollingDown && isScrolledBeyondMinThreshold))
},
0
)
@ -106,6 +102,9 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element {
case 'navigate-to-emails':
router.push('/settings/emails')
break
case 'navigate-to-labels':
router.push('/settings/labels')
break
case 'navigate-to-profile':
if (props.user) {
router.push(`/${props.user.profile.username}`)
@ -128,7 +127,7 @@ export function PrimaryHeader(props: HeaderProps): JSX.Element {
isDisplayingShadow={isScrolled}
isVisible={true}
isFixedPosition={true}
displayFontStepper={props.displayFontStepper}
toolbarControl={props.toolbarControl}
/>
</>
)
@ -143,7 +142,7 @@ type NavHeaderProps = {
isDisplayingShadow?: boolean
isVisible?: boolean
isFixedPosition: boolean
displayFontStepper?: boolean
toolbarControl?: JSX.Element
}
function NavHeader(props: NavHeaderProps): JSX.Element {
@ -159,16 +158,16 @@ function NavHeader(props: NavHeaderProps): JSX.Element {
zIndex: 5,
width: '100%',
boxShadow: props.isDisplayingShadow ? '$panelShadow' : 'unset',
bg: '$grayBase',
p: '0px $3 0px $3',
height: '68px',
height: '48px',
position: 'fixed',
minHeight: '68px',
bg: 'transparent',
'@smDown': {
height: '48px',
minHeight: '48px',
p: '0px 18px 0px 16px',
},
'@lgDown': {
bg: '$grayBase',
},
}}
>
<HStack alignment="center" distribution="start">
@ -183,26 +182,26 @@ function NavHeader(props: NavHeaderProps): JSX.Element {
</Box>
<NavLinks currentPath={currentPath} isLoggedIn={!!props.username} />
</HStack>
{props.toolbarControl && (
<HStack distribution="end" alignment="center" css={{
height: '100%', width: '100%',
mr: '16px',
display: 'none',
'@lgDown': {
display: 'flex',
},
}}
>
{props.toolbarControl}
</HStack>
)}
{props.username ? (
<HStack
alignment="center"
css={{ display: 'flex', alignItems: 'center' }}
>
<Box css={{ '@smDown': { visibility: 'collapse' } }}>
<a href="https://github.com/omnivore-app/omnivore" target='_blank' rel="noreferrer">
<Button style="ctaLightGray" css={{ background: 'unset', mr: '32px' }}>
<HStack css={{ height: '100%' }}>
<svg version="1.1" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<path fill={theme.colors.grayTextContrast.toString()} fillRule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path>
</svg>
<SpanBox css={{ pl: '8px', color: '$grayTextContrast' }}>Follow us on GitHub</SpanBox>
<Box className='ctaButtonIcon' css={{ ml: '4px' }}>
<ArrowSquareOut size={16} />
</Box>
</HStack>
</Button>
</a>
</Box>
<DropdownMenu
username={props.username}
triggerElement={
@ -212,7 +211,6 @@ function NavHeader(props: NavHeaderProps): JSX.Element {
/>
}
actionHandler={props.actionHandler}
displayFontStepper={props.displayFontStepper}
/>
</HStack>
) : (

View file

@ -4,7 +4,6 @@ import {
ReactNode,
MutableRefObject,
useEffect,
useContext,
useState,
} from 'react'
import { PrimaryHeader } from './../patterns/PrimaryHeader'
@ -23,7 +22,7 @@ type PrimaryLayoutProps = {
hideHeader?: boolean
pageMetaDataProps?: PageMetaDataProps
scrollElementRef?: MutableRefObject<HTMLDivElement | null>
displayFontStepper?: boolean
headerToolbarControl?: JSX.Element
}
export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
@ -62,23 +61,28 @@ export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
{props.pageMetaDataProps ? (
<PageMetaData {...props.pageMetaDataProps} />
) : null}
<Box css={{ bg: '$grayBase', height: '100vh', width: '100vw' }}>
<Box css={{
height: '100vh',
width: '100vw',
bg: 'transparent',
'@smDown': {
bg: '$grayBase',
}
}}>
<PrimaryHeader
user={viewerData?.me}
hideHeader={props.hideHeader}
userInitials={viewerData?.me?.name.charAt(0) ?? ''}
profileImageURL={viewerData?.me?.profile.pictureUrl}
isFixedPosition={true}
toolbarControl={props.headerToolbarControl}
scrollElementRef={props.scrollElementRef}
displayFontStepper={props.displayFontStepper}
setShowLogoutConfirmation={setShowLogoutConfirmation}
setShowKeyboardCommandsModal={setShowKeyboardCommandsModal}
/>
<Box
ref={props.scrollElementRef}
css={{
top: '68px',
'@smDown': { top: '48px' },
position: 'fixed',
overflowY: 'auto',
height: '100%',
@ -86,6 +90,13 @@ export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
bg: '$grayBase',
}}
>
<Box
ref={props.scrollElementRef}
css={{
height: '48px',
bg: '$grayBase',
}}
></Box>
{props.children}
{showLogoutConfirmation ? (
<ConfirmationModal

View file

@ -54,8 +54,7 @@ export function SettingsLayout(props: SettingsLayoutProps): JSX.Element {
/>
<Box
css={{
top: '68px',
'@smDown': { top: '48px' },
top: '48px',
position: 'fixed',
overflowY: 'auto',
height: '100%',

View file

@ -0,0 +1,154 @@
import { Separator } from "@radix-ui/react-separator"
import { ArchiveBox, DotsThree, HighlighterCircle, TagSimple, TextAa } from "phosphor-react"
import { ArticleAttributes } from "../../../lib/networking/queries/useGetArticleQuery"
import { useGetUserPreferences } from "../../../lib/networking/queries/useGetUserPreferences"
import { Button } from "../../elements/Button"
import { Dropdown } from "../../elements/DropdownElements"
import { Box, SpanBox } from "../../elements/LayoutPrimitives"
import { TooltipWrapped } from "../../elements/Tooltip"
import { styled, theme } from "../../tokens/stitches.config"
import { SetLabelsControl } from "./SetLabelsControl"
import { ReaderSettingsControl } from "./ReaderSettingsControl"
import { usePersistedState } from "../../../lib/hooks/usePersistedState"
export type ArticleActionsMenuLayout = 'horizontal' | 'vertical'
type ArticleActionsMenuProps = {
article: ArticleAttributes
layout: ArticleActionsMenuLayout
lineHeight: number
marginWidth: number
articleActionHandler: (action: string, arg?: unknown) => void
}
type MenuSeparatorProps = {
layout: ArticleActionsMenuLayout
}
const MenuSeparator = (props: MenuSeparatorProps): JSX.Element => {
const LineSeparator = styled(Separator, {
width: '100%',
margin: 0,
borderBottom: `1px solid ${theme.colors.grayLine.toString()}`,
my: '8px',
})
return (props.layout == 'vertical' ? <LineSeparator /> : <></>)
}
type ActionDropdownProps = {
layout: ArticleActionsMenuLayout
triggerElement: JSX.Element
children: JSX.Element
}
const ActionDropdown = (props: ActionDropdownProps): JSX.Element => {
return <Dropdown
showArrow={true}
css={{ m: '0px', p: '0px', overflow: 'hidden', width: '265px', maxWidth: '265px', '@smDown': { width: '230px' } }}
side={props.layout == 'vertical' ? 'right' : 'bottom'}
sideOffset={props.layout == 'vertical' ? 8 : 0}
align={props.layout == 'vertical' ? 'start' : 'center'}
alignOffset={props.layout == 'vertical' ? -18 : undefined}
triggerElement={props.triggerElement}
>
{props.children}
</Dropdown>
}
export function ArticleActionsMenu(props: ArticleActionsMenuProps): JSX.Element {
return (
<>
<Box
css={{
display: 'flex',
alignItems: 'center',
flexDirection: props.layout == 'vertical' ? 'column' : 'row',
justifyContent: props.layout == 'vertical' ? 'center' : 'flex-end',
gap: props.layout == 'vertical' ? '8px' : '24px',
paddingTop: '6px',
}}
>
<ActionDropdown
layout={props.layout}
triggerElement={
<TooltipWrapped
tooltipContent="Adjust Display Settings"
tooltipSide={props.layout == 'vertical' ? 'right' : 'bottom'}
>
<TextAa size={24} color={theme.colors.readerFont.toString()} />
</TooltipWrapped>
}
>
<ReaderSettingsControl
lineHeight={props.lineHeight}
marginWidth={props.marginWidth}
articleActionHandler={props.articleActionHandler}
/>
</ActionDropdown>
<MenuSeparator layout={props.layout} />
<SpanBox css={{
'display': 'flex',
'@smDown': {
display: 'none',
}}}
>
<ActionDropdown
layout={props.layout}
triggerElement={
<TooltipWrapped
tooltipContent="Edit labels"
tooltipSide={props.layout == 'vertical' ? 'right' : 'bottom'}
>
<TagSimple size={24} color={theme.colors.readerFont.toString()} />
</TooltipWrapped>
}
>
<SetLabelsControl
article={props.article}
articleActionHandler={props.articleActionHandler}
/>
</ActionDropdown>
</SpanBox>
<Button style='articleActionIcon'
onClick={() => props.articleActionHandler('setLabels')}
css={{
'display': 'none',
'@smDown': {
display: 'flex',
},
}}>
<TagSimple size={24} color={theme.colors.readerFont.toString()} />
</Button>
<Button style='articleActionIcon' onClick={() => props.articleActionHandler('showHighlights')}>
<TooltipWrapped
tooltipContent="View Highlights"
tooltipSide={props.layout == 'vertical' ? 'right' : 'bottom'}
>
<HighlighterCircle size={24} color={theme.colors.readerFont.toString()} />
</TooltipWrapped>
</Button>
<MenuSeparator layout={props.layout} />
<Button style='articleActionIcon' onClick={() => props.articleActionHandler('archive')}>
<TooltipWrapped
tooltipContent="Archive"
tooltipSide={props.layout == 'vertical' ? 'right' : 'bottom'}
>
<ArchiveBox size={24} color={theme.colors.readerFont.toString()} />
</TooltipWrapped>
</Button>
{/* <MenuSeparator layout={props.layout} />
<Button style='articleActionIcon'>
<DotsThree size={24} color={theme.colors.readerFont.toString()} />
</Button> */}
</Box>
</>
)
}

View file

@ -1,6 +1,6 @@
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { Article } from './../../../components/templates/article/Article'
import { Box, VStack } from './../../elements/LayoutPrimitives'
import { Box, SpanBox, VStack } from './../../elements/LayoutPrimitives'
import { StyledText } from './../../elements/StyledText'
import { ArticleSubtitle } from './../../patterns/ArticleSubtitle'
import { theme, ThemeId } from './../../tokens/stitches.config'
@ -13,9 +13,12 @@ import { ArticleHeaderToolbar } from './ArticleHeaderToolbar'
import { userPersonalizationMutation } from '../../../lib/networking/mutations/userPersonalizationMutation'
import { updateThemeLocally } from '../../../lib/themeUpdater'
import { ArticleMutations } from '../../../lib/articleActions'
import { LabelChip } from '../../elements/LabelChip'
import { Label } from '../../../lib/networking/fragments/labelFragment'
type ArticleContainerProps = {
article: ArticleAttributes
labels: Label[]
articleMutations: ArticleMutations
scrollElementRef: MutableRefObject<HTMLDivElement | null>
isAppleAppEmbed: boolean
@ -24,21 +27,22 @@ type ArticleContainerProps = {
margin?: number
fontSize?: number
fontFamily?: string
lineHeight?: number
showHighlightsModal: boolean
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
}
export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
const [showShareModal, setShowShareModal] = useState(false)
const [showLabelsModal, setShowLabelsModal] = useState(false)
const [showNotesSidebar, setShowNotesSidebar] = useState(false)
const [showReportIssuesModal, setShowReportIssuesModal] = useState(false)
const [showHighlightsModal, setShowHighlightsModal] = useState(props.showHighlightsModal)
const [fontSize, setFontSize] = useState(props.fontSize ?? 20)
const [labels, setLabels] = useState(
props.article.labels?.map((l) => l.id) || []
)
const updateFontSize = async (newFontSize: number) => {
setFontSize(newFontSize)
await userPersonalizationMutation({ fontSize: newFontSize })
if (fontSize !== newFontSize) {
setFontSize(newFontSize)
await userPersonalizationMutation({ fontSize: newFontSize })
}
}
useEffect(() => {
@ -85,8 +89,9 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
}, [props.article])
const styles = {
margin: props.margin ?? 140,
fontSize,
margin: props.margin ?? 360,
lineHeight: props.lineHeight ?? 150,
fontFamily: props.fontFamily ?? 'inter',
readerFontColor: theme.colors.readerFont.toString(),
readerFontColorTransparent: theme.colors.readerFontTransparent.toString(),
@ -100,10 +105,11 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
id="article-container"
css={{
padding: '16px',
maxWidth: '94%',
maxWidth: '100%',
background: props.isAppleAppEmbed ? 'unset' : theme.colors.grayBg.toString(),
'--text-font-family': styles.fontFamily,
'--text-font-size': `${styles.fontSize}px`,
'--line-height': `150%`,
'--line-height': `${styles.lineHeight}%`,
'--blockquote-padding': '0.5em 1em',
'--blockquote-icon-font-size': '1.3rem',
'--figure-margin': '1.6rem auto',
@ -117,15 +123,15 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
'--blockquote-icon-font-size': '1.7rem',
'--figure-margin': '2.6875rem auto',
'--hr-margin': '2em',
margin: `30px ${styles.margin / 2}px`,
margin: `30px 0px`,
},
'@md': {
maxWidth: '92%',
maxWidth: 1024 - (styles.margin),
},
'@lg': {
margin: `30px 0`,
width: 'auto',
maxWidth: 1024 - styles.margin,
maxWidth: 1024 - (styles.margin),
},
}}
>
@ -143,13 +149,22 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
author={props.article.author}
href={props.article.url}
/>
<ArticleHeaderToolbar
articleTitle={props.article.title}
articleShareURL={props.highlightsBaseURL}
setShowNotesSidebar={setShowNotesSidebar}
setShowShareArticleModal={setShowShareModal}
hasHighlights={props.article.highlights?.length > 0}
/>
{props.labels ? (
<SpanBox css={{ pb: '16px', width: '100%', '&:empty': { display: 'none' } }}>
{props.labels?.map((label) =>
<LabelChip key={label.id} text={label.name} color={label.color} />
)}
</SpanBox>
) : null}
{props.isAppleAppEmbed && (
<ArticleHeaderToolbar
articleTitle={props.article.title}
articleShareURL={props.highlightsBaseURL}
setShowShareArticleModal={setShowShareModal}
setShowHighlightsModal={props.setShowHighlightsModal}
hasHighlights={props.article.highlights?.length > 0}
/>
)}
</VStack>
<Article
articleId={props.article.id}
@ -181,10 +196,10 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
articleAuthor={props.article.author ?? ''}
articleId={props.article.id}
isAppleAppEmbed={props.isAppleAppEmbed}
highlightBarDisabled={props.highlightBarDisabled}
showNotesSidebar={showNotesSidebar}
highlightsBaseURL={props.highlightsBaseURL}
setShowNotesSidebar={setShowNotesSidebar}
highlightBarDisabled={props.highlightBarDisabled}
showHighlightsModal={props.showHighlightsModal}
setShowHighlightsModal={props.setShowHighlightsModal}
articleMutations={props.articleMutations}
/>
{showReportIssuesModal ? (

View file

@ -14,7 +14,7 @@ type ArticleHeaderToolbarProps = {
articleTitle: string
articleShareURL: string
hasHighlights: boolean
setShowNotesSidebar: (showNotesSidebar: boolean) => void
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
setShowShareArticleModal: (showShareModal: boolean) => void
}
@ -46,7 +46,12 @@ export function ArticleHeaderToolbar(
return (
<HStack distribution="between" alignment="center" css={{ gap: '$2' }}>
{props.hasHighlights && (
<Button style="plainIcon" onClick={() => props.setShowNotesSidebar(true)} title="View all your highlights and notes">
<Button style="plainIcon" onClick={() => {
if (props.setShowHighlightsModal) {
props.setShowHighlightsModal(true)
}
}}
title="View all your highlights and notes">
<CommentIcon
size={24}
strokeColor={theme.colors.grayTextContrast.toString()}

View file

@ -0,0 +1,63 @@
import { X } from 'phosphor-react'
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { UserPreferences } from '../../../lib/networking/queries/useGetUserPreferences'
import { Button } from '../../elements/Button'
import { CrossIcon } from '../../elements/images/CrossIcon'
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
import {
ModalRoot,
ModalOverlay,
ModalContent,
} from '../../elements/ModalPrimitives'
import { StyledText } from '../../elements/StyledText'
import { theme } from '../../tokens/stitches.config'
import { ReaderSettingsControl } from './ReaderSettingsControl'
type DisplaySettingsModalProps = {
onOpenChange: (open: boolean) => void
lineHeight: number
marginWidth: number
articleActionHandler: (action: string, arg?: number) => void
}
export function DisplaySettingsModal(props: DisplaySettingsModalProps): JSX.Element {
return (
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
<ModalOverlay />
<ModalContent
css={{ overflow: 'auto' }}
onPointerDownOutside={(event) => {
event.preventDefault()
props.onOpenChange(false)
}}
>
<VStack css={{ width: '100%' }}>
<HStack
distribution="between"
alignment="center"
css={{ width: '100%' }}
>
<StyledText style="modalHeadline" css={{ pl: '16px' }}>Labels</StyledText>
<Button
css={{ pt: '16px', pr: '16px' }}
style="ghost"
onClick={() => {
props.onOpenChange(false)
}}
>
<CrossIcon
size={14}
strokeColor={theme.colors.grayText.toString()}
/>
</Button>
</HStack>
<ReaderSettingsControl
lineHeight={props.lineHeight}
marginWidth={props.marginWidth}
articleActionHandler={props.articleActionHandler}
/>
</VStack>
</ModalContent>
</ModalRoot>
)
}

View file

@ -1,111 +0,0 @@
import {
ModalContent,
ModalOverlay,
ModalRoot,
} from '../../elements/ModalPrimitives'
import { HStack, VStack } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { CrossIcon } from '../../elements/images/CrossIcon'
import { theme } from '../../tokens/stitches.config'
import { Label, useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
import { ChangeEvent, useCallback, useState } from 'react'
import { setLabelsMutation } from '../../../lib/networking/mutations/setLabelsMutation'
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { LabelChip } from '../../elements/LabelChip'
type EditLabelsModalProps = {
labels: Label[]
article: ArticleAttributes
onOpenChange: (open: boolean) => void
setLabels: (labels: Label[]) => void
}
export function EditLabelsModal(props: EditLabelsModalProps): JSX.Element {
const [selectedLabels, setSelectedLabels] = useState(props.labels)
const { labels } = useGetLabelsQuery()
const saveAndExit = useCallback(async () => {
const result = await setLabelsMutation(props.article.id, selectedLabels.map((l) => l.id))
console.log('result of setting labels', result)
props.onOpenChange(false)
props.setLabels(selectedLabels)
}, [props, selectedLabels])
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
// const label = event.target.value
// if (event.target.checked) {
// setSelectedLabels([...selectedLabels, label])
// } else {
// setSelectedLabels(selectedLabels.filter((l) => l !== label))
// }
},
[selectedLabels]
)
return (
<ModalRoot defaultOpen onOpenChange={saveAndExit}>
<ModalOverlay />
<ModalContent
onPointerDownOutside={(event) => {
event.preventDefault()
}}
css={{ overflow: 'auto', p: '0' }}
>
<VStack distribution="start" css={{ p: '0' }}>
<HStack
distribution="between"
alignment="center"
css={{ width: '100%' }}
>
<StyledText style="modalHeadline" css={{ p: '16px' }}>
Edit Labels
</StyledText>
<Button
css={{ pt: '16px', pr: '16px' }}
style="ghost"
onClick={() => {
props.onOpenChange(false)
}}
>
<CrossIcon
size={20}
strokeColor={theme.colors.grayText.toString()}
/>
</Button>
</HStack>
{labels &&
labels.map((label) => (
<HStack
key={label.id}
css={{ height: '50px', verticalAlign: 'middle' }}
onClick={() => {
// if (selectedLabels.includes(label.id)) {
// setSelectedLabels(
// selectedLabels.filter((id) => id !== label.id)
// )
// } else {
// setSelectedLabels([...selectedLabels, label.id])
// }
}}
>
<LabelChip color={label.color} text={label.name} />
<input
type="checkbox"
value={label.id}
onChange={handleChange}
checked={selectedLabels.includes(label)}
/>
</HStack>
))}
<HStack css={{ width: '100%', mb: '16px' }} alignment="center">
<Button style="ctaDarkYellow" onClick={saveAndExit}>
Save
</Button>
</HStack>
</VStack>
</ModalContent>
</ModalRoot>
)
}

View file

@ -25,9 +25,9 @@ type HighlightsLayerProps = {
articleAuthor: string
isAppleAppEmbed: boolean
highlightBarDisabled: boolean
showNotesSidebar: boolean
showHighlightsModal: boolean
highlightsBaseURL: string
setShowNotesSidebar: React.Dispatch<React.SetStateAction<boolean>>
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
articleMutations: ArticleMutations
}
@ -466,11 +466,11 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
)
}
if (props.showNotesSidebar) {
if (props.showHighlightsModal) {
return (
<HighlightsModal
highlights={highlights}
onOpenChange={() => props.setShowNotesSidebar(false)}
onOpenChange={() => props.setShowHighlightsModal(false)}
deleteHighlightAction={(highlightId: string) => {
removeHighlightCallback(highlightId)
}}

View file

@ -30,6 +30,7 @@ export function HighlightsModal(props: HighlightsModalProps): JSX.Element {
<ModalContent
onPointerDownOutside={(event) => {
event.preventDefault()
props.onOpenChange(false)
}}
css={{ overflow: 'auto', p: '0' }}
>

View file

@ -0,0 +1,130 @@
import { HStack, VStack, SpanBox } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { styled, theme } from '../../tokens/stitches.config'
import { useEffect, useState } from 'react'
import { AlignCenterHorizontalSimple, ArrowsInLineHorizontal, ArrowsOutLineHorizontal, Minus, Pen, Plus, Trash, X } from 'phosphor-react'
import { AIcon } from '../../elements/images/AIcon'
import { TickedRangeSlider } from '../../elements/TickedRangeSlider'
import { showSuccessToast } from '../../../lib/toastHelpers'
type ReaderSettingsProps = {
marginWidth: number
lineHeight: number
articleActionHandler: (action: string, arg?: number) => void
}
const VerticalDivider = styled(SpanBox, {
width: '1px',
height: '100%',
background: `${theme.colors.grayLine.toString()}`,
})
export function ReaderSettingsControl(props: ReaderSettingsProps): JSX.Element {
const [lineHeight, setLineHeight] = useState(props.lineHeight)
const [marginWidth, setMarginWidth] = useState(props.marginWidth)
useEffect(() => {
setLineHeight(props.lineHeight)
setMarginWidth(props.marginWidth)
}, [props.lineHeight, props.marginWidth, setLineHeight, setMarginWidth])
return (
<VStack>
<HStack
alignment='center'
css={{
width: '100%',
height: '70px',
borderBottom: `1px solid ${theme.colors.grayLine.toString()}`,
}}
>
<Button style='plainIcon' onClick={() => props.articleActionHandler('decrementFontSize')}>
<AIcon size={28} color={theme.colors.readerFont.toString()} />
<Minus size={28} color={theme.colors.readerFont.toString()}/>
</Button>
<VerticalDivider />
<Button style='plainIcon' onClick={() => props.articleActionHandler('incrementFontSize')}>
<AIcon size={44} color={theme.colors.readerFont.toString()} />
<Plus size={28} color={theme.colors.readerFont.toString()} />
</Button>
</HStack>
<VStack
css={{
p: '0px',
m: '0px',
pb: '14px',
width: '100%',
height: '100%',
'@mdDown': {
display: 'none',
},
}}
>
<StyledText color={theme.colors.readerFontTransparent.toString()} css={{ pl: '8px', m: '0px', pt: '14px' }}>Margin:</StyledText>
<HStack distribution='between' css={{ gap: '16px', alignItems: 'center', alignSelf: 'center' }}>
<Button style='plainIcon' css={{ pt: '10px', px: '4px' }} onClick={() => {
const newMarginWith = Math.max(marginWidth - 45, 200)
setMarginWidth(newMarginWith)
props.articleActionHandler('setMarginWidth', newMarginWith)
}}>
<ArrowsOutLineHorizontal size={24} color={theme.colors.readerFont.toString()} />
</Button>
<TickedRangeSlider min={200} max={560} step={45} value={marginWidth} onChange={(value) => {
setMarginWidth(value)
props.articleActionHandler('setMarginWidth', value)
}} />
<Button style='plainIcon' css={{ pt: '10px', px: '4px' }} onClick={() => {
const newMarginWith = Math.min(marginWidth + 45, 560)
setMarginWidth(newMarginWith)
props.articleActionHandler('setMarginWidth', newMarginWith)
}}>
<ArrowsInLineHorizontal size={24} color={theme.colors.readerFont.toString()} />
</Button>
</HStack>
</VStack>
<VStack css={{
p: '0px',
m: '0px',
pb: '12px',
width: '100%',
height: '100%',
}}>
<StyledText color={theme.colors.readerFontTransparent.toString()} css={{ pl: '12px', m: '0px', pt: '14px' }}>Line Spacing:</StyledText>
<HStack distribution='between' css={{ gap: '16px', alignItems: 'center', alignSelf: 'center' }}>
<Button style='plainIcon' css={{ pt: '10px', px: '4px' }} onClick={() => {
const newLineHeight = Math.max(lineHeight - 25, 100)
setLineHeight(newLineHeight)
props.articleActionHandler('setLineHeight', newLineHeight)
}}>
<AlignCenterHorizontalSimple size={25} color={theme.colors.readerFont.toString()} />
</Button>
<TickedRangeSlider min={100} max={300} step={25} value={lineHeight} onChange={(value) => {
setLineHeight(value)
props.articleActionHandler('setLineHeight', value)
}} />
<Button style='plainIcon' css={{ pt: '10px', px: '4px' }} onClick={() => {
const newLineHeight = Math.min(lineHeight + 25, 300)
setLineHeight(newLineHeight)
props.articleActionHandler('setLineHeight', newLineHeight)
}}>
<AlignCenterHorizontalSimple size={25} color={theme.colors.readerFont.toString()} />
</Button>
</HStack>
<Button style='plainIcon' css={{ justifyContent: 'center', textDecoration: 'underline', display: 'flex', gap: '4px', width: '100%', fontSize: '12px', p: '8px', pb: '0px', pt: '16px', height: '42px', alignItems: 'center' }}
onClick={() => {
setMarginWidth(290)
setLineHeight(150)
props.articleActionHandler('resetReaderSettings')
showSuccessToast('Display settings reset', { position: 'bottom-right' })
}}
>
Reset to default
</Button>
</VStack>
</VStack>
)
}

View file

@ -0,0 +1,345 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react'
import Link from 'next/link'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { CrossIcon } from '../../elements/images/CrossIcon'
import { styled, theme } from '../../tokens/stitches.config'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { Check, Circle, PencilSimple, Plus } from 'phosphor-react'
import { isTouchScreenDevice } from '../../../lib/deviceType'
import { setLabelsMutation } from '../../../lib/networking/mutations/setLabelsMutation'
import { createLabelMutation } from '../../../lib/networking/mutations/createLabelMutation'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { randomLabelColorHex } from '../../../utils/settings-page/labels/labelColorObjects'
import { useRouter } from 'next/router'
type SetLabelsControlProps = {
article: ArticleAttributes
articleActionHandler: (action: string, arg?: unknown) => void
}
type HeaderProps = {
filterText: string
focused: boolean
resetFocusedIndex: () => void
setFilterText: (text: string) => void
}
const FormInput = styled('input', {
width: '100%',
fontSize: '16px',
fontFamily: 'inter',
fontWeight: 'normal',
lineHeight: '1.8',
color: '$grayTextContrast',
'&:focus': {
outline: 'none',
},
})
const StyledLabel = styled('label', {
display: 'flex',
justifyContent: 'flex-start',
})
function Header(props: HeaderProps): JSX.Element {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!isTouchScreenDevice() && props.focused && inputRef.current) {
inputRef.current.focus()
}
}, [props.focused])
return (
<VStack css={{ width: '100%', my: '0px', borderBottom: '1px solid $grayBorder'}}>
<Box css={{
width: '100%',
my: '14px',
px: '14px',
}}>
<FormInput
ref={inputRef}
type="text"
tabIndex={props.focused && !isTouchScreenDevice() ? 0 : -1}
autoFocus={!isTouchScreenDevice()}
value={props.filterText}
placeholder="Filter for label"
onChange={(event) => {
props.setFilterText(event.target.value)
}}
onFocus={() => {
props.resetFocusedIndex()
}}
css={{
border: '1px solid $grayBorder',
borderRadius: '8px',
width: '100%',
bg: 'transparent',
fontSize: '16px',
textIndent: '8px',
marginBottom: '2px',
color: '$grayTextContrast',
'&:focus': {
outline: 'none',
boxShadow: '0px 0px 2px 2px rgba(255, 234, 159, 0.56)',
},
}}
/>
</Box>
</VStack>)
}
type LabelListItemProps = {
label: Label
focused: boolean
selected: boolean
toggleLabel: (label: Label) => void
}
function LabelListItem(props: LabelListItemProps): JSX.Element {
const ref = useRef<HTMLLabelElement>(null)
const { label, focused, selected } = props
useEffect(() => {
if (props.focused && ref.current) {
ref.current.focus()
}
}, [props.focused])
return (
<StyledLabel
ref={ref}
css={{
width: '100%',
height: '42px',
borderBottom: '1px solid $grayBorder',
bg: props.focused ? '$grayBgActive' : 'unset',
}}
tabIndex={props.focused ? 0 : -1}
onClick={(event) => {
event.preventDefault()
props.toggleLabel(label)
ref.current?.blur()
}}
>
<input autoFocus={focused} hidden={true} type="checkbox" checked={selected} readOnly />
<Box css={{ pl: '10px', width: '32px', display: 'flex', alignItems: 'center' }}>
{selected && <Check size={15} color={theme.colors.grayText.toString()} weight='bold' />}
</Box>
<Box css={{ width: '30px', height: '100%', display: 'flex', alignItems: 'center' }}>
<Circle width={22} height={22} color={label.color} weight='fill' />
</Box>
<Box css={{ overflow: 'clip', height: '100%', display: 'flex', alignItems: 'center' }}>
<StyledText style="caption">{label.name}</StyledText>
</Box>
<Box css={{ pl: '10px', width: '40px', marginLeft: 'auto', display: 'flex', alignItems: 'center' }}>
{selected && <CrossIcon
size={14}
strokeColor={theme.colors.grayText.toString()}
/>}
</Box>
</StyledLabel>
)
}
type FooterProps = {
focused: boolean
}
function Footer(props: FooterProps): JSX.Element {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (props.focused && ref.current) {
ref.current.focus()
}
}, [props.focused])
return (
<HStack
ref={ref}
distribution="start" alignment="center"
css={{
width: '100%', height: '42px',
bg: props.focused ? '$grayBgActive' : 'unset',
color: theme.colors.grayText.toString(),
'a:link': {
textDecoration: 'none',
},
'a:visited': {
color: theme.colors.grayText.toString(),
},
}}
>
<SpanBox css={{ display: 'flex', fontSize: '12px', padding: '33px', gap: '8px' }}>
<PencilSimple size={18} color={theme.colors.grayText.toString()} />
<Link href="/settings/labels">Edit labels</Link>
</SpanBox>
</HStack>
)
}
export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
const router = useRouter()
const [filterText, setFilterText] = useState('')
const { labels, revalidate } = useGetLabelsQuery()
const [selectedLabels, setSelectedLabels] = useState<Label[]>(props.article.labels || [])
useEffect(() => {
setFocusedIndex(undefined)
}, [filterText])
const isSelected = useCallback((label: Label): boolean => {
return selectedLabels.some((other) => {
return other.id === label.id
})
}, [selectedLabels])
const toggleLabel = useCallback(async (label: Label) => {
let newSelectedLabels = [...selectedLabels]
if (isSelected(label)) {
newSelectedLabels = selectedLabels.filter((other) => {
return other.id !== label.id
})
} else {
newSelectedLabels = [...selectedLabels, label]
}
setSelectedLabels(newSelectedLabels)
const result = await setLabelsMutation(
props.article.linkId,
newSelectedLabels.map((label) => label.id)
)
props.article.labels = result
props.articleActionHandler('refreshLabels', result)
revalidate()
}, [isSelected, selectedLabels, setSelectedLabels])
const filteredLabels = useMemo(() => {
if (!labels) {
return []
}
return labels.filter((label) => {
return label.name.toLowerCase().includes(filterText.toLowerCase())
})
}, [labels, filterText])
// Move focus through the labels list on tab or arrow up/down keys
const [focusedIndex, setFocusedIndex] = useState<number | undefined>(undefined)
const handleKeyDown = useCallback(async (event: React.KeyboardEvent<HTMLInputElement>) => {
const maxIndex = filteredLabels.length + 1
if (event.key === 'ArrowUp') {
event.preventDefault()
let newIndex = focusedIndex
if (focusedIndex) {
newIndex = Math.max(0, focusedIndex - 1)
} else {
newIndex = undefined
}
// If the `Create New label` button isn't visible we skip it
// when navigating with the arrow keys
if (focusedIndex === maxIndex && !filterText) {
newIndex = maxIndex - 2
}
setFocusedIndex(newIndex)
}
if (event.key === 'ArrowDown' || event.key === 'Tab') {
event.preventDefault()
let newIndex = focusedIndex
if (focusedIndex === undefined) {
newIndex = 0
} else {
newIndex = Math.min(maxIndex, focusedIndex + 1)
}
// If the `Create New label` button isn't visible we skip it
// when navigating with the arrow keys
if (focusedIndex === maxIndex - 2 && !filterText) {
newIndex = maxIndex
}
setFocusedIndex(newIndex)
}
if (event.key === 'Enter') {
event.preventDefault()
if (focusedIndex === maxIndex) {
router.push('/settings/labels')
return
}
if (focusedIndex === maxIndex - 1) {
await createLabelFromFilterText()
return
}
if (focusedIndex !== undefined) {
const label = filteredLabels[focusedIndex]
if (label) {
toggleLabel(label)
}
}
}
}, [filterText, filteredLabels, focusedIndex, isSelected, selectedLabels, setSelectedLabels])
const createLabelFromFilterText = useCallback(async () => {
const label = await createLabelMutation(filterText, randomLabelColorHex(), '')
if (label) {
showSuccessToast(`Created label ${label.name}`, { position: 'bottom-right' })
toggleLabel(label)
} else {
showErrorToast('Failed to create label', { position: 'bottom-right' })
}
}, [filterText, selectedLabels, setSelectedLabels, toggleLabel])
return (
<VStack
distribution="start"
onKeyDown={handleKeyDown}
css={{
p: '0',
width: '100%',
}}>
<Header
focused={focusedIndex === undefined}
resetFocusedIndex={() => setFocusedIndex(undefined)}
setFilterText={setFilterText} filterText={filterText}
/>
<VStack
distribution="start"
alignment="start"
css={{ flexGrow: '1', overflow: 'scroll', width: '100%', height: '100%', maxHeight: '294px'
}}>
{filteredLabels &&
filteredLabels.map((label, idx) => (
<LabelListItem
key={label.id}
label={label}
focused={idx === focusedIndex}
selected={isSelected(label)}
toggleLabel={toggleLabel}
/>
))}
</VStack>
{filterText && (
<Button style='modalOption' css={{
pl: '26px',
color: theme.colors.grayText.toString(),
height: '42px',
borderBottom: '1px solid $grayBorder',
bg: focusedIndex === filteredLabels.length ? '$grayBgActive' : 'unset',
}}
onClick={createLabelFromFilterText}
>
<HStack alignment='center' distribution='start' css={{ gap: '8px' }}>
<Plus size={18} color={theme.colors.grayText.toString()} />
<SpanBox css={{ fontSize: '12px' }}>{`Create new label "${filterText}"`}</SpanBox>
</HStack>
</Button>
)}
<Footer focused={focusedIndex === (filteredLabels.length + 1)} />
</VStack>
)
}

View file

@ -0,0 +1,56 @@
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
import { Button } from '../../elements/Button'
import { CrossIcon } from '../../elements/images/CrossIcon'
import { HStack, VStack } from '../../elements/LayoutPrimitives'
import {
ModalRoot,
ModalOverlay,
ModalContent,
} from '../../elements/ModalPrimitives'
import { StyledText } from '../../elements/StyledText'
import { theme } from '../../tokens/stitches.config'
import { SetLabelsControl } from './SetLabelsControl'
type SetLabelsModalProps = {
article: ArticleAttributes
onOpenChange: (open: boolean) => void
articleActionHandler: (action: string, arg?: unknown) => void
}
export function SetLabelsModal(props: SetLabelsModalProps): JSX.Element {
return (
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
<ModalOverlay />
<ModalContent
css={{ border: '1px solid $grayBorder' }}
onPointerDownOutside={(event) => {
event.preventDefault()
props.onOpenChange(false)
}}
>
<VStack css={{ width: '100%' }}>
<HStack
distribution="between"
alignment="center"
css={{ width: '100%' }}
>
<StyledText style="modalHeadline" css={{ pl: '16px' }}>Labels</StyledText>
<Button
css={{ pt: '16px', pr: '16px' }}
style="ghost"
onClick={() => {
props.onOpenChange(false)
}}
>
<CrossIcon
size={14}
strokeColor={theme.colors.grayText.toString()}
/>
</Button>
</HStack>
<SetLabelsControl {...props} />
</VStack>
</ModalContent>
</ModalRoot>
)
}

View file

@ -102,8 +102,7 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
borderStyles: {},
shadows: {
panelShadow: '0px 4px 18px rgba(120, 123, 134, 0.12)',
cardBoxShadow:
'0px 0px 9px -2px rgba(32, 31, 29, 0.09), 0px 7px 12px rgba(32, 31, 29, 0.07)',
cardBoxShadow: '0px 0px 4px 0px rgba(0, 0, 0, 0.1)',
},
zIndices: {},
transitions: {},
@ -156,6 +155,7 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
xsmDown: '(max-width: 375px)',
smDown: '(max-width: 575px)',
mdDown: '(max-width: 768px)',
lgDown: '(max-width: 992px)',
sm: '(min-width: 576px)',
md: '(min-width: 768px)',
lg: '(min-width: 992px)',
@ -199,7 +199,7 @@ const darkThemeSpec = {
},
shadows: {
cardBoxShadow:
'0px 0px 9px -2px rgba(32, 31, 29, 0.09), 0px 7px 12px rgba(32, 31, 29, 0.07)',
'0px 0px 9px -2px rgba(255, 255, 255, 0.09), 0px 7px 12px rgba(255, 255, 255, 0.07)',
},
}

View file

@ -207,7 +207,9 @@ type ArticleKeyboardAction =
| 'openOriginalArticle'
| 'incrementFontSize'
| 'decrementFontSize'
| 'editLabels'
| 'incrementMarginWidth'
| 'decrementMarginWidth'
| 'setLabels'
export function articleKeyboardCommands(
router: NextRouter | undefined,
@ -238,11 +240,23 @@ export function articleKeyboardCommands(
shortcutKeyDescription: '-',
callback: () => actionHandler('decrementFontSize'),
},
{
shortcutKeys: [']'],
actionDescription: 'Increase margin width',
shortcutKeyDescription: ']',
callback: () => actionHandler('incrementMarginWidth'),
},
{
shortcutKeys: ['['],
actionDescription: 'Decrease margin width',
shortcutKeyDescription: '[',
callback: () => actionHandler('decrementMarginWidth'),
},
{
shortcutKeys: ['l'],
actionDescription: 'Edit labels',
shortcutKeyDescription: 'l',
callback: () => actionHandler('editLabels'),
callback: () => actionHandler('setLabels'),
},
]
}

View file

@ -1,5 +1,14 @@
import { gql } from 'graphql-request'
export type LabelColor =
| '#FF5D99'
| '#7CFF7B'
| '#FFD234'
| '#7BE4FF'
| '#CE88EF'
| '#EF8C43'
| 'custom color';
export const labelFragment = gql`
fragment LabelFields on Label {
id
@ -13,7 +22,7 @@ export const labelFragment = gql`
export type Label = {
id: string
name: string
color: string
color: LabelColor
description?: string
createdAt: string
}
createdAt: Date
}

View file

@ -1,6 +1,16 @@
import { gql } from 'graphql-request'
import { Label } from '../fragments/labelFragment'
import { gqlFetcher } from '../networkHelpers'
type CreateLabelResult = {
createLabel: CreateLabel
errorCodes?: unknown[]
}
type CreateLabel = {
label: Label
}
export async function createLabelMutation(
name: string,
color: string,
@ -32,9 +42,9 @@ export async function createLabelMutation(
`
try {
const data = await gqlFetcher(mutation)
const data = await gqlFetcher(mutation) as CreateLabelResult
console.log('created label', data)
return data
return data.errorCodes ? undefined : data.createLabel.label
} catch (error) {
console.log('createLabelMutation error', error)
return undefined

View file

@ -1,16 +1,26 @@
import { gql } from 'graphql-request'
import { Label, labelFragment } from '../fragments/labelFragment'
import { gqlFetcher } from '../networkHelpers'
type SetLabelsResult = {
setLabels: SetLabels
errorCodes?: unknown[]
}
type SetLabels = {
labels: Label[]
}
export async function setLabelsMutation(
pageId: string,
labelIds: string[]
): Promise<any | undefined> {
): Promise<Label[] | undefined> {
const mutation = gql`
mutation SetLabels($input: SetLabelsInput!) {
setLabels(input: $input) {
... on SetLabelsSuccess {
labels {
id
...LabelFields
}
}
... on SetLabelsError {
@ -18,12 +28,12 @@ export async function setLabelsMutation(
}
}
}
${labelFragment}
`
try {
const data = await gqlFetcher(mutation, { input: { pageId, labelIds } })
console.log(data)
return data
const data = await gqlFetcher(mutation, { input: { pageId, labelIds } }) as SetLabelsResult
return data.errorCodes ? undefined : data.setLabels.labels
} catch (error) {
console.log('SetLabelsOutput error', error)
return undefined

View file

@ -1,10 +1,11 @@
import { gql } from 'graphql-request'
import useSWRImmutable from 'swr'
import useSWRImmutable, { Cache } from 'swr'
import { makeGqlFetcher, RequestContext, ssrFetcher } from '../networkHelpers'
import { articleFragment, ContentReader } from '../fragments/articleFragment'
import { Highlight, highlightFragment } from '../fragments/highlightFragment'
import { ScopedMutator } from 'swr/dist/types'
import { Label, labelFragment } from '../fragments/labelFragment'
import { LibraryItems } from './useGetLibraryItemsQuery'
type ArticleQueryInput = {
username?: string
@ -77,16 +78,7 @@ const query = gql`
${highlightFragment}
${labelFragment}
`
export const cacheArticle = (
mutate: ScopedMutator,
username: string,
article: ArticleAttributes,
includeFriendsHighlights = false
) => {
mutate([query, username, article.slug, includeFriendsHighlights], {
article: { article: { ...article, cached: true } },
})
}
export function useGetArticleQuery({
username,
@ -99,7 +91,7 @@ export function useGetArticleQuery({
includeFriendsHighlights,
}
const { data, error } = useSWRImmutable(
const { data, error, mutate } = useSWRImmutable(
slug ? [query, username, slug, includeFriendsHighlights] : null,
makeGqlFetcher(variables)
)
@ -133,3 +125,46 @@ export async function articleQuery(
return Promise.reject()
}
export const cacheArticle = (
mutate: ScopedMutator,
username: string,
article: ArticleAttributes,
includeFriendsHighlights = false
) => {
mutate([query, username, article.slug, includeFriendsHighlights], {
article: { article: { ...article, cached: true } },
})
}
export const removeItemFromCache = (
cache: Cache<unknown>,
mutate: ScopedMutator,
itemId: string,
) => {
try {
const mappedCache = cache as Map<string, unknown>
mappedCache.forEach((value: any, key) => {
if (typeof value == 'object' && 'articles' in value) {
const articles = value.articles as LibraryItems
const idx = articles.edges.findIndex((edge) => edge.node.id == itemId)
if (idx > -1) {
value.articles.edges.splice(idx, 1)
mutate(key, value, false)
}
}
})
mappedCache.forEach((value: any, key) => {
if (Array.isArray(value)) {
const idx = value.findIndex((item) => 'articles' in item)
if (idx > -1) {
mutate(key, value, false)
}
}
})
} catch (error) {
console.log('error removing item from cache', error)
}
}

View file

@ -1,7 +1,6 @@
import { gql } from 'graphql-request'
import useSWR from 'swr'
import { LabelColor } from '../../../utils/settings-page/labels/types';
import { labelFragment } from '../fragments/labelFragment'
import { Label, labelFragment } from '../fragments/labelFragment'
import { publicGqlFetcher } from '../networkHelpers'
type LabelsQueryResponse = {
@ -18,14 +17,6 @@ type LabelsData = {
labels?: unknown
}
export type Label = {
id: string
name: string
color: LabelColor
description?: string
createdAt: Date
}
export function useGetLabelsQuery(): LabelsQueryResponse {
const query = gql`
query GetLabels {

View file

@ -6,7 +6,7 @@ import { articleFragment } from '../fragments/articleFragment'
import { setLinkArchivedMutation } from '../mutations/setLinkArchivedMutation'
import { deleteLinkMutation } from '../mutations/deleteLinkMutation'
import { articleReadingProgressMutation } from '../mutations/articleReadingProgressMutation'
import { Label } from './../fragments/labelFragment'
import { Label, labelFragment } from './../fragments/labelFragment'
import { showErrorToast, showSuccessToast } from '../../toastHelpers'
export type LibraryItemsQueryInput = {
@ -40,7 +40,7 @@ export type LibraryItemsData = {
articles: LibraryItems
}
type LibraryItems = {
export type LibraryItems = {
edges: LibraryItem[]
pageInfo: PageInfo
errorCodes?: string[]
@ -67,6 +67,30 @@ export type PageInfo = {
totalCount: number
}
const libraryItemFragment = gql`
fragment ArticleFields on Article {
id
title
url
author
image
savedAt
createdAt
publishedAt
contentReader
originalArticleUrl
readingProgressPercent
readingProgressAnchorIndex
slug
isArchived
description
linkId
labels {
...LabelFields
}
}
`
export function useGetLibraryItemsQuery({
limit,
sortDescending,
@ -109,7 +133,8 @@ export function useGetLibraryItemsQuery({
}
}
}
${articleFragment}
${libraryItemFragment}
${labelFragment}
`
const variables = {

View file

@ -51,7 +51,7 @@ export function useGetNewsletterEmailsQuery(): NewsletterEmailsQueryResponse {
isValidating,
emailAddresses,
revalidate: () => {
mutate()
mutate(undefined, true)
}
}
}

View file

@ -25,6 +25,7 @@ export type UserPreferences = {
fontSize: number
fontFamily: string
margin: number
lineHeight?: number
libraryLayoutType: string
librarySortOrder?: SortOrder
}

View file

@ -1,12 +1,12 @@
import { PrimaryLayout } from '../../../components/templates/PrimaryLayout'
import { LoadingView } from '../../../components/patterns/LoadingView'
import { useGetViewerQuery } from '../../../lib/networking/queries/useGetViewerQuery'
import { useGetArticleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
import { removeItemFromCache, useGetArticleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
import { useRouter } from 'next/router'
import { VStack } from './../../../components/elements/LayoutPrimitives'
import { ArticleContainer } from './../../../components/templates/article/ArticleContainer'
import { PdfArticleContainerProps } from './../../../components/templates/article/PdfArticleContainer'
import { useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useKeyboardShortcuts } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts'
import { articleKeyboardCommands, navigationCommands } from '../../../lib/keyboardShortcuts/navigationShortcuts'
import dynamic from 'next/dynamic'
@ -20,9 +20,16 @@ import { articleReadingProgressMutation } from '../../../lib/networking/mutation
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
import { userPersonalizationMutation } from '../../../lib/networking/mutations/userPersonalizationMutation'
import Script from 'next/script'
import { EditLabelsModal } from '../../../components/templates/article/EditLabelsModal'
import { theme } from '../../../components/tokens/stitches.config'
import { ArticleActionsMenu } from '../../../components/templates/article/ArticleActionsMenu'
import { setLinkArchivedMutation } from '../../../lib/networking/mutations/setLinkArchivedMutation'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { isVipUser } from '../../../lib/featureFlag'
import { useSWRConfig } from 'swr'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { SetLabelsModal } from '../../../components/templates/article/SetLabelsModal'
import { DisplaySettingsModal } from '../../../components/templates/article/DisplaySettingsModal'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
const PdfArticleContainerNoSSR = dynamic<PdfArticleContainerProps>(
() => import('./../../../components/templates/article/PdfArticleContainer'),
@ -31,49 +38,121 @@ const PdfArticleContainerNoSSR = dynamic<PdfArticleContainerProps>(
export default function Home(): JSX.Element {
const router = useRouter()
const { cache, mutate } = useSWRConfig()
const scrollRef = useRef<HTMLDivElement | null>(null)
const { slug } = router.query
const [showLabelsModal, setShowLabelsModal] = useState(false)
const [showHighlightsModal, setShowHighlightsModal] = useState(false)
// Populate data cache
const { viewerData } = useGetViewerQuery()
const { preferencesData } = useGetUserPreferences()
const [fontSize, setFontSize] = useState(preferencesData?.fontSize ?? 20)
const [lineHeight, setLineHeight] = usePersistedState({ key: 'lineHeight', initialValue: 150 })
const [marginWidth, setMarginWidth] = usePersistedState({ key: 'marginWidth', initialValue: 200 })
const [showSetLabelsModal, setShowSetLabelsModal] = useState(false)
const [showEditDisplaySettingsModal, setShowEditDisplaySettingsModal] = useState(false)
const { articleData } = useGetArticleQuery({
username: router.query.username as string,
slug: router.query.slug as string,
includeFriendsHighlights: false,
})
const { preferencesData } = useGetUserPreferences()
const article = articleData?.article.article
const [fontSize, setFontSize] = useState(preferencesData?.fontSize ?? 20)
const [labels, setLabels] = useState<Label[]>([])
useEffect(() => {
if (article?.labels) {
setLabels(article.labels)
}
}, [article])
useKeyboardShortcuts(navigationCommands(router))
const updateFontSize = async (newFontSize: number) => {
setFontSize(newFontSize)
await userPersonalizationMutation({ fontSize: newFontSize })
}
const actionHandler = useCallback(async(action: string, arg?: unknown) => {
const updateFontSize = async(newFontSize: number) => {
setFontSize(newFontSize)
await userPersonalizationMutation({ fontSize: newFontSize })
}
switch (action) {
case 'archive':
if (article) {
removeItemFromCache(cache, mutate, article.id)
await setLinkArchivedMutation({
linkId: article.id,
archived: true,
}).then((res) => {
if (res) {
showSuccessToast('Link archived', { position: 'bottom-right' })
} else {
// todo: revalidate or put back in cache?
showErrorToast('Error archiving link', { position: 'bottom-right' })
}
})
router.push(`/home`)
}
break
case 'openOriginalArticle':
const url = article?.url
if (url) {
window.open(url, '_blank')
}
break
case 'refreshLabels':
setLabels(arg as Label[])
break
case 'showHighlights':
setShowHighlightsModal(true)
break
case 'incrementFontSize':
await updateFontSize(Math.min(fontSize + 2, 28))
break
case 'decrementFontSize':
await updateFontSize(Math.max(fontSize - 2, 10))
break
case 'setMarginWidth': {
const value = Number(arg)
if (value >= 200 && value <= 560) {
setMarginWidth(value)
}
break
}
case 'incrementMarginWidth':
setMarginWidth(Math.min(marginWidth + 45, 560))
break
case 'decrementMarginWidth':
setMarginWidth(Math.max(marginWidth - 45, 200))
break
case 'setLineHeight': {
const value = Number(arg)
if (value >= 100 && value <= 300) {
setLineHeight(arg as number)
}
break
}
case 'editDisplaySettings': {
setShowEditDisplaySettingsModal(true)
break
}
case 'setLabels': {
setShowSetLabelsModal(true)
break
}
case 'resetReaderSettings': {
updateFontSize(20)
setMarginWidth(290)
setLineHeight(150)
break
}
}
}, [article, cache, mutate, router,
fontSize, setFontSize, lineHeight,
setLineHeight, marginWidth, setMarginWidth])
useKeyboardShortcuts(
articleKeyboardCommands(router, async (action) => {
switch (action) {
case 'openOriginalArticle':
const url = article?.url
if (url) {
window.open(url, '_blank')
}
break
case 'incrementFontSize':
await updateFontSize(Math.min(fontSize + 2, 28))
break
case 'decrementFontSize':
await updateFontSize(Math.max(fontSize - 2, 10))
break
case 'editLabels':
if (viewerData?.me && isVipUser(viewerData?.me)) {
setShowLabelsModal(true)
}
break
}
actionHandler(action)
})
)
@ -82,7 +161,15 @@ export default function Home(): JSX.Element {
<PrimaryLayout
pageTestId="home-page-tag"
scrollElementRef={scrollRef}
displayFontStepper={true}
headerToolbarControl={
<ArticleActionsMenu
article={article}
layout='horizontal'
lineHeight={lineHeight}
marginWidth={marginWidth}
articleActionHandler={actionHandler}
/>
}
pageMetaDataProps={{
title: article.title,
path: router.pathname,
@ -97,6 +184,28 @@ export default function Home(): JSX.Element {
/>
<Toaster />
<VStack distribution="between" alignment="center" css={{
position: 'fixed',
flexDirection: 'row-reverse',
top: '-120px',
left: 8,
height: '100%',
width: '48px',
'@lgDown': {
display: 'none',
},
}}
>
{article.contentReader !== 'PDF' ? (
<ArticleActionsMenu
article={article}
layout='vertical'
lineHeight={lineHeight}
marginWidth={marginWidth}
articleActionHandler={actionHandler}
/>
) : null}
</VStack>
{article.contentReader == 'PDF' ? (
<PdfArticleContainerNoSSR
article={article}
@ -104,10 +213,15 @@ export default function Home(): JSX.Element {
/>
) : (
<VStack
alignment="center"
distribution="center"
ref={scrollRef}
className="disable-webkit-callout"
alignment="center"
distribution="center"
ref={scrollRef}
className="disable-webkit-callout"
css={{
'@smDown': {
background: theme.colors.grayBg.toString(),
}
}}
>
<ArticleContainer
article={article}
@ -116,6 +230,11 @@ export default function Home(): JSX.Element {
highlightBarDisabled={false}
highlightsBaseURL={`${webBaseURL}/${viewerData.me?.profile?.username}/${slug}/highlights`}
fontSize={fontSize}
margin={marginWidth}
lineHeight={lineHeight}
labels={labels}
showHighlightsModal={showHighlightsModal}
setShowHighlightsModal={setShowHighlightsModal}
articleMutations={{
createHighlightMutation,
deleteHighlightMutation,
@ -124,20 +243,25 @@ export default function Home(): JSX.Element {
articleReadingProgressMutation,
}}
/>
{/* {showLabelsModal && (
<EditLabelsModal
labels={article.labels || []}
article={article}
onOpenChange={() => {
setShowLabelsModal(false)
}}
setLabels={(labels: Label[]) => {
// setLabels(labels)
}}
/>
)} */}
</VStack>
)}
</VStack>
)}
{showSetLabelsModal && (
<SetLabelsModal
article={article}
articleActionHandler={actionHandler}
onOpenChange={() => setShowSetLabelsModal(false)}
/>
)}
{showEditDisplaySettingsModal && (
<DisplaySettingsModal
lineHeight={lineHeight}
marginWidth={marginWidth}
articleActionHandler={actionHandler}
onOpenChange={() => setShowEditDisplaySettingsModal(false)}
/>
)}
</PrimaryLayout>
)
}

View file

@ -63,6 +63,7 @@ function AppArticleEmbedContent(
props: AppArticleEmbedContentProps
): JSX.Element {
const scrollRef = useRef<HTMLDivElement | null>(null)
const [showHighlightsModal, setShowHighlightsModal] = useState(false)
const { articleData } = useGetArticleQuery({
username: props.username,
@ -99,6 +100,9 @@ function AppArticleEmbedContent(
fontSize={props.fontSize}
margin={props.margin}
fontFamily={props.fontFamily}
labels={[]}
showHighlightsModal={showHighlightsModal}
setShowHighlightsModal={setShowHighlightsModal}
articleMutations={{
createHighlightMutation,
deleteHighlightMutation,

View file

@ -0,0 +1,89 @@
/* eslint-disable @next/next/no-img-element */
import { Box, HStack } from '../../components/elements/LayoutPrimitives'
import { PrimaryLayout } from '../../components/templates/PrimaryLayout'
import Link from 'next/link'
export default function Labels(): JSX.Element {
return (
<PrimaryLayout
pageMetaDataProps={{
title: 'Labels',
path: '/help/labels',
}}
pageTestId="help-labels-tag"
>
<Box
css={{
m: '42px',
maxWidth: '640px',
color: '$grayText',
img: {
maxWidth: '85%',
},
'@smDown': {
m: '16px',
maxWidth: '85%',
alignSelf: 'center',
},
}}
>
<h1>Organize your Omnivore library with labels</h1>
<hr />
<h2>Introduction</h2>
<p>
Labels allow you to group and search for content in Omnivore. A saved page
can have multiple labels and search results can be filtered by label.
</p>
<h2>Adding labels to a page on iOS</h2>
<p>
On iOS you add and remove labels from a page using the Assign Labels modal.
</p>
<p>
You can open the Assign Labels modal from the home view or the reader view.
In the home view long press on an item and choose Edit Labels from the dropdowm
menu. In the reader view use the top right menu button.
</p>
<h2>Adding labels to a page on the web</h2>
<p>
On the web you add and remove labels from a page using the Assign Labels dropdown
or modal depending on your screen size. For larger monitors you will see the Labels
button on the left hand side of the reader view. For smaller monitors you will see
the labels dropdown at the top of your screen.
</p>
<p>
You can also use keyboard commands to open the assign labels modal. On the reader
view tap the <code>l</code> key. Once open you can use the up/down arrow keys, or the
tab key to navigate the available labels, and the Enter key to toggle a label.
</p>
<h2>Searching by label on iOS</h2>
<p>
On iOS you can use the Labels search chip to search for labels. This will open a modal
allowing you to assign multiple labels to your search. This will become an <code>OR </code>
search, meaning if you add multiple labels to your search, pages that have any of the
labels will be returned.
</p>
<h2>Searching by label with Advanced Search</h2>
<p>
Omnivore&apos;s advanced search syntax supports searching for multiple labels using
<code>AND</code> and <code>OR</code> clauses. You can also negate a label search
to find all pages that do not have a certain label.
</p>
<p>Some examples:</p>
<ul>
<li><code>-label:Newsletter</code> finds all pages that do not have the label <code>Newsletter</code></li>
<li><code>label:Cooking,Fitness</code> finds all your pages with either the <code>Cooking</code> or <code>Fitness</code> labels</li>
<li><code>label:Newsletter label:Surfing</code> finds all pages with both the <code>Newsletter</code> and <code>Surfing</code> labels</li>
<li><code>label:Coding -label:Newsletter</code> finds all pages with the <code>Coding</code> label that do not have the <code>Newsletter</code> label</li>
</ul>
<h2>Editing your list of labels</h2>
<p>
The <Link href="/settings/labels"><a>labels</a></Link> page allows you to
edit all of your labels. From here you can create new labels, delete existing
labels, or modify the color and description of a label.
</p>
</Box>
<Box css={{ height: '120px' }} />
</PrimaryLayout>
)
}

View file

@ -15,8 +15,7 @@ import { updateLabelMutation } from '../../lib/networking/mutations/updateLabelM
import { deleteLabelMutation } from '../../lib/networking/mutations/deleteLabelMutation'
import { applyStoredTheme, isDarkTheme } from '../../lib/themeUpdater'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
import { Label } from '../../lib/networking/queries/useGetLabelsQuery'
import { Label, LabelColor } from '../../lib/networking/fragments/labelFragment'
import { StyledText } from '../../components/elements/StyledText'
import {
ArrowClockwise,
@ -26,7 +25,6 @@ import {
Plus,
} from 'phosphor-react'
import {
LabelColor,
GenericTableCardProps,
LabelColorHex,
} from '../../utils/settings-page/labels/types'
@ -185,18 +183,14 @@ export default function LabelsPage(): JSX.Element {
async function createLabel(): Promise<void> {
const res = await createLabelMutation(
nameInputText,
nameInputText.trim(),
labelColorHex.value,
descriptionInputText
)
if (res) {
if (res.createLabel.errorCodes && res.createLabel.errorCodes.length > 0) {
showErrorToast(res.createLabel.errorCodes[0])
} else {
showSuccessToast('Label created', { position: 'bottom-right' })
resetLabelState()
revalidate()
}
showSuccessToast('Label created', { position: 'bottom-right' })
resetLabelState()
revalidate()
} else {
showErrorToast('Failed to create label')
}

View file

@ -99,3 +99,44 @@ div#appleid-signin {
border-bottom-color: transparent;
border-left-color: transparent;
}
.slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 1px;
background: var(--colors-grayBorderHover);
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px !important;
height: 16px;
border-radius: 50%;
background: var(--colors-utilityTextContrast);
cursor: pointer;
}
.slider::-moz-range-thumb {
-webkit-appearance: none;
width: 16px !important;
height: 16px;
border-radius: 50%;
background: var(--colors-utilityTextContrast);
cursor: pointer;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
border: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: var(--colors-grayTextContrast);
}
button {
padding: 0px;
margin: 0px;
}

View file

@ -44,3 +44,9 @@ export const labelColorObjects: LabelColorObjects = {
background: '#D8D7D50D',
},
}
export const randomLabelColorHex = () => {
const colorHexes = Object.keys(labelColorObjects).slice(0, -1)
const randomColorHex = colorHexes[Math.floor(Math.random() * colorHexes.length)]
return randomColorHex
}

View file

@ -1,14 +1,4 @@
import React from "react";
import { Label } from "../../../lib/networking/queries/useGetLabelsQuery";
export type LabelColor =
| '#FF5D99'
| '#7CFF7B'
| '#FFD234'
| '#7BE4FF'
| '#CE88EF'
| '#EF8C43'
| 'custom color';
import { Label, LabelColor } from "../../../lib/networking/fragments/labelFragment";
export type LabelOptionProps = {
color: string;

View file

@ -1727,9 +1727,9 @@ array-flatten@1.1.1:
integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
async@^3.1.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720"
integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==
version "3.2.3"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9"
integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==
at-least-node@^1.0.0:
version "1.0.0"

237
yarn.lock
View file

@ -4681,20 +4681,20 @@
"@sendgrid/client" "^7.6.0"
"@sendgrid/helpers" "^7.6.0"
"@sentry/browser@6.17.7":
version "6.17.7"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.17.7.tgz#04b72ae29a56c2001be525fa39c17aa7d9992e7f"
integrity sha512-0Ad6TmB5KH5o152Hgk5tlxNiooV0Rfoj7HEzxdOnHFkl57aR7VsiPkzIBl9vxn4iyy7IheUONhHSOU1osJkv2w==
"@sentry/browser@6.19.6":
version "6.19.6"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.19.6.tgz#75be467667fffa1f4745382fc7a695568609c634"
integrity sha512-V5QyY1cO1iuFCI78dOFbHV7vckbeQEPPq3a5dGSXlBQNYnd9Ec5xoxp5nRNpWQPOZ8/Ixt9IgRxdqVTkWib51g==
dependencies:
"@sentry/core" "6.17.7"
"@sentry/types" "6.17.7"
"@sentry/utils" "6.17.7"
"@sentry/core" "6.19.6"
"@sentry/types" "6.19.6"
"@sentry/utils" "6.19.6"
tslib "^1.9.3"
"@sentry/cli@^1.72.0":
version "1.72.2"
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.72.2.tgz#ba57d8248fe4d2836883bbe3047fdd75cd6739b3"
integrity sha512-H/yrxyYscOzn0YBOzPsBnIW7QxsSQrG1kQfD1FisuMhPl67D948DPkagAms4sy1v+MntBBNnD4Z+WsdRzFWXJQ==
"@sentry/cli@^1.73.0":
version "1.74.3"
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.74.3.tgz#8405a19f6bb21b2ff3d051fb8a18056cc796c5ae"
integrity sha512-74NiqWTgTFDPe2S99h1ge5UMe6aAC44ebareadd1P6MdaNfYz6JUEa2QrDfMq7TKccEiRFXhXBHbUI8mxzrzuQ==
dependencies:
https-proxy-agent "^5.0.0"
mkdirp "^0.5.5"
@ -4702,6 +4702,7 @@
npmlog "^4.1.2"
progress "^2.0.3"
proxy-from-env "^1.1.0"
which "^2.0.2"
"@sentry/core@5.30.0":
version "5.30.0"
@ -4714,17 +4715,6 @@
"@sentry/utils" "5.30.0"
tslib "^1.9.3"
"@sentry/core@6.17.7":
version "6.17.7"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.17.7.tgz#f591235c06b1a4e75d748b15c539e071bd3f5cf5"
integrity sha512-SRhLkD05lQb4eCt1ed9Dz72DKbRDlM8PJix8eC2oJLtwyFTS0IlJNkIYRrbsSKkJUm0VsKcDkzIHvUAgBBQICw==
dependencies:
"@sentry/hub" "6.17.7"
"@sentry/minimal" "6.17.7"
"@sentry/types" "6.17.7"
"@sentry/utils" "6.17.7"
tslib "^1.9.3"
"@sentry/core@6.19.3":
version "6.19.3"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.19.3.tgz#88268afc8c42716c455ad77bb4bed2bbf96abd83"
@ -4736,6 +4726,17 @@
"@sentry/utils" "6.19.3"
tslib "^1.9.3"
"@sentry/core@6.19.6":
version "6.19.6"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.19.6.tgz#7d4649d0148b5d0be1358ab02e2f869bf7363e9a"
integrity sha512-biEotGRr44/vBCOegkTfC9rwqaqRKIpFljKGyYU6/NtzMRooktqOhjmjmItNCMRknArdeaQwA8lk2jcZDXX3Og==
dependencies:
"@sentry/hub" "6.19.6"
"@sentry/minimal" "6.19.6"
"@sentry/types" "6.19.6"
"@sentry/utils" "6.19.6"
tslib "^1.9.3"
"@sentry/hub@5.30.0":
version "5.30.0"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.30.0.tgz#2453be9b9cb903404366e198bd30c7ca74cdc100"
@ -4745,15 +4746,6 @@
"@sentry/utils" "5.30.0"
tslib "^1.9.3"
"@sentry/hub@6.17.7":
version "6.17.7"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.17.7.tgz#5c90d661e263dad7da0e0106f1cb90cf797d93a7"
integrity sha512-siGzcg+quGOdjRaBGAz6T3ycwHUsGgvalptSJdf5Q783FVFhU+haPul++zGOYURXOgx0RjYGWqagwO8+jljl3Q==
dependencies:
"@sentry/types" "6.17.7"
"@sentry/utils" "6.17.7"
tslib "^1.9.3"
"@sentry/hub@6.19.3":
version "6.19.3"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.19.3.tgz#d555c83404f19ac9b68f336b051b8e7a9d75feb0"
@ -4763,23 +4755,22 @@
"@sentry/utils" "6.19.3"
tslib "^1.9.3"
"@sentry/integrations@6.17.7":
version "6.17.7"
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-6.17.7.tgz#c14f409fec91743db4a21232413de235946cb6d3"
integrity sha512-/BYVoLHY5gUZNan6bSlsRYX44Qi2UlusLuwgEc1HZm9m4C2h4mVwhHy0XlhBAYWCr0q28ggtiFNzIxqHSKZu6g==
"@sentry/hub@6.19.6":
version "6.19.6"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.19.6.tgz#ada83ceca0827c49534edfaba018221bc1eb75e1"
integrity sha512-PuEOBZxvx3bjxcXmWWZfWXG+orojQiWzv9LQXjIgroVMKM/GG4QtZbnWl1hOckUj7WtKNl4hEGO2g/6PyCV/vA==
dependencies:
"@sentry/types" "6.17.7"
"@sentry/utils" "6.17.7"
localforage "^1.8.1"
"@sentry/types" "6.19.6"
"@sentry/utils" "6.19.6"
tslib "^1.9.3"
"@sentry/integrations@^6.19.1":
version "6.19.1"
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-6.19.1.tgz#ccc5da6b20b9ba9ff4e8ed79c92b512f8088fa8d"
integrity sha512-0ViIiwx9BlPi8jvT97ERpJWmRbBUThGOyjNVzWxaa07OEjOgKI6cNgsBHWbPjaCM00WSzaOFOtYiC4464+Mucg==
"@sentry/integrations@6.19.6", "@sentry/integrations@^6.19.1":
version "6.19.6"
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-6.19.6.tgz#157152f16a8ad8df8a97d08bfe740909446b075a"
integrity sha512-K2xuA/ByhTh3qfIe0/XIsQSNf1HrRuIgtkC4TbU7T0QosybtXDsh6t/EWK+qzs2RjVE+Iaqldihstpoyew1JgA==
dependencies:
"@sentry/types" "6.19.1"
"@sentry/utils" "6.19.1"
"@sentry/types" "6.19.6"
"@sentry/utils" "6.19.6"
localforage "^1.8.1"
tslib "^1.9.3"
@ -4792,15 +4783,6 @@
"@sentry/types" "5.30.0"
tslib "^1.9.3"
"@sentry/minimal@6.17.7":
version "6.17.7"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.17.7.tgz#f19182047f19b563f40a30d45d2ce9ad7df1ec4e"
integrity sha512-+/FGem1uXsXikX9wHPw44nevO7YTVjkkiPjyLsvnWMjv64r4Au5s+NQSFHDaytRm9IlU//+OasCAS5VAwHcYRg==
dependencies:
"@sentry/hub" "6.17.7"
"@sentry/types" "6.17.7"
tslib "^1.9.3"
"@sentry/minimal@6.19.3":
version "6.19.3"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.19.3.tgz#b9b7f0d7f0cd2341b243318668ac01458f9d7889"
@ -4810,34 +4792,28 @@
"@sentry/types" "6.19.3"
tslib "^1.9.3"
"@sentry/nextjs@^6.16.1":
version "6.17.7"
resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-6.17.7.tgz#00e0380ba2b709674e495b3c00b24ee82405575d"
integrity sha512-X9XfO3vDDv3YEnOc7taGK6Sb1wfBlL3cc1jcMoOma8qW9z+8tDoM3NBPp2uOmWdfo28D0hxVOIvqFa/fiKbbEg==
"@sentry/minimal@6.19.6":
version "6.19.6"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.19.6.tgz#b6cced3708e25d322039e68ebdf8fadfa445bf7d"
integrity sha512-T1NKcv+HTlmd8EbzUgnGPl4ySQGHWMCyZ8a8kXVMZOPDzphN3fVIzkYzWmSftCWp0rpabXPt9aRF2mfBKU+mAQ==
dependencies:
"@sentry/core" "6.17.7"
"@sentry/hub" "6.17.7"
"@sentry/integrations" "6.17.7"
"@sentry/node" "6.17.7"
"@sentry/react" "6.17.7"
"@sentry/tracing" "6.17.7"
"@sentry/utils" "6.17.7"
"@sentry/webpack-plugin" "1.18.5"
"@sentry/hub" "6.19.6"
"@sentry/types" "6.19.6"
tslib "^1.9.3"
"@sentry/node@6.17.7":
version "6.17.7"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-6.17.7.tgz#c142d93328b29312098276e0cb66ec3b9e805a93"
integrity sha512-YlBhEv8YYUimHLpwzUP5lXkagbGxGAfjvsbahhvqf7rRl8Fu5XbatAcSDcx7YE4R0Iox94IfZy95kF2NL4Idow==
"@sentry/nextjs@^6.16.1":
version "6.19.6"
resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-6.19.6.tgz#419205659c0fe2b7695bb7c892c9014b55ddd01b"
integrity sha512-xV6yj9H1Ieg4uSS4SsT1x5GvrWdifuBNLPWrneQ89kWBuPVFLLH1wZA0gvDuq6AJstRZ3A5pWI2IbwpmQzxMWQ==
dependencies:
"@sentry/core" "6.17.7"
"@sentry/hub" "6.17.7"
"@sentry/tracing" "6.17.7"
"@sentry/types" "6.17.7"
"@sentry/utils" "6.17.7"
cookie "^0.4.1"
https-proxy-agent "^5.0.0"
lru_map "^0.3.3"
"@sentry/core" "6.19.6"
"@sentry/hub" "6.19.6"
"@sentry/integrations" "6.19.6"
"@sentry/node" "6.19.6"
"@sentry/react" "6.19.6"
"@sentry/tracing" "6.19.6"
"@sentry/utils" "6.19.6"
"@sentry/webpack-plugin" "1.18.8"
tslib "^1.9.3"
"@sentry/node@6.19.3":
@ -4854,6 +4830,20 @@
lru_map "^0.3.3"
tslib "^1.9.3"
"@sentry/node@6.19.6":
version "6.19.6"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-6.19.6.tgz#d63c4ffcf0150b4175a2e4e5021b53af46e5946f"
integrity sha512-kHQMfsy40ZxxdS9zMPmXCOOLWOJbQj6/aVSHt/L1QthYcgkAi7NJQNXnQIPWQDe8eP3DfNIWM7dc446coqjXrQ==
dependencies:
"@sentry/core" "6.19.6"
"@sentry/hub" "6.19.6"
"@sentry/types" "6.19.6"
"@sentry/utils" "6.19.6"
cookie "^0.4.1"
https-proxy-agent "^5.0.0"
lru_map "^0.3.3"
tslib "^1.9.3"
"@sentry/node@^5.26.0":
version "5.30.0"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.30.0.tgz#4ca479e799b1021285d7fe12ac0858951c11cd48"
@ -4869,15 +4859,15 @@
lru_map "^0.3.3"
tslib "^1.9.3"
"@sentry/react@6.17.7":
version "6.17.7"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-6.17.7.tgz#550735bd9b56f20f15059f10a4573c80d5c882a5"
integrity sha512-rt6nAa9SJ+A4va88FASBoR5/26V2uZx/yucT9NzwoBIW41jA8lAwTmFu5MdtxGDBKDUSJlL3lt/Nkiq6LkiTaw==
"@sentry/react@6.19.6":
version "6.19.6"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-6.19.6.tgz#4c07168637bfcef4d6556a2c4548b74a61eaed87"
integrity sha512-RnWZ7clg1lRgf/JFNnTOs8ZPCv566E5CwFXXb6swyjPYUMcIn95XujDQU9SU4hXZ4qXd9BRvifxqyxvq0LMXNw==
dependencies:
"@sentry/browser" "6.17.7"
"@sentry/minimal" "6.17.7"
"@sentry/types" "6.17.7"
"@sentry/utils" "6.17.7"
"@sentry/browser" "6.19.6"
"@sentry/minimal" "6.19.6"
"@sentry/types" "6.19.6"
"@sentry/utils" "6.19.6"
hoist-non-react-statics "^3.3.2"
tslib "^1.9.3"
@ -4906,17 +4896,6 @@
"@sentry/utils" "5.30.0"
tslib "^1.9.3"
"@sentry/tracing@6.17.7":
version "6.17.7"
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-6.17.7.tgz#f4536683b29bb3ac7ddda5ca49494731cec6b619"
integrity sha512-QzIDHOjjdi/0LTdrK2LTC27YEOODI473KD8KmMJ+r9PmjDeIjNzz4hJlPwQSnXR3Mu/8foxGJGXsAt3LNmKzlQ==
dependencies:
"@sentry/hub" "6.17.7"
"@sentry/minimal" "6.17.7"
"@sentry/types" "6.17.7"
"@sentry/utils" "6.17.7"
tslib "^1.9.3"
"@sentry/tracing@6.19.3":
version "6.19.3"
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-6.19.3.tgz#dfdbd5019486c899bdf352b1152d5d253544ef70"
@ -4928,26 +4907,32 @@
"@sentry/utils" "6.19.3"
tslib "^1.9.3"
"@sentry/tracing@6.19.6":
version "6.19.6"
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-6.19.6.tgz#faa156886afe441730f03cf9ac9c4982044b7135"
integrity sha512-STZdlEtTBqRmPw6Vjkzi/1kGkGPgiX0zdHaSOhSeA2HXHwx7Wnfu7veMKxtKWdO+0yW9QZGYOYqp0GVf4Swujg==
dependencies:
"@sentry/hub" "6.19.6"
"@sentry/minimal" "6.19.6"
"@sentry/types" "6.19.6"
"@sentry/utils" "6.19.6"
tslib "^1.9.3"
"@sentry/types@5.30.0":
version "5.30.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.30.0.tgz#19709bbe12a1a0115bc790b8942917da5636f402"
integrity sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw==
"@sentry/types@6.17.7":
version "6.17.7"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.17.7.tgz#61946a3f6532b8f21251b264f173b02f9ea2458e"
integrity sha512-iBlJDhrSowZKeqvutY0tCkUjrWqkLFsHrbaQ553r1Nx+/4mxHjzVYtEVGMjZAxQUEbkm0TbnQIkkT7ltglNJ9A==
"@sentry/types@6.19.1":
version "6.19.1"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.19.1.tgz#684a231f8fe10c9cf14f9552f5da0a4492e6fdbc"
integrity sha512-ovmNYdqD2MKLmru4calxetX1xjJdYim+HEI/GzwvVUYshsaXRq4EiQ17h3DAy90MV7JH279PmMoPGDTOpufq+Q==
"@sentry/types@6.19.3":
version "6.19.3"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.19.3.tgz#94b19da68d4d23561efb1014f72968bcea85cd0c"
integrity sha512-jHhqxp8MIWSfOc3krorirTGKTEaSFO6XrAvi+2AZhr6gvOChwOgzgrN2ZqesJcZmgCsqWV21u3usSwYeRrjOJA==
"@sentry/types@6.19.6":
version "6.19.6"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.19.6.tgz#70513f9dca05d23d7ab9c2a6cb08d4db6763ca67"
integrity sha512-QH34LMJidEUPZK78l+Frt3AaVFJhEmIi05Zf8WHd9/iTt+OqvCHBgq49DDr1FWFqyYWm/QgW/3bIoikFpfsXyQ==
"@sentry/utils@5.30.0":
version "5.30.0"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.30.0.tgz#9a5bd7ccff85ccfe7856d493bffa64cabc41e980"
@ -4956,22 +4941,6 @@
"@sentry/types" "5.30.0"
tslib "^1.9.3"
"@sentry/utils@6.17.7":
version "6.17.7"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.17.7.tgz#0574bf914cc129b5e47041b75bb34dfbe0decbba"
integrity sha512-HEEEeKlZtwfQvH0waSKv5FKRFjHkVgkkEiAigXoYGQAlaUIuwRTvZGFnsmBoKMIrA4pARkA00FwwdtMU7ziC8A==
dependencies:
"@sentry/types" "6.17.7"
tslib "^1.9.3"
"@sentry/utils@6.19.1":
version "6.19.1"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.19.1.tgz#9d5f50a0ceb60b63631de88b4abed07253568209"
integrity sha512-mWcQbQ1yO7PooLpJpQK84+wOfxGeb8iUKRb8inO+2Eg6VksDbXRuJ89Yd4APBTRxBj5Wihy48bPuVfKtovtm8g==
dependencies:
"@sentry/types" "6.19.1"
tslib "^1.9.3"
"@sentry/utils@6.19.3":
version "6.19.3"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.19.3.tgz#0c3a3f0b86c12e3b079e56e37a44e62a1226043d"
@ -4980,12 +4949,20 @@
"@sentry/types" "6.19.3"
tslib "^1.9.3"
"@sentry/webpack-plugin@1.18.5":
version "1.18.5"
resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-1.18.5.tgz#aaff79d8e05b8d803654490324252406c976b1cd"
integrity sha512-HycNZEcVRj/LxaG6hLsxjHo47mpxop3j7u2aUkriE2pT7XNpeypsa0WiokYzStxzCfSu8rbAbX4PchTGLMlTjw==
"@sentry/utils@6.19.6":
version "6.19.6"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.19.6.tgz#2ddc9ef036c3847084c43d0e5a55e4646bdf9021"
integrity sha512-fAMWcsguL0632eWrROp/vhPgI7sBj/JROWVPzpabwVkm9z3m1rQm6iLFn4qfkZL8Ozy6NVZPXOQ7EXmeU24byg==
dependencies:
"@sentry/cli" "^1.72.0"
"@sentry/types" "6.19.6"
tslib "^1.9.3"
"@sentry/webpack-plugin@1.18.8":
version "1.18.8"
resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-1.18.8.tgz#247a73a0aa9e28099a736bbe89ca0d35cbac7636"
integrity sha512-PtKr0NL62b5L3kPFGjwSNbIUwwcW5E5G6bQxAYZGpkgL1MFPnS4ND0SAsySuX0byQJRFFium5A19LpzyvQZSlQ==
dependencies:
"@sentry/cli" "^1.73.0"
"@sindresorhus/is@^0.14.0":
version "0.14.0"
@ -7620,9 +7597,9 @@ async-retry@^1.2.1, async-retry@^1.3.3:
retry "0.13.1"
async@^2.6.2:
version "2.6.3"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
version "2.6.4"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
dependencies:
lodash "^4.17.14"