Merge commit 'a657da2b5f59a9de2a302590df5361e3954fb5df' into OMN-506

This commit is contained in:
gitstart-omnivore
2022-05-04 20:48:38 +00:00
37 changed files with 666 additions and 330 deletions

View File

@ -6,7 +6,7 @@ import Views
struct FeedCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
let item: LinkedItem
@ObservedObject var item: LinkedItem
@ObservedObject var viewModel: HomeFeedViewModel

View File

@ -66,6 +66,7 @@ struct WebReaderContent {
contentReader: "WEB",
readingProgressPercent: \(item.readingProgress),
readingProgressAnchorIndex: \(item.readingProgressAnchor),
labels: \(item.labelsJSONString),
highlights: \(highlightsJSONString),
}

View File

@ -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(

View File

@ -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> {

View File

@ -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,

View File

@ -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

View File

@ -377,13 +377,7 @@ export const searchPages = async (
},
],
should: [],
must_not: [
{
term: {
state: ArticleSavingRequestStatus.Failed,
},
},
],
must_not: [],
},
},
sort: [

View File

@ -196,7 +196,7 @@ export interface Page {
createdAt: Date
updatedAt?: Date
publishedAt?: Date
savedAt?: Date
savedAt: Date
sharedAt?: Date
archivedAt?: Date | null
siteName?: string

View File

@ -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]

View File

@ -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] }
})

View File

@ -155,6 +155,7 @@ export function pdfAttachmentsRouter() {
slug: generateSlug(title),
id: '',
createdAt: new Date(),
savedAt: new Date(),
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
state: ArticleSavingRequestStatus.Succeeded,

View File

@ -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)
}

View File

@ -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,

View File

@ -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,

View File

@ -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({

View File

@ -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 => {

View File

@ -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',

View File

@ -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,

View File

@ -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 () => {

View File

@ -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,

View File

@ -34,6 +34,7 @@ const App = () => {
>
<ArticleContainer
article={window.omnivoreArticle}
labels={window.omnivoreArticle.labels}
scrollElementRef={React.createRef()}
isAppleAppEmbed={true}
highlightBarDisabled={true}

View File

@ -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)
})

View File

@ -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;

View File

@ -177,3 +177,7 @@ export const StyledImg = styled('img', {
export const StyledAnchor = styled('a', {
textDecoration: 'none'
})
export const StyledMark = styled('mark', {
})

View File

@ -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) => (

View File

@ -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>
)
}

View File

@ -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 {

View File

@ -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,
])

View File

@ -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 ?? ''}

View File

@ -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)
}}
/>

View File

@ -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}

View File

@ -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}

View File

@ -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>
)
}

View File

@ -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

View File

@ -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,

View 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
}