Merge commit 'a657da2b5f59a9de2a302590df5361e3954fb5df' into OMN-506
This commit is contained in:
@ -6,7 +6,7 @@ import Views
|
||||
struct FeedCardNavigationLink: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
let item: LinkedItem
|
||||
@ObservedObject var item: LinkedItem
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
|
||||
@ -66,6 +66,7 @@ struct WebReaderContent {
|
||||
contentReader: "WEB",
|
||||
readingProgressPercent: \(item.readingProgress),
|
||||
readingProgressAnchorIndex: \(item.readingProgressAnchor),
|
||||
labels: \(item.labelsJSONString),
|
||||
highlights: \(highlightsJSONString),
|
||||
}
|
||||
|
||||
|
||||
@ -58,6 +58,19 @@ public extension LinkedItem {
|
||||
return URL(string: pageURLString ?? "")
|
||||
}
|
||||
|
||||
var labelsJSONString: String {
|
||||
let labels = self.labels.asArray(of: LinkedItemLabel.self).map { label in
|
||||
[
|
||||
"id": NSString(string: label.id ?? ""),
|
||||
"color": NSString(string: label.color ?? ""),
|
||||
"name": NSString(string: label.name ?? ""),
|
||||
"description": NSString(string: label.labelDescription ?? "")
|
||||
]
|
||||
}
|
||||
guard let JSON = (try? JSONSerialization.data(withJSONObject: labels, options: .prettyPrinted)) else { return "[]" }
|
||||
return String(data: JSON, encoding: .utf8) ?? "[]"
|
||||
}
|
||||
|
||||
static func lookup(byID itemID: String, inContext context: NSManagedObjectContext) -> LinkedItem? {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
|
||||
@ -12,9 +12,9 @@ public extension DataService {
|
||||
}
|
||||
|
||||
let input = InputObjects.SaveUrlInput(
|
||||
clientRequestId: pageScrapePayload.url,
|
||||
clientRequestId: requestId,
|
||||
source: "ios-url",
|
||||
url: requestId
|
||||
url: pageScrapePayload.url
|
||||
)
|
||||
|
||||
let selection = Selection<MutationResult, Unions.SaveResult> {
|
||||
|
||||
@ -54,8 +54,8 @@ extension DataService {
|
||||
}
|
||||
|
||||
switch fetchedContent.contentStatus {
|
||||
case .failed, .unknown:
|
||||
throw BasicError.message(messageText: "content fetch failed")
|
||||
case .failed:
|
||||
throw BasicError.message(messageText: "content processing failed")
|
||||
case .processing:
|
||||
do {
|
||||
let retryDelayInNanoSeconds = UInt64(requestCount * 2 * 1_000_000_000)
|
||||
@ -65,12 +65,16 @@ extension DataService {
|
||||
} catch {
|
||||
throw BasicError.message(messageText: "content fetch failed")
|
||||
}
|
||||
case .succeeded:
|
||||
case .succeeded, .unknown:
|
||||
return fetchedContent
|
||||
}
|
||||
}
|
||||
|
||||
public func articleContent(username: String, itemID: String, useCache: Bool) async throws -> ArticleContent {
|
||||
public func articleContent(
|
||||
username: String,
|
||||
itemID: String,
|
||||
useCache: Bool
|
||||
) async throws -> ArticleContent {
|
||||
struct ArticleProps {
|
||||
let htmlContent: String
|
||||
let highlights: [InternalHighlight]
|
||||
@ -122,7 +126,11 @@ extension DataService {
|
||||
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
if let status = result.contentStatus, status == .succeeded {
|
||||
// Default to suceeded since older links will return a nil status
|
||||
// (but the content is almost always there)
|
||||
let status = result.contentStatus ?? .succeeded
|
||||
|
||||
if status == .succeeded {
|
||||
self?.persistArticleContent(
|
||||
htmlContent: result.htmlContent,
|
||||
itemID: itemID,
|
||||
|
||||
@ -3,60 +3,73 @@ import SwiftUI
|
||||
import Utils
|
||||
|
||||
public struct FeedCard: View {
|
||||
let item: LinkedItem
|
||||
@ObservedObject var item: LinkedItem
|
||||
|
||||
public init(item: LinkedItem) {
|
||||
self.item = item
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(item.unwrappedTitle)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
VStack {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(item.unwrappedTitle)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if let author = item.author {
|
||||
Text("By \(author)")
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
.lineLimit(1)
|
||||
if let author = item.author {
|
||||
Text("By \(author)")
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let publisherURL = item.publisherHostname {
|
||||
Text(publisherURL)
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
.underline()
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(0)
|
||||
|
||||
if let publisherURL = item.publisherHostname {
|
||||
Text(publisherURL)
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
.underline()
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(0)
|
||||
|
||||
Group {
|
||||
if let imageURL = item.imageURL {
|
||||
AsyncLoadingImage(url: imageURL) { imageStatus in
|
||||
if case let AsyncImageStatus.loaded(image) = imageStatus {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(1, contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.cornerRadius(6)
|
||||
} else if case AsyncImageStatus.loading = imageStatus {
|
||||
Color.appButtonBackground
|
||||
.frame(width: 80, height: 80)
|
||||
.cornerRadius(6)
|
||||
} else {
|
||||
EmptyView()
|
||||
Group {
|
||||
if let imageURL = item.imageURL {
|
||||
AsyncLoadingImage(url: imageURL) { imageStatus in
|
||||
if case let AsyncImageStatus.loaded(image) = imageStatus {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(1, contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.cornerRadius(6)
|
||||
} else if case AsyncImageStatus.loading = imageStatus {
|
||||
Color.appButtonBackground
|
||||
.frame(width: 80, height: 80)
|
||||
.cornerRadius(6)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Category Labels
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(item.labels.asArray(of: LinkedItemLabel.self), id: \.self) {
|
||||
TextChip(feedItemLabel: $0)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: 100)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -377,13 +377,7 @@ export const searchPages = async (
|
||||
},
|
||||
],
|
||||
should: [],
|
||||
must_not: [
|
||||
{
|
||||
term: {
|
||||
state: ArticleSavingRequestStatus.Failed,
|
||||
},
|
||||
},
|
||||
],
|
||||
must_not: [],
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
|
||||
@ -196,7 +196,7 @@ export interface Page {
|
||||
createdAt: Date
|
||||
updatedAt?: Date
|
||||
publishedAt?: Date
|
||||
savedAt?: Date
|
||||
savedAt: Date
|
||||
sharedAt?: Date
|
||||
archivedAt?: Date | null
|
||||
siteName?: string
|
||||
|
||||
@ -46,6 +46,7 @@ import { ContentParseError } from '../../utils/errors'
|
||||
import {
|
||||
authorized,
|
||||
generateSlug,
|
||||
isParsingTimeout,
|
||||
pageError,
|
||||
stringToHash,
|
||||
userDataToUser,
|
||||
@ -102,7 +103,7 @@ const FORCE_PUPPETEER_URLS = [
|
||||
/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:\/.*)?/,
|
||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/,
|
||||
]
|
||||
const UNPARSEABLE_CONTENT = 'We were unable to parse this page.'
|
||||
const UNPARSEABLE_CONTENT = '<p>We were unable to parse this page.</p>'
|
||||
|
||||
export type CreateArticlesSuccessPartial = Merge<
|
||||
CreateArticleSuccess,
|
||||
@ -246,8 +247,8 @@ export const createArticleResolver = authorized<
|
||||
|
||||
const saveTime = new Date()
|
||||
const slug = generateSlug(parsedContent?.title || croppedPathname)
|
||||
let articleToSave: Page = {
|
||||
id: '',
|
||||
const articleToSave: Page = {
|
||||
id: pageId || '',
|
||||
userId: uid,
|
||||
originalHtml: domContent,
|
||||
content: parsedContent?.content || '',
|
||||
@ -317,63 +318,47 @@ export const createArticleResolver = authorized<
|
||||
)
|
||||
}
|
||||
|
||||
const existingPage = await getPageByParam({
|
||||
userId: uid,
|
||||
url: articleToSave.url,
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
})
|
||||
if (existingPage) {
|
||||
// update existing page in elastic
|
||||
existingPage.slug = slug
|
||||
existingPage.savedAt = saveTime
|
||||
existingPage.archivedAt = archive ? saveTime : undefined
|
||||
existingPage.url = uploadFileUrlOverride || articleToSave.url
|
||||
existingPage.hash = articleToSave.hash
|
||||
|
||||
await updatePage(existingPage.id, existingPage, { ...ctx, uid })
|
||||
|
||||
log.info('page updated in elastic', existingPage.id)
|
||||
articleToSave = existingPage
|
||||
} else {
|
||||
// create new page in elastic
|
||||
if (!pageId) {
|
||||
pageId = await createPage(articleToSave, { ...ctx, uid })
|
||||
if (!pageId) {
|
||||
return pageError(
|
||||
{
|
||||
errorCodes: [CreateArticleErrorCode.ElasticError],
|
||||
},
|
||||
ctx,
|
||||
pageId
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const updated = await updatePage(pageId, articleToSave, {
|
||||
...ctx,
|
||||
uid,
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
return pageError(
|
||||
{
|
||||
errorCodes: [CreateArticleErrorCode.ElasticError],
|
||||
},
|
||||
ctx,
|
||||
pageId
|
||||
)
|
||||
}
|
||||
// create new page in elastic
|
||||
if (!pageId) {
|
||||
const newPageId = await createPage(articleToSave, { ...ctx, uid })
|
||||
if (!newPageId) {
|
||||
return pageError(
|
||||
{
|
||||
errorCodes: [CreateArticleErrorCode.ElasticError],
|
||||
},
|
||||
ctx,
|
||||
pageId
|
||||
)
|
||||
}
|
||||
articleToSave.id = newPageId
|
||||
} else {
|
||||
// update existing page's state from processing to succeeded
|
||||
articleToSave.archivedAt = archive ? saveTime : undefined
|
||||
articleToSave.url = uploadFileUrlOverride || articleToSave.url
|
||||
const updated = await updatePage(pageId, articleToSave, {
|
||||
...ctx,
|
||||
uid,
|
||||
})
|
||||
|
||||
log.info(
|
||||
'page created in elastic',
|
||||
pageId,
|
||||
articleToSave.url,
|
||||
articleToSave.slug,
|
||||
articleToSave.title
|
||||
)
|
||||
articleToSave.id = pageId
|
||||
if (!updated) {
|
||||
return pageError(
|
||||
{
|
||||
errorCodes: [CreateArticleErrorCode.ElasticError],
|
||||
},
|
||||
ctx,
|
||||
pageId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
'page created in elastic',
|
||||
articleToSave.id,
|
||||
articleToSave.url,
|
||||
articleToSave.slug,
|
||||
articleToSave.title
|
||||
)
|
||||
|
||||
const createdArticle: PartialArticle = {
|
||||
...articleToSave,
|
||||
isArchived: !!articleToSave.archivedAt,
|
||||
@ -408,7 +393,7 @@ export const getArticleResolver: ResolverFn<
|
||||
Record<string, unknown>,
|
||||
WithDataSourcesContext,
|
||||
QueryArticleArgs
|
||||
> = async (_obj, { slug }, { claims }) => {
|
||||
> = async (_obj, { slug }, { claims, pubsub }) => {
|
||||
try {
|
||||
if (!claims?.uid) {
|
||||
return { errorCodes: [ArticleErrorCode.Unauthorized] }
|
||||
@ -432,12 +417,8 @@ export const getArticleResolver: ResolverFn<
|
||||
return { errorCodes: [ArticleErrorCode.NotFound] }
|
||||
}
|
||||
|
||||
if (
|
||||
page.state === ArticleSavingRequestStatus.Processing &&
|
||||
new Date(page.createdAt).getTime() < new Date().getTime() - 1000 * 60
|
||||
) {
|
||||
if (isParsingTimeout(page)) {
|
||||
page.content = UNPARSEABLE_CONTENT
|
||||
page.description = UNPARSEABLE_CONTENT
|
||||
}
|
||||
|
||||
return {
|
||||
@ -874,6 +855,7 @@ export const searchResolver = authorized<
|
||||
savedDateFilter: searchQuery.savedDateFilter,
|
||||
publishedDateFilter: searchQuery.publishedDateFilter,
|
||||
subscriptionFilter: searchQuery.subscriptionFilter,
|
||||
includePending: true,
|
||||
},
|
||||
claims.uid
|
||||
)) || [[], 0]
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import {
|
||||
ArticleSavingRequestError,
|
||||
ArticleSavingRequestErrorCode,
|
||||
ArticleSavingRequestStatus,
|
||||
ArticleSavingRequestSuccess,
|
||||
CreateArticleSavingRequestError,
|
||||
CreateArticleSavingRequestErrorCode,
|
||||
@ -9,7 +10,11 @@ import {
|
||||
MutationCreateArticleSavingRequestArgs,
|
||||
QueryArticleSavingRequestArgs,
|
||||
} from '../../generated/graphql'
|
||||
import { authorized, pageToArticleSavingRequest } from '../../utils/helpers'
|
||||
import {
|
||||
authorized,
|
||||
isParsingTimeout,
|
||||
pageToArticleSavingRequest,
|
||||
} from '../../utils/helpers'
|
||||
import { createPageSaveRequest } from '../../services/create_page_save_request'
|
||||
import { getPageById } from '../../elastic/pages'
|
||||
import { isErrorWithCode } from '../user'
|
||||
@ -62,8 +67,12 @@ export const articleSavingRequestResolver = authorized<
|
||||
user = await models.user.get(page.userId)
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (error) {}
|
||||
if (user && page)
|
||||
if (user && page) {
|
||||
if (isParsingTimeout(page)) {
|
||||
page.state = ArticleSavingRequestStatus.Succeeded
|
||||
}
|
||||
return { articleSavingRequest: pageToArticleSavingRequest(user, page) }
|
||||
}
|
||||
|
||||
return { errorCodes: [ArticleSavingRequestErrorCode.NotFound] }
|
||||
})
|
||||
|
||||
@ -155,6 +155,7 @@ export function pdfAttachmentsRouter() {
|
||||
slug: generateSlug(title),
|
||||
id: '',
|
||||
createdAt: new Date(),
|
||||
savedAt: new Date(),
|
||||
readingProgressPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
|
||||
@ -10,11 +10,11 @@ import {
|
||||
import { generateSlug, pageToArticleSavingRequest } from '../utils/helpers'
|
||||
import * as privateIpLib from 'private-ip'
|
||||
import { countByCreatedAt, createPage, getPageByParam } from '../elastic/pages'
|
||||
import { ArticleSavingRequestStatus, Page, PageType } from '../elastic/types'
|
||||
import { ArticleSavingRequestStatus, PageType } from '../elastic/types'
|
||||
import { createPubSubClient, PubsubClient } from '../datalayer/pubsub'
|
||||
import normalizeUrl from 'normalize-url'
|
||||
|
||||
const SAVING_DESCRIPTION = 'Your link is being saved...'
|
||||
const SAVING_CONTENT = 'Your link is being saved...'
|
||||
|
||||
const isPrivateIP = privateIpLib.default
|
||||
|
||||
@ -82,52 +82,47 @@ export const createPageSaveRequest = async (
|
||||
// get priority by checking rate limit if not specified
|
||||
priority = priority || (await getPriorityByRateLimit(userId))
|
||||
|
||||
// look for existing page
|
||||
url = normalizeUrl(url, {
|
||||
stripHash: true,
|
||||
stripWWW: false,
|
||||
})
|
||||
const createdTaskName = await enqueueParseRequest(
|
||||
url,
|
||||
userId,
|
||||
articleSavingRequestId,
|
||||
priority
|
||||
)
|
||||
|
||||
const existingPage = await getPageByParam({
|
||||
let page = await getPageByParam({
|
||||
userId,
|
||||
url,
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
})
|
||||
if (existingPage) {
|
||||
console.log('Page already exists', url)
|
||||
existingPage.taskName = createdTaskName
|
||||
return pageToArticleSavingRequest(user, existingPage)
|
||||
if (page) {
|
||||
console.log('Page already exists', page)
|
||||
articleSavingRequestId = page.id
|
||||
} else {
|
||||
page = {
|
||||
id: articleSavingRequestId,
|
||||
userId,
|
||||
content: SAVING_CONTENT,
|
||||
hash: '',
|
||||
pageType: PageType.Unknown,
|
||||
readingProgressAnchorIndex: 0,
|
||||
readingProgressPercent: 0,
|
||||
slug: generateSlug(url),
|
||||
title: url,
|
||||
url,
|
||||
state: ArticleSavingRequestStatus.Processing,
|
||||
createdAt: new Date(),
|
||||
savedAt: new Date(),
|
||||
}
|
||||
|
||||
// create processing page
|
||||
const pageId = await createPage(page, { pubsub, uid: userId })
|
||||
if (!pageId) {
|
||||
console.log('Failed to create page', page)
|
||||
return Promise.reject({
|
||||
errorCode: CreateArticleSavingRequestErrorCode.BadData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const page: Page = {
|
||||
id: articleSavingRequestId,
|
||||
userId,
|
||||
content: SAVING_DESCRIPTION,
|
||||
createdAt: new Date(),
|
||||
hash: '',
|
||||
pageType: PageType.Unknown,
|
||||
readingProgressAnchorIndex: 0,
|
||||
readingProgressPercent: 0,
|
||||
slug: generateSlug(url),
|
||||
title: url,
|
||||
url,
|
||||
taskName: createdTaskName,
|
||||
state: ArticleSavingRequestStatus.Processing,
|
||||
description: SAVING_DESCRIPTION,
|
||||
}
|
||||
|
||||
const pageId = await createPage(page, { pubsub, uid: userId })
|
||||
if (!pageId) {
|
||||
console.log('Failed to create page', page)
|
||||
return Promise.reject({
|
||||
errorCode: CreateArticleSavingRequestErrorCode.BadData,
|
||||
})
|
||||
}
|
||||
// enqueue task to parse page
|
||||
await enqueueParseRequest(url, userId, articleSavingRequestId, priority)
|
||||
|
||||
return pageToArticleSavingRequest(user, page)
|
||||
}
|
||||
|
||||
@ -66,6 +66,7 @@ export const saveEmail = async (
|
||||
publishedAt: validatedDate(parseResult.parsedContent?.publishedDate),
|
||||
slug: slug,
|
||||
createdAt: new Date(),
|
||||
savedAt: new Date(),
|
||||
readingProgressAnchorIndex: 0,
|
||||
readingProgressPercent: 0,
|
||||
subscription: input.author,
|
||||
|
||||
@ -94,6 +94,7 @@ export const saveFile = async (
|
||||
userId: saver.id,
|
||||
id: input.clientRequestId,
|
||||
createdAt: new Date(),
|
||||
savedAt: new Date(),
|
||||
readingProgressPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
|
||||
@ -91,10 +91,11 @@ export const savePage = async (
|
||||
hash: stringToHash(parseResult.parsedContent?.content || input.url),
|
||||
image: parseResult.parsedContent?.previewImage,
|
||||
publishedAt: validatedDate(parseResult.parsedContent?.publishedDate),
|
||||
createdAt: new Date(),
|
||||
readingProgressPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
createdAt: new Date(),
|
||||
savedAt: new Date(),
|
||||
}
|
||||
|
||||
const existingPage = await getPageByParam({
|
||||
|
||||
@ -202,6 +202,14 @@ export const pageToArticleSavingRequest = (
|
||||
updatedAt: page.updatedAt || new Date(),
|
||||
})
|
||||
|
||||
export const isParsingTimeout = (page: Page): boolean => {
|
||||
return (
|
||||
// page processed more than 30 seconds ago
|
||||
page.state === ArticleSavingRequestStatus.Processing &&
|
||||
new Date(page.savedAt).getTime() < new Date().getTime() - 1000 * 30
|
||||
)
|
||||
}
|
||||
|
||||
export const validatedDate = (
|
||||
date: Date | string | undefined
|
||||
): Date | undefined => {
|
||||
|
||||
@ -46,6 +46,7 @@ describe('elastic api', () => {
|
||||
slug: 'test slug',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
savedAt: new Date(),
|
||||
readingProgressPercent: 100,
|
||||
readingProgressAnchorIndex: 0,
|
||||
url: 'https://blog.omnivore.app/p/getting-started-with-omnivore',
|
||||
@ -98,6 +99,7 @@ describe('elastic api', () => {
|
||||
slug: 'test',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
savedAt: new Date(),
|
||||
readingProgressPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
url: 'https://blog.omnivore.app/testUrl',
|
||||
@ -202,6 +204,7 @@ describe('elastic api', () => {
|
||||
content: 'test',
|
||||
slug: 'test',
|
||||
createdAt: new Date(createdAt),
|
||||
savedAt: new Date(),
|
||||
readingProgressPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
url: 'https://blog.omnivore.app/testCount',
|
||||
|
||||
@ -434,7 +434,7 @@ describe('Article API', () => {
|
||||
pageId,
|
||||
{
|
||||
state: ArticleSavingRequestStatus.Processing,
|
||||
createdAt: new Date(Date.now() - 1000 * 60),
|
||||
savedAt: new Date(Date.now() - 1000 * 60),
|
||||
},
|
||||
ctx
|
||||
)
|
||||
@ -444,7 +444,7 @@ describe('Article API', () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.article.article.content).to.eql(
|
||||
'We were unable to parse this page.'
|
||||
'<p>We were unable to parse this page.</p>'
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -474,7 +474,7 @@ describe('Article API', () => {
|
||||
before(async () => {
|
||||
// Create some test pages
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const page = {
|
||||
const page: Page = {
|
||||
id: '',
|
||||
hash: 'test hash',
|
||||
userId: user.id,
|
||||
@ -488,6 +488,7 @@ describe('Article API', () => {
|
||||
readingProgressAnchorIndex: 0,
|
||||
url: url,
|
||||
savedAt: new Date(),
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
} as Page
|
||||
const pageId = await createPage(page, ctx)
|
||||
if (!pageId) {
|
||||
@ -583,7 +584,7 @@ describe('Article API', () => {
|
||||
)
|
||||
expect(
|
||||
res.body.data.articles.pageInfo.startCursor,
|
||||
'startCursor'
|
||||
'st artCursor'
|
||||
).to.eql('5')
|
||||
expect(res.body.data.articles.pageInfo.endCursor, 'endCursor').to.eql(
|
||||
'10'
|
||||
@ -596,6 +597,31 @@ describe('Article API', () => {
|
||||
// expect(res.body.data.articles.pageInfo.hasPreviousPage).to.eql(true)
|
||||
})
|
||||
})
|
||||
|
||||
context('when there are pages with failed state', () => {
|
||||
before(async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await updatePage(
|
||||
pages[i].id,
|
||||
{
|
||||
state: ArticleSavingRequestStatus.Failed,
|
||||
},
|
||||
ctx
|
||||
)
|
||||
}
|
||||
after = '10'
|
||||
})
|
||||
it('should include state=failed pages', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.articles.edges.length).to.eql(5)
|
||||
expect(res.body.data.articles.edges[0].node.id).to.eql(pages[4].id)
|
||||
expect(res.body.data.articles.edges[1].node.id).to.eql(pages[3].id)
|
||||
expect(res.body.data.articles.edges[2].node.id).to.eql(pages[2].id)
|
||||
expect(res.body.data.articles.edges[3].node.id).to.eql(pages[1].id)
|
||||
expect(res.body.data.articles.edges[4].node.id).to.eql(pages[0].id)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SavePage', () => {
|
||||
@ -731,6 +757,7 @@ describe('Article API', () => {
|
||||
title: 'test title',
|
||||
content: '<p>test</p>',
|
||||
createdAt: new Date(),
|
||||
savedAt: new Date(),
|
||||
url: 'https://blog.omnivore.app/setBookmarkArticle',
|
||||
slug: 'test-with-omnivore',
|
||||
readingProgressPercent: 0,
|
||||
|
||||
@ -96,7 +96,7 @@ describe('ArticleSavingRequest API', () => {
|
||||
const page = await getPageById(
|
||||
res.body.data.createArticleSavingRequest.articleSavingRequest.id
|
||||
)
|
||||
expect(page?.description).to.eq('Your link is being saved...')
|
||||
expect(page?.content).to.eq('Your link is being saved...')
|
||||
})
|
||||
|
||||
it('returns an error if the url is invalid', async () => {
|
||||
|
||||
@ -51,6 +51,7 @@ export const createTestElasticPage = async (
|
||||
title: 'test title',
|
||||
content: '<p>test content</p>',
|
||||
createdAt: new Date(),
|
||||
savedAt: new Date(),
|
||||
url: 'https://example.com/test-url',
|
||||
slug: 'test-with-omnivore',
|
||||
labels: labels,
|
||||
|
||||
@ -34,6 +34,7 @@ const App = () => {
|
||||
>
|
||||
<ArticleContainer
|
||||
article={window.omnivoreArticle}
|
||||
labels={window.omnivoreArticle.labels}
|
||||
scrollElementRef={React.createRef()}
|
||||
isAppleAppEmbed={true}
|
||||
highlightBarDisabled={true}
|
||||
|
||||
@ -87,7 +87,7 @@ const logAppliedMigrations = (
|
||||
}
|
||||
|
||||
export const INDEX_ALIAS = 'pages_alias'
|
||||
export const client = new Client({
|
||||
export const esClient = new Client({
|
||||
node: process.env.ELASTIC_URL || 'http://localhost:9200',
|
||||
auth: {
|
||||
username: process.env.ELASTIC_USERNAME || '',
|
||||
@ -103,7 +103,7 @@ const updateMappings = async (): Promise<void> => {
|
||||
)
|
||||
|
||||
// update mappings
|
||||
await client.indices.putMapping({
|
||||
await esClient.indices.putMapping({
|
||||
index: INDEX_ALIAS,
|
||||
body: JSON.parse(indexSettings).mappings,
|
||||
})
|
||||
@ -123,8 +123,39 @@ postgrator
|
||||
log('Starting updating elasticsearch index mappings...')
|
||||
|
||||
updateMappings()
|
||||
.then(() => console.log('\nUpdating elastic completed.'))
|
||||
.then(() => console.log('\nUpdating elastic mappings completed.'))
|
||||
.catch((error) => {
|
||||
log(`${chalk.red('Updating failed: ')}${error.message}`, chalk.red)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
log('Starting adding default state to pages in elasticsearch...')
|
||||
esClient
|
||||
.update_by_query({
|
||||
index: INDEX_ALIAS,
|
||||
body: {
|
||||
script: {
|
||||
source: 'ctx._source.state = params.state',
|
||||
lang: 'painless',
|
||||
params: {
|
||||
state: 'SUCCEEDED',
|
||||
},
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
must_not: [
|
||||
{
|
||||
exists: {
|
||||
field: 'state',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => console.log('\nAdding default state completed.'))
|
||||
.catch((error) => {
|
||||
log(`${chalk.red('Adding failed: ')}${error.message}`, chalk.red)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@ -12,21 +12,30 @@ const YOUTUBE_URL_MATCH =
|
||||
exports.youtubeHandler = {
|
||||
|
||||
shouldPrehandle: (url, env) => {
|
||||
return YOUTUBE_URL_MATCH.test(url.toString())
|
||||
return YOUTUBE_URL_MATCH.test(url.toString())
|
||||
},
|
||||
|
||||
getVideoId: (url) => {
|
||||
const u = new URL(url);
|
||||
const videoId = u.searchParams['v']
|
||||
if (!videoId) {
|
||||
const match = url.toString().match(YOUTUBE_URL_MATCH)
|
||||
if (match === null || match.length < 6 || !match[5]) {
|
||||
return undefined
|
||||
}
|
||||
return match[5]
|
||||
}
|
||||
return videoId
|
||||
},
|
||||
|
||||
prehandle: async (url, env) => {
|
||||
console.log('prehandling youtube url', url)
|
||||
|
||||
const match = url.toString().match(YOUTUBE_URL_MATCH)
|
||||
if (match === null || match.length < 6 || !match[5]) {
|
||||
const videoId = getVideoId(url)
|
||||
if (!videoId) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const videoId = match[5]
|
||||
const oembedUrl = `https://www.youtube.com/oembed?format=json&url=` + encodeURIComponent(`https://www.youtube.com/watch?v=${videoId}`)
|
||||
const oembed = (await axios.get(oembedUrl.toString())).data;
|
||||
console.log('pageContent', oembed);
|
||||
const title = oembed.title;
|
||||
const ratio = oembed.width / oembed.height;
|
||||
const thumbnail = oembed.thumbnail_url;
|
||||
|
||||
@ -177,3 +177,7 @@ export const StyledImg = styled('img', {
|
||||
export const StyledAnchor = styled('a', {
|
||||
textDecoration: 'none'
|
||||
})
|
||||
|
||||
export const StyledMark = styled('mark', {
|
||||
|
||||
})
|
||||
@ -8,6 +8,7 @@ type HighlightViewProps = {
|
||||
highlight: Highlight
|
||||
author?: string
|
||||
title?: string
|
||||
scrollToHighlight?: (arg: string) => void;
|
||||
}
|
||||
|
||||
export function HighlightView(props: HighlightViewProps): JSX.Element {
|
||||
@ -22,6 +23,7 @@ export function HighlightView(props: HighlightViewProps): JSX.Element {
|
||||
fontSize: '18px',
|
||||
lineHeight: '27px',
|
||||
color: '$textDefault',
|
||||
cursor: 'pointer',
|
||||
})
|
||||
|
||||
return (
|
||||
@ -31,7 +33,11 @@ export function HighlightView(props: HighlightViewProps): JSX.Element {
|
||||
<StyledText style='shareHighlightModalAnnotation'>{annotation}</StyledText>
|
||||
</Box>)
|
||||
}
|
||||
<StyledQuote>
|
||||
<StyledQuote onClick={() => {
|
||||
if (props.scrollToHighlight) {
|
||||
props.scrollToHighlight(props.highlight.id)
|
||||
}
|
||||
}}>
|
||||
{props.highlight.prefix}
|
||||
<SpanBox css={{ bg: '$highlightBackground', p: '1px', borderRadius: '2px', }}>
|
||||
{lines.map((line: string, index: number) => (
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
import { styled } from '@stitches/react'
|
||||
import { VStack, HStack } from '../../elements/LayoutPrimitives'
|
||||
import { StyledMark, StyledText } from '../../elements/StyledText'
|
||||
import { LinkedItemCardAction, LinkedItemCardProps } from './CardTypes'
|
||||
|
||||
export interface HighlightItemCardProps
|
||||
extends Pick<LinkedItemCardProps, 'item'> {
|
||||
handleAction: (action: LinkedItemCardAction) => void
|
||||
}
|
||||
|
||||
export const PreviewImage = styled('img', {
|
||||
objectFit: 'cover',
|
||||
cursor: 'pointer',
|
||||
})
|
||||
|
||||
export function HighlightItemCard(props: HighlightItemCardProps): JSX.Element {
|
||||
return (
|
||||
<VStack
|
||||
css={{
|
||||
p: '$2',
|
||||
height: '100%',
|
||||
maxWidth: '498px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
wordBreak: 'break-word',
|
||||
overflow: 'clip',
|
||||
border: '1px solid $grayBorder',
|
||||
boxShadow: '0px 3px 11px rgba(32, 31, 29, 0.04)',
|
||||
bg: '$grayBg',
|
||||
'&:focus': {
|
||||
bg: '$grayBgActive',
|
||||
},
|
||||
'&:hover': {
|
||||
bg: '$grayBgActive',
|
||||
},
|
||||
}}
|
||||
alignment="start"
|
||||
distribution="start"
|
||||
onClick={() => {
|
||||
props.handleAction('showDetail')
|
||||
}}
|
||||
>
|
||||
<StyledText
|
||||
css={{
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
<StyledMark
|
||||
css={{
|
||||
background: '$highlightBackground',
|
||||
color: '$highlightText',
|
||||
}}
|
||||
>
|
||||
{props.item.quote}
|
||||
</StyledMark>
|
||||
</StyledText>
|
||||
<HStack
|
||||
css={{
|
||||
marginTop: 'auto',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{props.item.image && (
|
||||
<PreviewImage
|
||||
src={props.item.image}
|
||||
alt="Preview Image"
|
||||
width={16}
|
||||
height={16}
|
||||
css={{ borderRadius: '50%' }}
|
||||
onError={(e) => {
|
||||
;(e.target as HTMLElement).style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<StyledText
|
||||
css={{
|
||||
marginLeft: '$2',
|
||||
fontWeight: '700',
|
||||
}}
|
||||
>
|
||||
{props.item.title
|
||||
.substring(0, 50)
|
||||
.concat(props.item.title.length > 50 ? '...' : '')}
|
||||
</StyledText>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { GridLinkedItemCard } from './GridLinkedItemCard'
|
||||
import { ListLinkedItemCard } from './ListLinkedItemCard'
|
||||
import type { LinkedItemCardProps } from './CardTypes'
|
||||
import { HighlightItemCard } from './HighlightItemCard'
|
||||
import { PageType } from '../../../lib/networking/fragments/articleFragment'
|
||||
|
||||
const siteName = (originalArticleUrl: string, itemUrl: string): string => {
|
||||
try {
|
||||
@ -15,6 +17,9 @@ const siteName = (originalArticleUrl: string, itemUrl: string): string => {
|
||||
export function LinkedItemCard(props: LinkedItemCardProps): JSX.Element {
|
||||
const originText = siteName(props.item.originalArticleUrl, props.item.url)
|
||||
|
||||
if (props.item.pageType === PageType.HIGHLIGHTS) {
|
||||
return <HighlightItemCard {...props} />
|
||||
}
|
||||
if (props.layout == 'LIST_LAYOUT') {
|
||||
return <ListLinkedItemCard {...props} originText={originText} />
|
||||
} else {
|
||||
|
||||
@ -21,8 +21,10 @@ import { ArticleMutations } from '../../../lib/articleActions'
|
||||
export type ArticleProps = {
|
||||
articleId: string
|
||||
content: string
|
||||
highlightReady: boolean
|
||||
initialAnchorIndex: number
|
||||
initialReadingProgress?: number
|
||||
highlightHref: MutableRefObject<string | null>
|
||||
scrollElementRef: MutableRefObject<HTMLDivElement | null>
|
||||
articleMutations: ArticleMutations
|
||||
}
|
||||
@ -139,50 +141,53 @@ export function Article(props: ArticleProps): JSX.Element {
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldScrollToInitialPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
setShouldScrollToInitialPosition(false)
|
||||
|
||||
if (props.initialReadingProgress && props.initialReadingProgress >= 98) {
|
||||
return
|
||||
}
|
||||
|
||||
const anchorElement = document.querySelector(
|
||||
`[data-omnivore-anchor-idx='${props.initialAnchorIndex.toString()}']`
|
||||
)
|
||||
|
||||
if (anchorElement) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const calculateOffset = (obj: any): number => {
|
||||
let offset = 0
|
||||
if (obj.offsetParent) {
|
||||
do {
|
||||
offset += obj.offsetTop
|
||||
} while ((obj = obj.offsetParent))
|
||||
return offset
|
||||
}
|
||||
|
||||
return 0
|
||||
if (props.highlightReady) {
|
||||
if (!shouldScrollToInitialPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.scrollElementRef.current) {
|
||||
props.scrollElementRef.current?.scroll(
|
||||
0,
|
||||
calculateOffset(anchorElement)
|
||||
)
|
||||
} else {
|
||||
window.document.documentElement.scroll(
|
||||
0,
|
||||
calculateOffset(anchorElement)
|
||||
)
|
||||
setShouldScrollToInitialPosition(false)
|
||||
|
||||
if (props.initialReadingProgress && props.initialReadingProgress >= 98) {
|
||||
return
|
||||
}
|
||||
|
||||
const anchorElement = props.highlightHref.current
|
||||
? document.querySelector(
|
||||
`[omnivore-highlight-id="${props.highlightHref.current}"]`
|
||||
)
|
||||
: document.querySelector(
|
||||
`[data-omnivore-anchor-idx='${props.initialAnchorIndex.toString()}']`
|
||||
)
|
||||
|
||||
if (anchorElement) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const calculateOffset = (obj: any): number => {
|
||||
let offset = 0
|
||||
if (obj.offsetParent) {
|
||||
do {
|
||||
offset += obj.offsetTop
|
||||
} while ((obj = obj.offsetParent))
|
||||
return offset
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const calculatedOffset = calculateOffset(anchorElement)
|
||||
|
||||
if (props.scrollElementRef.current) {
|
||||
props.scrollElementRef.current?.scroll(0, calculatedOffset - 100)
|
||||
} else {
|
||||
window.document.documentElement.scroll(0, calculatedOffset - 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
props.highlightReady,
|
||||
props.scrollElementRef,
|
||||
props.initialAnchorIndex,
|
||||
props.initialReadingProgress,
|
||||
props.scrollElementRef,
|
||||
shouldScrollToInitialPosition,
|
||||
])
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import { ArticleSubtitle } from './../../patterns/ArticleSubtitle'
|
||||
import { theme, ThemeId } from './../../tokens/stitches.config'
|
||||
import { HighlightsLayer } from '../../templates/article/HighlightsLayer'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { MutableRefObject, useEffect, useState } from 'react'
|
||||
import { MutableRefObject, useEffect, useState, useRef } from 'react'
|
||||
import { ReportIssuesModal } from './ReportIssuesModal'
|
||||
import { reportIssueMutation } from '../../../lib/networking/mutations/reportIssueMutation'
|
||||
import { ArticleHeaderToolbar } from './ArticleHeaderToolbar'
|
||||
@ -15,6 +15,7 @@ import { updateThemeLocally } from '../../../lib/themeUpdater'
|
||||
import { ArticleMutations } from '../../../lib/articleActions'
|
||||
import { LabelChip } from '../../elements/LabelChip'
|
||||
import { Label } from '../../../lib/networking/fragments/labelFragment'
|
||||
import { HighlightLocation, makeHighlightStartEndOffset } from '../../../lib/highlights/highlightGenerator'
|
||||
|
||||
type ArticleContainerProps = {
|
||||
article: ArticleAttributes
|
||||
@ -36,6 +37,11 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [showReportIssuesModal, setShowReportIssuesModal] = useState(false)
|
||||
const [fontSize, setFontSize] = useState(props.fontSize ?? 20)
|
||||
const highlightHref = useRef(window.location.hash ? window.location.hash.split('#')[1] : null)
|
||||
const [highlightReady, setHighlightReady] = useState(false)
|
||||
const [highlightLocations, setHighlightLocations] = useState<
|
||||
HighlightLocation[]
|
||||
>([])
|
||||
|
||||
const updateFontSize = async (newFontSize: number) => {
|
||||
if (fontSize !== newFontSize) {
|
||||
@ -48,6 +54,21 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
updateFontSize(props.fontSize ?? 20)
|
||||
}, [props.fontSize])
|
||||
|
||||
// Load the highlights
|
||||
useEffect(() => {
|
||||
const res: HighlightLocation[] = []
|
||||
props.article.highlights.forEach((highlight) => {
|
||||
try {
|
||||
const offset = makeHighlightStartEndOffset(highlight)
|
||||
res.push(offset)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
setHighlightLocations(res)
|
||||
setHighlightReady(true)
|
||||
}, [props.article.highlights, setHighlightLocations])
|
||||
|
||||
// Listen for font size and color mode change events sent from host apps (ios, macos...)
|
||||
useEffect(() => {
|
||||
const increaseFontSize = async () => {
|
||||
@ -158,6 +179,8 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
)}
|
||||
</VStack>
|
||||
<Article
|
||||
highlightReady={highlightReady}
|
||||
highlightHref={highlightHref}
|
||||
articleId={props.article.id}
|
||||
content={props.article.content}
|
||||
initialAnchorIndex={props.article.readingProgressAnchorIndex}
|
||||
@ -182,6 +205,7 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
<Box css={{ height: '100px' }} />
|
||||
</Box>
|
||||
<HighlightsLayer
|
||||
highlightLocations={highlightLocations}
|
||||
highlights={props.article.highlights}
|
||||
articleTitle={props.article.title}
|
||||
articleAuthor={props.article.author ?? ''}
|
||||
|
||||
@ -29,6 +29,7 @@ type HighlightsLayerProps = {
|
||||
highlightsBaseURL: string
|
||||
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
|
||||
articleMutations: ArticleMutations
|
||||
highlightLocations: HighlightLocation[]
|
||||
}
|
||||
|
||||
type HighlightModalAction = 'none' | 'addComment' | 'share'
|
||||
@ -49,9 +50,6 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
const [highlightModalAction, setHighlightModalAction] =
|
||||
useState<HighlightActionProps>({ highlightModalAction: 'none' })
|
||||
|
||||
const [highlightLocations, setHighlightLocations] = useState<
|
||||
HighlightLocation[]
|
||||
>([])
|
||||
const focusedHighlightMousePos = useRef({ pageX: 0, pageY: 0 })
|
||||
|
||||
const [focusedHighlight, setFocusedHighlight] = useState<
|
||||
@ -59,26 +57,12 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
>(undefined)
|
||||
|
||||
const [selectionData, setSelectionData] = useSelection(
|
||||
highlightLocations,
|
||||
props.highlightLocations,
|
||||
false //noteModal.open,
|
||||
)
|
||||
|
||||
const canShareNative = useCanShareNative()
|
||||
|
||||
// Load the highlights
|
||||
useEffect(() => {
|
||||
const res: HighlightLocation[] = []
|
||||
highlights.forEach((highlight) => {
|
||||
try {
|
||||
const offset = makeHighlightStartEndOffset(highlight)
|
||||
res.push(offset)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
setHighlightLocations(res)
|
||||
}, [highlights, setHighlightLocations])
|
||||
|
||||
const removeHighlightCallback = useCallback(
|
||||
async (id?: string) => {
|
||||
const highlightId = id || focusedHighlight?.id
|
||||
@ -89,7 +73,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
if (didDeleteHighlight) {
|
||||
removeHighlights(
|
||||
highlights.map(($0) => $0.id),
|
||||
highlightLocations
|
||||
props.highlightLocations
|
||||
)
|
||||
setHighlights(highlights.filter(($0) => $0.id !== highlightId))
|
||||
setFocusedHighlight(undefined)
|
||||
@ -97,16 +81,16 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
console.error('Failed to delete highlight')
|
||||
}
|
||||
},
|
||||
[focusedHighlight, highlights, highlightLocations]
|
||||
[focusedHighlight, highlights, props.highlightLocations]
|
||||
)
|
||||
|
||||
const updateHighlightsCallback = useCallback(
|
||||
(highlight: Highlight) => {
|
||||
removeHighlights([highlight.id], highlightLocations)
|
||||
removeHighlights([highlight.id], props.highlightLocations)
|
||||
const keptHighlights = highlights.filter(($0) => $0.id !== highlight.id)
|
||||
setHighlights([...keptHighlights, highlight])
|
||||
},
|
||||
[highlights, highlightLocations]
|
||||
[highlights, props.highlightLocations]
|
||||
)
|
||||
|
||||
const handleNativeShare = useCallback(
|
||||
@ -159,7 +143,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
selection: selection,
|
||||
articleId: props.articleId,
|
||||
existingHighlights: highlights,
|
||||
highlightStartEndOffsets: highlightLocations,
|
||||
highlightStartEndOffsets: props.highlightLocations,
|
||||
annotation: note,
|
||||
}, props.articleMutations)
|
||||
|
||||
@ -214,10 +198,22 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
selectionData,
|
||||
setSelectionData,
|
||||
canShareNative,
|
||||
highlightLocations,
|
||||
props.highlightLocations,
|
||||
]
|
||||
)
|
||||
|
||||
const scrollToHighlight = (id: string) => {
|
||||
const foundElement = document.querySelector(`[omnivore-highlight-id="${id}"]`)
|
||||
if(foundElement){
|
||||
foundElement.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth'
|
||||
})
|
||||
window.location.hash = `#${id}`
|
||||
props.setShowHighlightsModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect mouseclick on a highlight -- call `setFocusedHighlight` when highlight detected
|
||||
const handleClickHighlight = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
@ -261,7 +257,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
})
|
||||
} else setFocusedHighlight(undefined)
|
||||
},
|
||||
[highlights, highlightLocations]
|
||||
[highlights, props.highlightLocations]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -469,9 +465,10 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
||||
if (props.showHighlightsModal) {
|
||||
return (
|
||||
<HighlightsModal
|
||||
highlights={highlights}
|
||||
onOpenChange={() => props.setShowHighlightsModal(false)}
|
||||
deleteHighlightAction={(highlightId: string) => {
|
||||
highlights={highlights}
|
||||
onOpenChange={() => props.setShowHighlightsModal(false)}
|
||||
scrollToHighlight={scrollToHighlight}
|
||||
deleteHighlightAction={(highlightId: string) => {
|
||||
removeHighlightCallback(highlightId)
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -19,6 +19,7 @@ import { Pen, Trash } from 'phosphor-react'
|
||||
|
||||
type HighlightsModalProps = {
|
||||
highlights: Highlight[]
|
||||
scrollToHighlight?: (arg: string) => void;
|
||||
deleteHighlightAction?: (highlightId: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
@ -60,6 +61,7 @@ export function HighlightsModal(props: HighlightsModalProps): JSX.Element {
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
showDelete={!!props.deleteHighlightAction}
|
||||
scrollToHighlight={props.scrollToHighlight}
|
||||
deleteHighlightAction={() => {
|
||||
if (props.deleteHighlightAction) {
|
||||
props.deleteHighlightAction(highlight.id)
|
||||
@ -82,6 +84,7 @@ export function HighlightsModal(props: HighlightsModalProps): JSX.Element {
|
||||
type ModalHighlightViewProps = {
|
||||
highlight: Highlight
|
||||
showDelete: boolean
|
||||
scrollToHighlight?: (arg: string) => void;
|
||||
deleteHighlightAction: () => void
|
||||
}
|
||||
|
||||
@ -156,7 +159,7 @@ function ModalHighlightView(props: ModalHighlightViewProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<VStack>
|
||||
<HighlightView highlight={props.highlight} />
|
||||
<HighlightView scrollToHighlight={props.scrollToHighlight} highlight={props.highlight} />
|
||||
{props.highlight.annotation && !isEditing ? (
|
||||
<StyledText css={{ px: '24px' }}>{props.highlight.annotation}</StyledText>
|
||||
) : null}
|
||||
|
||||
@ -40,7 +40,7 @@ import { Label } from '../../../lib/networking/fragments/labelFragment'
|
||||
import { isVipUser } from '../../../lib/featureFlag'
|
||||
import { EmptyLibrary } from './EmptyLibrary'
|
||||
import TopBarProgress from 'react-topbar-progress-indicator'
|
||||
import { State } from '../../../lib/networking/fragments/articleFragment'
|
||||
import { State, PageType } from '../../../lib/networking/fragments/articleFragment'
|
||||
|
||||
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
|
||||
|
||||
@ -53,7 +53,7 @@ const timeZoneHourDiff = -new Date().getTimezoneOffset() / 60
|
||||
const SAVED_SEARCHES: Record<string, string> = {
|
||||
Inbox: `in:inbox`,
|
||||
'Read Later': `in:inbox -label:Newsletter`,
|
||||
Highlighted: `in:inbox has:highlights`,
|
||||
Highlights: `type:highlights`,
|
||||
Today: `in:inbox saved:${
|
||||
new Date(new Date().getTime() - 24 * 3600000).toISOString().split('T')[0]
|
||||
}Z${timeZoneHourDiff.toLocaleString('en-US', {
|
||||
@ -112,23 +112,23 @@ export function HomeFeedContainer(props: HomeFeedContainerProps): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setQueryInputs, router.isReady, router.query])
|
||||
|
||||
const { articlesPages, size, setSize, isValidating, performActionOnItem } =
|
||||
const { itemsPages, size, setSize, isValidating, performActionOnItem } =
|
||||
useGetLibraryItemsQuery(queryInputs)
|
||||
|
||||
const hasMore = useMemo(() => {
|
||||
if (!articlesPages) {
|
||||
if (!itemsPages) {
|
||||
return false
|
||||
}
|
||||
return articlesPages[articlesPages.length - 1].articles.pageInfo.hasNextPage
|
||||
}, [articlesPages])
|
||||
return itemsPages[itemsPages.length - 1].search.pageInfo.hasNextPage
|
||||
}, [itemsPages])
|
||||
|
||||
const libraryItems = useMemo(() => {
|
||||
const items =
|
||||
articlesPages?.flatMap((ad) => {
|
||||
return ad.articles.edges
|
||||
itemsPages?.flatMap((ad) => {
|
||||
return ad.search.edges
|
||||
}) || []
|
||||
return items
|
||||
}, [articlesPages, performActionOnItem])
|
||||
}, [itemsPages, performActionOnItem])
|
||||
|
||||
const handleFetchMore = useCallback(() => {
|
||||
if (isValidating || !hasMore) {
|
||||
@ -262,7 +262,8 @@ export function HomeFeedContainer(props: HomeFeedContainerProps): JSX.Element {
|
||||
if (item.node.state === State.PROCESSING) {
|
||||
router.push(`/${username}/links/${item.node.id}`)
|
||||
} else {
|
||||
router.push(`/${username}/${item.node.slug}`)
|
||||
const dl = item.node.pageType === PageType.HIGHLIGHTS ? `#${item.node.id}` : ''
|
||||
router.push(`/${username}/${item.node.slug}` + dl)
|
||||
}
|
||||
}
|
||||
break
|
||||
@ -440,8 +441,8 @@ export function HomeFeedContainer(props: HomeFeedContainerProps): JSX.Element {
|
||||
setSize(size + 1)
|
||||
}}
|
||||
hasMore={hasMore}
|
||||
hasData={!!articlesPages}
|
||||
totalItems={articlesPages?.[0].articles.pageInfo.totalCount || 0}
|
||||
hasData={!!itemsPages}
|
||||
totalItems={itemsPages?.[0].search.pageInfo.totalCount || 0}
|
||||
isValidating={isValidating}
|
||||
shareTarget={shareTarget}
|
||||
setShareTarget={setShareTarget}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { SearchIcon } from '../../elements/images/SearchIcon'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
import { DropdownOption, Dropdown } from '../../elements/DropdownElements'
|
||||
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
|
||||
import { FormInput } from '../../elements/FormElements'
|
||||
import { searchBarCommands } from '../../../lib/keyboardShortcuts/navigationShortcuts'
|
||||
import { useKeyboardShortcuts } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts'
|
||||
@ -16,7 +15,19 @@ type LibrarySearchBarProps = {
|
||||
applySearchQuery: (searchQuery: string) => void
|
||||
}
|
||||
|
||||
type LibraryFilter = 'in:inbox' | 'in:all' | 'in:archive' | 'type:file'
|
||||
type LibraryFilter =
|
||||
| 'in:inbox'
|
||||
| 'in:all'
|
||||
| 'in:archive'
|
||||
| 'type:file'
|
||||
| 'type:highlights'
|
||||
| `saved:${string}`
|
||||
| `sort:updated`
|
||||
|
||||
// get last week's date
|
||||
const recentlySavedStartDate = new Date(
|
||||
new Date().getTime() - 7 * 24 * 60 * 60 * 1000
|
||||
).toLocaleDateString('en-US')
|
||||
|
||||
const FOCUSED_BOXSHADOW = '0px 0px 2px 2px rgba(255, 234, 159, 0.56)'
|
||||
|
||||
@ -153,6 +164,21 @@ export function DropdownFilterMenu(
|
||||
title="Files"
|
||||
hideSeparator
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => props.onFilterChange('type:highlights')}
|
||||
title="Highlights"
|
||||
hideSeparator
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => props.onFilterChange(`saved:${recentlySavedStartDate}`)}
|
||||
title="Recently Saved"
|
||||
hideSeparator
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => props.onFilterChange(`sort:updated`)}
|
||||
title="Recently Read"
|
||||
hideSeparator
|
||||
/>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
@ -30,6 +30,16 @@ export enum State {
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
||||
export enum PageType {
|
||||
ARTICLE = 'ARTICLE',
|
||||
BOOK = 'BOOK',
|
||||
FILE = 'FILE',
|
||||
PROFILE = 'PROFILE',
|
||||
WEBSITE = 'WEBSITE',
|
||||
HIGHLIGHTS = 'HIGHLIGHTS',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
export type ArticleFragmentData = {
|
||||
id: string
|
||||
title: string
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { gql } from 'graphql-request'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
import { gqlFetcher } from '../networkHelpers'
|
||||
import type { ArticleFragmentData } from '../fragments/articleFragment'
|
||||
import type { ArticleFragmentData, PageType, State } from '../fragments/articleFragment'
|
||||
import { ContentReader } from '../fragments/articleFragment'
|
||||
import { setLinkArchivedMutation } from '../mutations/setLinkArchivedMutation'
|
||||
import { deleteLinkMutation } from '../mutations/deleteLinkMutation'
|
||||
import { articleReadingProgressMutation } from '../mutations/articleReadingProgressMutation'
|
||||
@ -16,8 +17,8 @@ export type LibraryItemsQueryInput = {
|
||||
}
|
||||
|
||||
type LibraryItemsQueryResponse = {
|
||||
articlesPages?: LibraryItemsData[]
|
||||
articlesDataError?: unknown
|
||||
itemsPages?: LibraryItemsData[]
|
||||
itemsDataError?: unknown
|
||||
isLoading: boolean
|
||||
isValidating: boolean
|
||||
size: number
|
||||
@ -36,7 +37,7 @@ type LibraryItemAction =
|
||||
| 'refresh'
|
||||
|
||||
export type LibraryItemsData = {
|
||||
articles: LibraryItems
|
||||
search: LibraryItems
|
||||
}
|
||||
|
||||
export type LibraryItems = {
|
||||
@ -50,12 +51,30 @@ export type LibraryItem = {
|
||||
node: LibraryItemNode
|
||||
}
|
||||
|
||||
export type LibraryItemNode = ArticleFragmentData & {
|
||||
description?: string
|
||||
hasContent: boolean
|
||||
export type LibraryItemNode = {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
author?: string
|
||||
image?: string
|
||||
createdAt: string
|
||||
publishedAt?: string
|
||||
contentReader?: ContentReader
|
||||
originalArticleUrl: string
|
||||
sharedComment?: string
|
||||
readingProgressPercent: number
|
||||
readingProgressAnchorIndex: number
|
||||
slug: string
|
||||
isArchived: boolean
|
||||
description: string
|
||||
ownedByViewer: boolean
|
||||
uploadFileId: string
|
||||
labels?: Label[]
|
||||
pageId: string
|
||||
shortId: string
|
||||
quote: string
|
||||
annotation: string
|
||||
state: State
|
||||
pageType: PageType
|
||||
}
|
||||
|
||||
export type PageInfo = {
|
||||
@ -66,31 +85,6 @@ 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
|
||||
state
|
||||
labels {
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export function useGetLibraryItemsQuery({
|
||||
limit,
|
||||
sortDescending,
|
||||
@ -98,27 +92,38 @@ export function useGetLibraryItemsQuery({
|
||||
cursor,
|
||||
}: LibraryItemsQueryInput): LibraryItemsQueryResponse {
|
||||
const query = gql`
|
||||
query GetArticles(
|
||||
$sharedOnly: Boolean
|
||||
$sort: SortParams
|
||||
$after: String
|
||||
$first: Int
|
||||
$query: String
|
||||
) {
|
||||
articles(
|
||||
sharedOnly: $sharedOnly
|
||||
sort: $sort
|
||||
first: $first
|
||||
after: $after
|
||||
query: $query
|
||||
includePending: true
|
||||
) {
|
||||
... on ArticlesSuccess {
|
||||
query Search($after: String, $first: Int, $query: String) {
|
||||
search(first: $first, after: $after, query: $query) {
|
||||
... on SearchSuccess {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
...ArticleFields
|
||||
id
|
||||
title
|
||||
slug
|
||||
url
|
||||
pageType
|
||||
contentReader
|
||||
createdAt
|
||||
isArchived
|
||||
readingProgressPercent
|
||||
readingProgressAnchorIndex
|
||||
author
|
||||
image
|
||||
description
|
||||
publishedAt
|
||||
ownedByViewer
|
||||
originalArticleUrl
|
||||
uploadFileId
|
||||
labels {
|
||||
id
|
||||
name
|
||||
color
|
||||
}
|
||||
pageId
|
||||
shortId
|
||||
quote
|
||||
annotation
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
@ -129,21 +134,14 @@ export function useGetLibraryItemsQuery({
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
... on ArticlesError {
|
||||
... on SearchError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
${libraryItemFragment}
|
||||
${labelFragment}
|
||||
`
|
||||
|
||||
const variables = {
|
||||
sharedOnly: false,
|
||||
sort: {
|
||||
order: sortDescending ? 'DESCENDING' : 'ASCENDING',
|
||||
by: 'UPDATED_TIME',
|
||||
},
|
||||
after: cursor,
|
||||
first: limit,
|
||||
query: searchQuery,
|
||||
@ -162,12 +160,10 @@ export function useGetLibraryItemsQuery({
|
||||
limit,
|
||||
sortDescending,
|
||||
searchQuery,
|
||||
pageIndex === 0
|
||||
? undefined
|
||||
: previousResult.articles.pageInfo.endCursor,
|
||||
pageIndex === 0 ? undefined : previousResult.search.pageInfo.endCursor,
|
||||
]
|
||||
},
|
||||
(_query, _l, _s, _sq, cursor: string) => {
|
||||
(_query, _l, _s, _sq, cursor) => {
|
||||
return gqlFetcher(query, { ...variables, after: cursor }, true)
|
||||
},
|
||||
{ revalidateFirstPage: false }
|
||||
@ -182,7 +178,7 @@ export function useGetLibraryItemsQuery({
|
||||
// the response in the case of an error.
|
||||
if (!error && responsePages) {
|
||||
const errors = responsePages.filter(
|
||||
(d) => d.articles.errorCodes && d.articles.errorCodes.length > 0
|
||||
(d) => d.search.errorCodes && d.search.errorCodes.length > 0
|
||||
)
|
||||
if (errors?.length > 0) {
|
||||
responseError = errors
|
||||
@ -202,13 +198,13 @@ export function useGetLibraryItemsQuery({
|
||||
if (!responsePages) {
|
||||
return
|
||||
}
|
||||
for (const articlesData of responsePages) {
|
||||
const itemIndex = articlesData.articles.edges.indexOf(item)
|
||||
for (const searchResults of responsePages) {
|
||||
const itemIndex = searchResults.search.edges.indexOf(item)
|
||||
if (itemIndex !== -1) {
|
||||
if (typeof mutatedItem === 'undefined') {
|
||||
articlesData.articles.edges.splice(itemIndex, 1)
|
||||
searchResults.search.edges.splice(itemIndex, 1)
|
||||
} else {
|
||||
articlesData.articles.edges[itemIndex] = mutatedItem
|
||||
searchResults.search.edges[itemIndex] = mutatedItem
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -313,8 +309,8 @@ export function useGetLibraryItemsQuery({
|
||||
|
||||
return {
|
||||
isValidating,
|
||||
articlesPages: responsePages || undefined,
|
||||
articlesDataError: responseError,
|
||||
itemsPages: responsePages || undefined,
|
||||
itemsDataError: responseError,
|
||||
isLoading: !error && !data,
|
||||
performActionOnItem,
|
||||
size,
|
||||
|
||||
71
packages/web/stories/HighlightItemCard.stories.tsx
Normal file
71
packages/web/stories/HighlightItemCard.stories.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
import { HighlightItemCard, HighlightItemCardProps } from '../components/patterns/LibraryCards/HighlightItemCard'
|
||||
import { updateThemeLocally } from '../lib/themeUpdater'
|
||||
import { ThemeId } from '../components/tokens/stitches.config'
|
||||
import { PageType, State } from '../lib/networking/fragments/articleFragment'
|
||||
|
||||
export default {
|
||||
title: 'Components/HighlightItemCard',
|
||||
component: HighlightItemCard,
|
||||
argTypes: {
|
||||
item: {
|
||||
description: 'The highlight.',
|
||||
},
|
||||
handleAction: {
|
||||
description: 'Action that fires on click.'
|
||||
}
|
||||
}
|
||||
} as ComponentMeta<typeof HighlightItemCard>
|
||||
|
||||
const highlight: HighlightItemCardProps = {
|
||||
handleAction: () => console.log('Handling Action'),
|
||||
item:{
|
||||
id: "nnnnn",
|
||||
shortId: "shortId",
|
||||
quote: "children not only participate in herding work, but are also encouraged to act independently in most other areas of life. They have a say in deciding when to eat, when to sleep, and what to wear, even at temperatures of -30C (-22F).",
|
||||
annotation: "Okay… this is wild! I love this independence. Wondering how I can reponsibly instill this type of indepence in my own kids…",
|
||||
createdAt: '',
|
||||
description: '',
|
||||
isArchived: false,
|
||||
originalArticleUrl: 'https://example.com',
|
||||
ownedByViewer: true,
|
||||
pageId: '1',
|
||||
readingProgressAnchorIndex: 12,
|
||||
readingProgressPercent: 50,
|
||||
slug: 'slug',
|
||||
title: "This is a title",
|
||||
uploadFileId: '1',
|
||||
url: 'https://example.com',
|
||||
author: 'Author',
|
||||
image: 'https://logos-world.net/wp-content/uploads/2021/11/Unity-New-Logo.png',
|
||||
state: State.SUCCEEDED,
|
||||
pageType: PageType.HIGHLIGHTS,
|
||||
},
|
||||
}
|
||||
|
||||
const Template = (props: HighlightItemCardProps) => <HighlightItemCard {...props} />
|
||||
|
||||
export const LightHighlightItemCard: ComponentStory<
|
||||
typeof HighlightItemCard
|
||||
> = (args: any) => {
|
||||
updateThemeLocally(ThemeId.Light)
|
||||
return (
|
||||
<Template {...args}/>
|
||||
)
|
||||
}
|
||||
export const DarkHighlightItemCard: ComponentStory<
|
||||
typeof HighlightItemCard
|
||||
> = (args: any) => {
|
||||
updateThemeLocally(ThemeId.Dark)
|
||||
return (
|
||||
<Template {...args}/>
|
||||
)
|
||||
}
|
||||
|
||||
LightHighlightItemCard.args = {
|
||||
...highlight
|
||||
}
|
||||
|
||||
DarkHighlightItemCard.args = {
|
||||
...highlight
|
||||
}
|
||||
Reference in New Issue
Block a user