Merge main

This commit is contained in:
Thomas Rogers 2023-08-30 19:48:04 +02:00
commit a58864fe2c
31 changed files with 890 additions and 1097 deletions

File diff suppressed because one or more lines are too long

View file

@ -159,8 +159,8 @@ public struct GridCard: View {
// .onTapGesture { tapHandler() }
}
if let status = item.serverSyncStatus, status != ServerSyncStatus.isNSync.rawValue {
SyncStatusIcon(status: ServerSyncStatus(rawValue: Int(status)) ?? ServerSyncStatus.isNSync)
if item.serverSyncStatus != ServerSyncStatus.isNSync.rawValue {
SyncStatusIcon(status: ServerSyncStatus(rawValue: Int(item.serverSyncStatus)) ?? ServerSyncStatus.isNSync)
}
}
.padding(.horizontal, 0)

View file

@ -89,7 +89,8 @@ public struct FeedCard: View {
#endif
}
if let recs = Recommendation.notViewers(viewer: viewer, item.recommendations), recs.count > 0 {
let recs = Recommendation.notViewers(viewer: viewer, item.recommendations)
if recs.count > 0 {
let byStr = Recommendation.byline(recs)
let inStr = Recommendation.groupsLine(recs)
HStack {

File diff suppressed because one or more lines are too long

View file

@ -31,6 +31,7 @@ import { Merge } from '../../util'
import { analytics } from '../../utils/analytics'
import { enqueueRssFeedFetch } from '../../utils/createTask'
import { authorized } from '../../utils/helpers'
import { Brackets } from 'typeorm'
type PartialSubscription = Omit<Subscription, 'newsletterEmail'>
@ -47,61 +48,71 @@ export const subscriptionsResolver = authorized<
SubscriptionsSuccessPartial,
SubscriptionsError,
QuerySubscriptionsArgs
>(
async (
_obj,
{ sort, type = SubscriptionType.Newsletter }, // default to newsletter
{ claims: { uid }, log }
) => {
log.info('subscriptionsResolver')
>(async (_obj, { sort, type }, { claims: { uid }, log }) => {
log.info('subscriptionsResolver')
analytics.track({
userId: uid,
event: 'subscriptions',
properties: {
env: env.server.apiEnv,
},
})
try {
const sortBy =
sort?.by === SortBy.UpdatedTime ? 'lastFetchedAt' : 'createdAt'
const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC'
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [SubscriptionsErrorCode.Unauthorized],
}
}
const queryBuilder = getRepository(Subscription)
.createQueryBuilder('subscription')
.leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail')
.where({
user: { id: uid },
type,
})
// only return active subscriptions for newsletter
if (type === SubscriptionType.Newsletter) {
queryBuilder.andWhere({ status: SubscriptionStatus.Active })
}
const subscriptions = await queryBuilder
.orderBy('subscription.' + sortBy, sortOrder)
.getMany()
analytics.track({
userId: uid,
event: 'subscriptions',
properties: {
env: env.server.apiEnv,
},
})
try {
const sortBy =
sort?.by === SortBy.UpdatedTime ? 'lastFetchedAt' : 'createdAt'
const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC'
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
subscriptions,
}
} catch (error) {
log.error(error)
return {
errorCodes: [SubscriptionsErrorCode.BadRequest],
errorCodes: [SubscriptionsErrorCode.Unauthorized],
}
}
const queryBuilder = getRepository(Subscription)
.createQueryBuilder('subscription')
.leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail')
.where({
user: { id: uid },
})
if (type && type == SubscriptionType.Newsletter) {
queryBuilder.andWhere({
type,
status: SubscriptionStatus.Active,
})
} else if (type && type == SubscriptionType.Rss) {
queryBuilder.andWhere({
type,
})
} else {
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where({
type: SubscriptionType.Newsletter,
status: SubscriptionStatus.Active,
}).orWhere({
type: SubscriptionType.Rss,
})
})
)
}
const subscriptions = await queryBuilder
.orderBy('subscription.' + sortBy, sortOrder)
.getMany()
return {
subscriptions,
}
} catch (error) {
log.error(error)
return {
errorCodes: [SubscriptionsErrorCode.BadRequest],
}
}
)
})
export type UnsubscribeSuccessPartial = Merge<
UnsubscribeSuccess,

View file

@ -10,7 +10,10 @@ import {
SubscriptionStatus,
SubscriptionType,
} from '../../src/generated/graphql'
import { UNSUBSCRIBE_EMAIL_TEXT } from '../../src/services/subscriptions'
import {
UNSUBSCRIBE_EMAIL_TEXT,
unsubscribe,
} from '../../src/services/subscriptions'
import * as sendEmail from '../../src/utils/sendEmail'
import { createTestSubscription, createTestUser, deleteTestUser } from '../db'
import { graphqlRequest, request } from '../util'
@ -49,7 +52,7 @@ describe('Subscriptions API', () => {
SubscriptionStatus.Unsubscribed
)
// create an rss feed subscription
await createTestSubscription(
const sub4 = await createTestSubscription(
user,
'sub_4',
undefined,
@ -57,7 +60,7 @@ describe('Subscriptions API', () => {
undefined,
SubscriptionType.Rss
)
subscriptions = [sub2, sub1]
subscriptions = [sub4, sub2, sub1]
})
after(async () => {
@ -88,7 +91,6 @@ describe('Subscriptions API', () => {
it('should return subscriptions', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.subscriptions.subscriptions).to.eql(
subscriptions.map((sub) => ({
id: sub.id,
@ -97,6 +99,174 @@ describe('Subscriptions API', () => {
)
})
it('should return only newsletters when type newsletter supplied', async () => {
query = `
query {
subscriptions(type: NEWSLETTER) {
... on SubscriptionsSuccess {
subscriptions {
id
name
}
}
... on SubscriptionsError {
errorCodes
}
}
}
`
const newsletters = subscriptions.filter(
(s) => s.type == SubscriptionType.Newsletter
)
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.subscriptions.subscriptions).to.eql(
newsletters.map((sub) => ({
id: sub.id,
name: sub.name,
}))
)
})
it('should not return inactive newsletters but should return inactive RSS', async () => {
const sub5 = await createTestSubscription(
user,
'sub_5',
undefined,
SubscriptionStatus.Unsubscribed,
undefined,
SubscriptionType.Rss
)
try {
await createTestSubscription(
user,
'sub_6',
undefined,
SubscriptionStatus.Unsubscribed,
undefined,
SubscriptionType.Newsletter
)
const allSubscriptions = [sub5, ...subscriptions]
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.subscriptions.subscriptions).to.eql(
allSubscriptions.map((sub) => ({
id: sub.id,
name: sub.name,
}))
)
} finally {
unsubscribe(sub5)
}
})
it('should not return other users subscriptions', async () => {
// create test user and login
const user2 = await createTestUser('fakeUser2')
try {
await createTestSubscription(
user2,
'sub_other',
undefined,
SubscriptionStatus.Unsubscribed,
undefined,
SubscriptionType.Rss
)
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.subscriptions.subscriptions).to.eql(
subscriptions.map((sub) => ({
id: sub.id,
name: sub.name,
}))
)
} finally {
deleteTestUser(user2.id)
}
})
it('should not return other users subscriptions when type is set to RSS', async () => {
query = `
query {
subscriptions(type: RSS) {
... on SubscriptionsSuccess {
subscriptions {
id
name
}
}
... on SubscriptionsError {
errorCodes
}
}
}
`
const user2 = await createTestUser('fakeUser2')
try {
await createTestSubscription(
user2,
'sub_other',
undefined,
SubscriptionStatus.Unsubscribed,
undefined,
SubscriptionType.Rss
)
const rssItems = subscriptions.filter(
(s) => s.type == SubscriptionType.Rss
)
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.subscriptions.subscriptions).to.eql(
rssItems.map((sub) => ({
id: sub.id,
name: sub.name,
}))
)
} finally {
deleteTestUser(user2.id)
}
})
it('should not return other users subscriptions when type is set to NEWSLETTER', async () => {
query = `
query {
subscriptions(type: NEWSLETTER) {
... on SubscriptionsSuccess {
subscriptions {
id
name
}
}
... on SubscriptionsError {
errorCodes
}
}
}
`
const user2 = await createTestUser('fakeUser2')
try {
await createTestSubscription(
user2,
'sub_other',
undefined,
SubscriptionStatus.Unsubscribed,
undefined,
SubscriptionType.Rss
)
const newsletters = subscriptions.filter(
(s) => s.type == SubscriptionType.Newsletter
)
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.subscriptions.subscriptions).to.eql(
newsletters.map((sub) => ({
id: sub.id,
name: sub.name,
}))
)
} finally {
deleteTestUser(user2.id)
}
})
it('responds status code 400 when invalid query', async () => {
const invalidQuery = `
query {

View file

@ -10,7 +10,7 @@ export class ScrapingBeeHandler extends ContentHandler {
shouldPreHandle(url: string): boolean {
const u = new URL(url)
const hostnames = ['nytimes.com', 'news.google.com']
const hostnames = ['nytimes.com', 'news.google.com', 'fool.ca']
return hostnames.some((h) => u.hostname.endsWith(h))
}

View file

@ -164,7 +164,11 @@ export default function ExtensionsInstallHelp({
to Omnivore from your computer.
<br />
{!onboarding && (
<Link passHref href="/help/saving-links">
<a
href="https://docs.omnivore.app/using/saving.html"
target="_blank"
rel="noreferrer"
>
<StyledAnchor
css={{
color: '$grayTextContrast',
@ -175,7 +179,7 @@ export default function ExtensionsInstallHelp({
>
Learn more about the browser extension -&gt;
</StyledAnchor>
</Link>
</a>
)}
</StyledText>
<HStack

View file

@ -23,6 +23,8 @@ export function InfoLink(props: InfoLinkProps): JSX.Element {
<a
href={props.href}
style={{ textDecoration: 'none', width: '24px', height: '24px' }}
target="_blank"
rel="noreferrer"
>
<TooltipWrapped
tooltipContent="Learn More"

View file

@ -149,7 +149,11 @@ export default function MobileInstallHelp({
share extension.
<br />
{!onboarding && (
<Link passHref href="/help/saving-links">
<a
href="https://docs.omnivore.app/using/saving.html"
target="_blank"
rel="noreferrer"
>
<StyledAnchor
css={{
color: '$grayTextContrast',
@ -160,7 +164,7 @@ export default function MobileInstallHelp({
>
Learn more about the iOS app -&gt;
</StyledAnchor>
</Link>
</a>
)}
</StyledText>
<HStack

View file

@ -0,0 +1,43 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class ArrowRightIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
>
<g>
<path
d="M3.33398 8H12.6673"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 10.6667L12.6667 8"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 5.33203L12.6667 7.9987"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -0,0 +1,32 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class ToggleCaretDownIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
width={size}
height={size}
viewBox="0 0 26 26"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M6.57812 10.3379L12.8281 16.5879L19.0781 10.3379"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -0,0 +1,32 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class ToggleCaretLeftIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
width={size}
height={size}
viewBox="0 0 26 26"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M15.9531 6.77344L9.70312 13.0234L15.9531 19.2734"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -0,0 +1,32 @@
/* eslint-disable functional/no-class */
/* eslint-disable functional/no-this-expression */
import { IconProps } from './IconProps'
import React from 'react'
export class ToggleCaretRightIcon extends React.Component<IconProps> {
render() {
const size = (this.props.size || 26).toString()
const color = (this.props.color || '#2A2A2A').toString()
return (
<svg
width={size}
height={size}
viewBox="0 0 26 26"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M9.70313 19.5742L15.9531 13.3242L9.70312 7.07422"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
)
}
}

View file

@ -1,8 +1,4 @@
import {
VStack,
HStack,
SpanBox,
} from '../elements/LayoutPrimitives'
import { VStack, HStack, SpanBox } from '../elements/LayoutPrimitives'
import { StyledText } from '../elements/StyledText'
import Link from 'next/link'
import { Button } from '../elements/Button'
@ -22,11 +18,14 @@ export function ErrorLayout(props: ErrorLayoutProps): JSX.Element {
return (
<VStack alignment="center" distribution="start" css={{ height: '100%' }}>
<HStack alignment="center" css={{ mt: '64px', verticalAlign: 'middle' }}>
<StyledText style="headline" css={{
marginRight: '25px',
padding: '32px',
borderRight: '1px solid $grayText',
}}>
<StyledText
style="headline"
css={{
marginRight: '25px',
padding: '32px',
borderRight: '1px solid $grayText',
}}
>
{props.statusCode}
</StyledText>
<StyledText style="body">
@ -34,9 +33,11 @@ export function ErrorLayout(props: ErrorLayoutProps): JSX.Element {
</StyledText>
</HStack>
<SpanBox css={{ height: '64px' }} />
<Link passHref href={viewerData?.me ? "/home" : "/login"}>
<Button style="ctaDarkYellow">{viewerData?.me ? "Go Home" : "Login"}</Button>
<Link passHref href={viewerData?.me ? '/home' : '/login'}>
<Button style="ctaDarkYellow">
{viewerData?.me ? 'Go Home' : 'Login'}
</Button>
</Link>
</VStack>
)
}
}

View file

@ -472,7 +472,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
async (action: HighlightAction, param?: string) => {
switch (action) {
case 'delete':
if (focusedHighlight?.annotation == undefined) {
if ((focusedHighlight?.annotation ?? '').length === 0) {
await removeHighlightCallback()
} else {
setConfirmDeleteHighlightWithNoteId(focusedHighlight?.id)

View file

@ -1,51 +1,237 @@
import Link from 'next/link'
import { Book } from 'phosphor-react'
import { Button } from '../../elements/Button'
import { VStack } from '../../elements/LayoutPrimitives'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { StyledText } from '../../elements/StyledText'
import { theme } from '../../tokens/stitches.config'
import { useMemo } from 'react'
import { searchQuery } from '../../../lib/networking/queries/search'
import { LIBRARY_LEFT_MENU_WIDTH } from './LibraryFilterMenu'
import { LayoutType } from './HomeFeedContainer'
import { ArrowRightIcon } from '../../elements/icons/ArrowRightIcon'
type EmptyLibraryProps = {
searchTerm: string | undefined
onAddLinkClicked: () => void
layoutType: LayoutType
}
export function EmptyLibrary(props: EmptyLibraryProps): JSX.Element {
type MessageType = 'feed' | 'newsletter' | 'library'
type HelpMessageProps = {
type: MessageType
}
const HelpMessage = (props: HelpMessageProps) => {
switch (props.type) {
case 'library':
return (
<>
You can add a link or read more about Omnivore&apos;s{' '}
<a
href="https://docs.omnivore.app/using/search.html"
target="_blank"
rel="noreferrer"
>
advanced search
</a>
.
</>
)
case 'feed':
return (
<>
You can subscribe to RSS feeds using the{' '}
<Link href="/settings/feeds" passHref>
feeds page
</Link>
. Learn more about feeds at &apos;s{' '}
<a
href="https://docs.omnivore.app/using/feeds.html"
target="_blank"
rel="noreferrer"
>
docs.omnivore.app/using/feeds.html
</a>
.
</>
)
case 'newsletter':
return (
<>
Create email addresses that can be used to subscribe to newsletters on
the{' '}
<Link href="/settings/emails" passHref>
emails page
</Link>
. Learn more about reading newsletters in Omnivore at &apos;s{' '}
<a
href="https://docs.omnivore.app/using/inbox.html"
target="_blank"
rel="noreferrer"
>
docs.omnivore.app/using/inbox.html
</a>
.
</>
)
}
return <></>
}
export const ErrorBox = (props: HelpMessageProps) => {
const errorTitle = useMemo(() => {
switch (props.type) {
case 'feed':
return 'You do not have any feed items matching this query.'
case 'newsletter':
return 'You do not have any newsletter item matching this query.'
}
return 'No results found for this query.'
}, [props.type])
return (
<VStack
alignment="center"
distribution="center"
<Box
css={{
color: '$grayTextContrast',
textAlign: 'center',
paddingTop: '88px',
flex: '1',
width: 'fit-content',
borderRadius: '5px',
background: 'rgba(255, 59, 48, 0.3)',
fontSize: '15px',
fontFamily: '$inter',
fontWeight: '500',
color: '$thTextContrast',
padding: '10px',
'@smDown': {
width: '100%',
},
'@xlgDown': {
justifyContent: 'flex-start',
},
}}
>
<Book size={44} color={theme.colors.grayTextContrast.toString()} />
<StyledText style="fixedHeadline" css={{ color: '$grayTextContrast' }}>
No results found.
</StyledText>
<StyledText style="footnote" css={{ color: '$grayTextContrast' }}>
You can add a link or read more about Omnivore&apos;s{' '}
<a
href="https://docs.omnivore.app/using/search.html"
target="_blank"
rel="noreferrer"
>
advanced search
</a>
.
</StyledText>
<Button
style="ctaDarkYellow"
onClick={() => {
props.onAddLinkClicked()
}}
>
Add Link
</Button>
</VStack>
{errorTitle}
</Box>
)
}
export const SuggestionBox = (props: HelpMessageProps) => {
const helpMessage = useMemo(() => {
switch (props.type) {
case 'feed':
return 'Want to add an RSS or Atom Subscription?'
case 'newsletter':
return 'Create an Omnivore email address and subscribe to newsletters.'
}
return "Add a link or read more about Omnivore's Advanced Search."
}, [props.type])
const helpTarget = useMemo(() => {
switch (props.type) {
case 'feed':
return '/settings/feeds'
case 'newsletter':
return '/settings/emails'
}
return 'https://docs.omnivore.app/'
}, [props.type])
return (
<HStack
css={{
gap: '10px',
width: 'fit-content',
borderRadius: '5px',
background: '$thBackground3',
fontSize: '15px',
fontFamily: '$inter',
fontWeight: '500',
color: '$thTextContrast',
padding: '10px',
justifyContent: 'flex-start',
'@smDown': {
flexDirection: 'column',
alignItems: 'center',
width: '100%',
},
}}
>
{helpMessage}
<SpanBox css={{ cursor: 'pointer' }}>
<Link href={helpTarget} passHref>
<SpanBox
css={{
display: 'flex',
alignItems: 'center',
color: '$omnivoreCtaYellow',
gap: '2px',
'&:hover': {
textDecoration: 'underline',
},
}}
>
<>Click Here</>
<ArrowRightIcon
size={25}
color={theme.colors.omnivoreCtaYellow.toString()}
/>
</SpanBox>
</Link>
</SpanBox>
</HStack>
)
}
export const EmptyLibrary = (props: EmptyLibraryProps) => {
const type = useMemo<MessageType>(() => {
if (props.searchTerm) {
switch (props.searchTerm) {
case 'label:RSS':
return 'feed'
case 'label:Newsletter':
return 'newsletter'
}
}
return 'library'
}, [props])
return (
<Box
css={{
display: 'inline-flex',
color: '$grayTextContrast',
gap: '10px',
pl: '0px',
width: '100%',
'@media (max-width: 1300px)': {
flexDirection: 'column',
},
'@media (max-width: 768px)': {
p: '15px',
},
'@media (min-width: 768px)': {
pl: '15px',
width: `calc(100vw - ${LIBRARY_LEFT_MENU_WIDTH})`,
},
'@media (min-width: 930px)': {
pl: '0px',
width: props.layoutType == 'GRID_LAYOUT' ? '660px' : '640px',
},
'@media (min-width: 1280px)': {
pl: '0px',
width: '1000px',
},
'@media (min-width: 1600px)': {
pl: '0px',
width: '1340px',
},
}}
>
<ErrorBox type={type} />
<SuggestionBox type={type} />
</Box>
)
}

View file

@ -2,7 +2,7 @@ import { Action, createAction, useKBar, useRegisterActions } from 'kbar'
import debounce from 'lodash/debounce'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import toast, { Toaster } from "react-hot-toast"
import toast, { Toaster } from 'react-hot-toast'
import TopBarProgress from 'react-topbar-progress-indicator'
import { useFetchMore } from '../../../lib/hooks/useFetchMoreScroll'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
@ -48,8 +48,9 @@ import {
} from '../../../lib/toastHelpers'
import { SetPageLabelsModalPresenter } from '../article/SetLabelsModalPresenter'
import { NotebookPresenter } from '../article/NotebookPresenter'
import { saveUrlMutation } from "../../../lib/networking/mutations/saveUrlMutation"
import { articleQuery } from "../../../lib/networking/queries/useGetArticleQuery"
import { saveUrlMutation } from '../../../lib/networking/mutations/saveUrlMutation'
import { articleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
import { searchQuery } from '../../../lib/networking/queries/search'
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
export type LibraryMode = 'reads' | 'highlights'
@ -70,7 +71,7 @@ const debouncedFetchSearchResults = debounce((query, cb) => {
// We set a relatively high delay for the refresh at the end, as it's likely there's an issue
// in processing. We give it the best attempt to be able to resolve, but if it doesn't we set
// the state as Failed. On refresh it will try again if the backend sends "PROCESSING"
const TIMEOUT_DELAYS = [1000, 2000, 2500, 3500, 5000, 10000, 60000];
const TIMEOUT_DELAYS = [1000, 2000, 2500, 3500, 5000, 10000, 60000]
export function HomeFeedContainer(): JSX.Element {
const { viewerData } = useGetViewerQuery()
@ -179,58 +180,69 @@ export function HomeFeedContainer(): JSX.Element {
return itemsPages[itemsPages.length - 1].search.pageInfo.hasNextPage
}, [itemsPages])
const libraryItems = useMemo(() => {
const items =
itemsPages?.flatMap((ad) => {
return ad.search.edges.map(it => ({ ...it, isLoading: it.node.state === 'PROCESSING'}));
return ad.search.edges.map((it) => ({
...it,
isLoading: it.node.state === 'PROCESSING',
}))
}) || []
return items
}, [itemsPages, performActionOnItem])
useEffect(() => {
const timeout : NodeJS.Timeout[] = []
const timeout: NodeJS.Timeout[] = []
const items =
(itemsPages?.flatMap((ad) => {
return ad.search.edges.map(it => ({ ...it, isLoading: it.node.state === 'PROCESSING'}));
}) || [])
.filter(it => it.isLoading);
const items = (
itemsPages?.flatMap((ad) => {
return ad.search.edges.map((it) => ({
...it,
isLoading: it.node.state === 'PROCESSING',
}))
}) || []
).filter((it) => it.isLoading)
items.map(async (item) => {
let startIdx = 0;
let startIdx = 0
const seeIfUpdated = async () => {
if (startIdx > TIMEOUT_DELAYS.length) {
item.node.state = State.FAILED;
item.node.state = State.FAILED
return
}
const username = viewerData?.me?.profile.username
const itemsToUpdate = libraryItems.filter(it => it.isLoading);
const itemsToUpdate = libraryItems.filter((it) => it.isLoading)
if (itemsToUpdate.length > 0) {
const link = await articleQuery({ username, slug: item.node.slug, includeFriendsHighlights: false })
const link = await articleQuery({
username,
slug: item.node.slug,
includeFriendsHighlights: false,
})
if (link && link.state != "PROCESSING") {
const updatedArticle = { ...item };
if (link && link.state != 'PROCESSING') {
const updatedArticle = { ...item }
updatedArticle.node = { ...item.node, ...link }
updatedArticle.isLoading = false;
updatedArticle.isLoading = false
console.log(`Updating Metadata of ${item.node.slug}.`)
performActionOnItem('update-item', updatedArticle);
return;
performActionOnItem('update-item', updatedArticle)
return
}
console.log(`Trying to get the metadata of item ${item.node.slug}... Retry ${startIdx} of 5`);
console.log(
`Trying to get the metadata of item ${item.node.slug}... Retry ${startIdx} of 5`
)
timeout.push(setTimeout(seeIfUpdated, TIMEOUT_DELAYS[startIdx++]))
}
}
await seeIfUpdated();
});
await seeIfUpdated()
})
return () => {
timeout.forEach(clearTimeout);
timeout.forEach(clearTimeout)
}
}, [itemsPages])
@ -769,36 +781,39 @@ export function HomeFeedContainer(): JSX.Element {
[itemsPages, multiSelectMode, checkedItems]
)
const handleLinkSubmission =
async (link: string, timezone: string, locale: string) => {
const result = await saveUrlMutation(link, timezone, locale)
if (result) {
toast(
() => (
<Box>
Link Saved
<span style={{ padding: '16px' }} />
<Button
style="ctaDarkYellow"
autoFocus
onClick={() => {
window.location.href = `/article?url=${encodeURIComponent(
link
)}`
}}
>
Read Now
</Button>
</Box>
),
{ position: 'bottom-right' }
)
const id = result.url?.match(/[^/]+$/)?.[0] ?? "";
performActionOnItem('refresh', undefined as unknown as any)
} else {
showErrorToast('Error saving link', { position: 'bottom-right' })
}
};
const handleLinkSubmission = async (
link: string,
timezone: string,
locale: string
) => {
const result = await saveUrlMutation(link, timezone, locale)
if (result) {
toast(
() => (
<Box>
Link Saved
<span style={{ padding: '16px' }} />
<Button
style="ctaDarkYellow"
autoFocus
onClick={() => {
window.location.href = `/article?url=${encodeURIComponent(
link
)}`
}}
>
Read Now
</Button>
</Box>
),
{ position: 'bottom-right' }
)
const id = result.url?.match(/[^/]+$/)?.[0] ?? ''
performActionOnItem('refresh', undefined as unknown as any)
} else {
showErrorToast('Error saving link', { position: 'bottom-right' })
}
}
return (
<HomeFeedGrid
@ -902,7 +917,11 @@ type HomeFeedContentProps = {
item: LibraryItem | undefined
) => Promise<void>
handleLinkSubmission: (link: string, timezone: string, locale:string) => Promise<void>,
handleLinkSubmission: (
link: string,
timezone: string,
locale: string
) => Promise<void>
setIsChecked: (itemId: string, set: boolean) => void
itemIsChecked: (itemId: string) => boolean
@ -986,7 +1005,10 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
)}
{props.showAddLinkModal && (
<AddLinkModal handleLinkSubmission={props.handleLinkSubmission} onOpenChange={() => props.setShowAddLinkModal(false)} />
<AddLinkModal
handleLinkSubmission={props.handleLinkSubmission}
onOpenChange={() => props.setShowAddLinkModal(false)}
/>
)}
</HStack>
</VStack>
@ -1044,6 +1066,8 @@ function LibraryItemsLayout(props: LibraryItemsLayoutProps): JSX.Element {
>
{!props.isValidating && props.items.length == 0 ? (
<EmptyLibrary
layoutType={props.layout}
searchTerm={props.searchTerm}
onAddLinkClicked={() => {
props.setShowAddLinkModal(true)
}}

View file

@ -1,9 +1,8 @@
import { ReactNode, useMemo } from 'react'
import { StyledText } from '../../elements/StyledText'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
import { Button } from '../../elements/Button'
import { CaretRight, Circle, DotsThree } from 'phosphor-react'
import { CaretRight, Circle } from 'phosphor-react'
import { useGetSubscriptionsQuery } from '../../../lib/networking/queries/useGetSubscriptionsQuery'
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
import { Label } from '../../../lib/networking/fragments/labelFragment'
@ -13,6 +12,11 @@ import { LogoBox } from '../../elements/LogoBox'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
import { useGetSavedSearchQuery} from '../../../lib/networking/queries/useGetSavedSearchQuery'
import { SavedSearch } from "../../../lib/networking/fragments/savedSearchFragment"
import { ToggleCaretDownIcon } from '../../elements/icons/ToggleCaretDownIcon'
import { ToggleCaretLeftIcon } from '../../elements/icons/ToggleCaretLeftIcon'
import Link from 'next/link'
import { ArrowRightIcon } from '../../elements/icons/ArrowRightIcon'
import { ToggleCaretRightIcon } from '../../elements/icons/ToggleCaretRightIcon'
export const LIBRARY_LEFT_MENU_WIDTH = '233px'
@ -109,11 +113,19 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
[isLoading]
)
const [collapsed, setCollapsed] = usePersistedState<boolean>({
key: `--saved-searches-collapsed`,
initialValue: false,
})
return (
<MenuPanel title="Saved Searches" editTitle="Edit Saved Searches" editFunc={() => {
window.location.href = '/settings/saved-searches'
}}>
{sortedSearches && sortedSearches?.map((item) => (
<MenuPanel title="Saved Searches"
editTitle="Edit Saved Searches"
editFunc={() => window.location.href = '/settings/saved-searches' }
collapsed={collapsed}
setCollapsed={setCollapsed}
>
{!collapsed && sortedSearches && sortedSearches?.map((item) => (
<FilterButton
key={item.name}
text={item.name}
@ -129,8 +141,8 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
const { subscriptions } = useGetSubscriptionsQuery()
const [viewAll, setViewAll] = usePersistedState<boolean>({
key: `--subscriptions-view-all`,
const [collapsed, setCollapsed] = usePersistedState<boolean>({
key: `--subscriptions-collapsed`,
initialValue: false,
})
@ -151,37 +163,46 @@ function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
[subscriptions]
)
if (!subscriptions || subscriptions.length < 1) {
return <></>
}
return (
<MenuPanel
title="Subscriptions"
editTitle="Edit Subscriptions"
editFunc={() => {
window.location.href = '/settings/subscriptions'
}}
collapsed={collapsed}
setCollapsed={setCollapsed}
>
{subscriptions.slice(0, viewAll ? undefined : 4).map((item) => {
return (
{!collapsed ? (
<>
<FilterButton filterTerm={`label:RSS`} text="Feeds" {...props} />
<FilterButton
key={item.id}
filterTerm={`subscription:\"${item.name}\"`}
text={item.name}
filterTerm={`label:Newsletter`}
text="Newsletters"
{...props}
/>
)
})}
<ViewAllButton state={viewAll} setState={setViewAll} />
{(subscriptions ?? []).map((item) => {
return (
<FilterButton
key={item.id}
filterTerm={`subscription:\"${item.name}\"`}
text={item.name}
{...props}
/>
)
})}
<EditButton
title="Edit Subscriptions"
destination="/settings/subscriptions"
/>
</>
) : (
<SpanBox css={{ mb: '10px' }} />
)}
</MenuPanel>
)
}
function Labels(props: LibraryFilterMenuProps): JSX.Element {
const { labels } = useGetLabelsQuery()
const [viewAll, setViewAll] = usePersistedState<boolean>({
key: `--labels-view-all`,
const [collapsed, setCollapsed] = usePersistedState<boolean>({
key: `--labels-collapsed`,
initialValue: false,
})
@ -196,14 +217,17 @@ function Labels(props: LibraryFilterMenuProps): JSX.Element {
title="Labels"
editTitle="Edit Labels"
hideBottomBorder={true}
editFunc={() => {
window.location.href = '/settings/labels'
}}
collapsed={collapsed}
setCollapsed={setCollapsed}
>
{sortedLabels.slice(0, viewAll ? undefined : 4).map((item) => {
return <LabelButton key={item.id} label={item} {...props} />
})}
<ViewAllButton state={viewAll} setState={setViewAll} />
{!collapsed && (
<>
{sortedLabels.map((item) => {
return <LabelButton key={item.id} label={item} {...props} />
})}
<EditButton title="Edit Labels" destination="/settings/labels" />
</>
)}
</MenuPanel>
)
}
@ -214,6 +238,8 @@ type MenuPanelProps = {
editFunc?: () => void
editTitle?: string
hideBottomBorder?: boolean
collapsed: boolean
setCollapsed: (collapsed: boolean) => void
}
function MenuPanel(props: MenuPanelProps): JSX.Element {
@ -230,7 +256,7 @@ function MenuPanel(props: MenuPanelProps): JSX.Element {
alignment="start"
distribution="start"
>
<HStack css={{ width: '100%' }} distribution="start" alignment="start">
<HStack css={{ width: '100%' }} distribution="start" alignment="center">
<StyledText
css={{
fontFamily: 'Inter',
@ -239,54 +265,40 @@ function MenuPanel(props: MenuPanelProps): JSX.Element {
lineHeight: '125%',
color: '$thLibraryMenuPrimary',
pl: '10px',
my: '20px',
mt: '20px',
mb: '10px',
}}
>
{props.title}
</StyledText>
<SpanBox
css={{
my: '15px',
marginLeft: 'auto',
display: 'flex',
height: '100%',
mt: '10px',
marginLeft: 'auto',
verticalAlign: 'middle',
}}
>
{props.editTitle && props.editFunc && (
<Dropdown
triggerElement={
<Box
css={{
display: 'flex',
height: '30px',
width: '30px',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '1000px',
cursor: 'pointer',
'&:hover': {
bg: '$thBackground4',
},
}}
>
<DotsThree
size={25}
weight="bold"
color={theme.colors.thTextSubtle2.toString()}
/>
</Box>
}
>
<DropdownOption
title={props.editTitle}
onSelect={() => {
if (props.editFunc) {
props.editFunc()
}
}}
<Button
style="articleActionIcon"
onClick={(event) => {
props.setCollapsed(!props.collapsed)
event.preventDefault()
}}
>
{props.collapsed ? (
<ToggleCaretRightIcon
size={15}
color={theme.colors.thLibraryMenuPrimary.toString()}
/>
</Dropdown>
)}
) : (
<ToggleCaretDownIcon
size={15}
color={theme.colors.thLibraryMenuPrimary.toString()}
/>
)}
</Button>
</SpanBox>
</HStack>
{props.children}
@ -450,34 +462,43 @@ function LabelButton(props: LabelButtonProps): JSX.Element {
)
}
type ViewAllButtonProps = {
state: boolean
setState: (state: boolean) => void
type EditButtonProps = {
title: string
destination: string
}
function ViewAllButton(props: ViewAllButtonProps): JSX.Element {
function EditButton(props: EditButtonProps): JSX.Element {
return (
<Button
style="ghost"
css={{
display: 'flex',
pl: '10px',
color: '#898989',
fontWeight: '600',
fontSize: '12px',
py: '20px',
gap: '2px',
alignItems: 'center',
}}
onClick={(e) => {
props.setState(!props.state)
e.preventDefault()
}}
>
{props.state ? 'Hide' : 'View All'}
{props.state ? null : (
<CaretRight size={12} color="#898989" weight="bold" />
)}
</Button>
<Link href={props.destination} passHref>
<SpanBox
css={{
ml: '10px',
mb: '10px',
display: 'flex',
alignItems: 'center',
gap: '2px',
'&:hover': {
textDecoration: 'underline',
},
width: '100%',
maxWidth: '100%',
height: '32px',
fontSize: '14px',
fontWeight: 'regular',
fontFamily: '$display',
color: '$thLibraryMenuUnselected',
verticalAlign: 'middle',
borderRadius: '3px',
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{props.title}
</SpanBox>
</Link>
)
}

View file

@ -12,6 +12,7 @@ export enum SubscriptionType {
export type Subscription = {
id: string
name: string
type: SubscriptionType
newsletterEmail?: string
url?: string
@ -38,16 +39,16 @@ type SubscriptionsData = {
}
export function useGetSubscriptionsQuery(
subscriptionType = SubscriptionType.NEWSLETTER,
sortBy = 'UPDATED_TIME'
): SubscriptionsQueryResponse {
const query = gql`
query GetSubscriptions {
subscriptions(sort: { by: ${sortBy} }, type: ${subscriptionType}) {
subscriptions(sort: { by: ${sortBy} }) {
... on SubscriptionsSuccess {
subscriptions {
id
name
type
newsletterEmail
url
description

View file

@ -1,66 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import { Box, HStack } from '../../components/elements/LayoutPrimitives'
import { SettingsLayout } from '../../components/templates/SettingsLayout'
import { Button } from '../../components/elements/Button'
import Link from 'next/link'
export default function Emails(): JSX.Element {
return (
<SettingsLayout>
<Box
css={{
m: '42px',
maxWidth: '640px',
color: '$grayText',
img: {
maxWidth: '85%',
},
'@smDown': {
m: '16px',
maxWidth: '85%',
alignSelf: 'center',
},
}}
>
<h1>Omnivore Email Addresses</h1>
<hr />
<p>
An Omnivore email address will receive email, detect whether the email
is a PDF document or newsletter, and add the content to your library.
</p>
<p>
If Omnivore doesn&apos;t think the item should be added to your
library, it will be forwarded to the email address you used when you
registered for Omnivore (from <code>msgs@omnivore.app</code>).
</p>
<h2>Sending PDFs to your Omnivore Email Address</h2>
<p>
Add PDFs to your Omnivore library by sending them to your Omnivore
email address. If there is a subject line in the email, it will be
used as the title of the PDF. If there is no subject line, the
filename will be used as the title.
</p>
<h2>Read all your newsletters in Omnivore</h2>
<p>
Subscribe to newsletters with your Omnivore email address and they
will be added to your library when we receive them.
</p>
<p>
<Link href="/help/newsletters">
Learn more about setting up newsletters
</Link>
</p>
<HStack alignment="center" css={{ mb: '32px', width: '100%' }}>
<Link passHref href="/settings/emails">
<Button style="ctaDarkYellow">Get Started</Button>
</Link>
</HStack>
</Box>
<Box css={{ height: '120px' }} />
</SettingsLayout>
)
}

View file

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

View file

@ -1,257 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import {
Box,
HStack,
SpanBox,
} from '../../components/elements/LayoutPrimitives'
import { SettingsLayout } from '../../components/templates/SettingsLayout'
import { Button } from '../../components/elements/Button'
import Link from 'next/link'
import { Copy, Plus } from 'phosphor-react'
import { theme } from '../../components/tokens/stitches.config'
const AddEmailButton = () => {
return (
<Button
style="ctaDarkYellow"
css={{
cursor: 'default',
display: 'inline-flex',
alignItems: 'center',
}}
>
<Plus size={18} style={{ marginRight: '6.5px' }} />
<SpanBox>Create a new email address</SpanBox>
</Button>
)
}
const CopyButton = () => {
return (
<Button
style="plainIcon"
css={{
pl: '2px',
pr: '4px',
cursor: 'default',
display: 'inline-flex',
}}
>
<Copy color={theme.colors.grayTextContrast.toString()} />
</Button>
)
}
export default function Newsletters(): JSX.Element {
return (
<SettingsLayout>
<Box
css={{
m: '42px',
maxWidth: '640px',
color: '$grayText',
img: {
maxWidth: '85%',
},
'@smDown': {
m: '16px',
maxWidth: '85%',
alignSelf: 'center',
},
}}
>
<h1>Read Newsletters in Omnivore</h1>
<hr />
<p>Omnivore supports newsletters from the following providers:</p>
<ul>
<li>
Newsletters hosted on{' '}
<a href="https://substack.com" target="_blank" rel="noreferrer">
substack.com
</a>
</li>
<li>
Newsletters hosted on{' '}
<a href="https://www.beehiiv.com/" target="_blank" rel="noreferrer">
beehiiv.com
</a>
</li>
<li>
The{' '}
<a
href="https://www.axios.com/newsletters"
target="_blank"
rel="noreferrer"
>
Axios Daily
</a>{' '}
newsletters
</li>
<li>
<a
href="https://golangweekly.com/"
target="_blank"
rel="noreferrer"
>
Golang Weekly
</a>
</li>
<li>
<a
href="https://www.bloomberg.com/account/newsletters"
target="_blank"
rel="noreferrer"
>
Bloomberg Newsletters
</a>
</li>
</ul>
<p>
If there is a newsletter you would like to read in Omnivore, please
let us know.
</p>
<h2>Omnivore Email Addresses</h2>
<p>
Omnivore allows you to create unique email addresses for subscribing
to newsletters. You can reuse one address for all your newsletters, or
you can create a unique address for each.
</p>
<p>
An Omnivore email address will receive email, detect whether the email
is a newsletter, and add the newsletter content to your library. If
the email does not appear to be a newsletter, it will be forwarded to
the email address you used when you registered for Omnivore.
</p>
<p>
There are multiple ways to add newsletters to your Omnivore library:
</p>
<ul>
<li>
<a href="#updating">
Updating your account email to an Omnivore email address
</a>
</li>
<li>
<a href="#directly">
Subscribe to the newsletter with an Omnivore email address
</a>
</li>
<li>
<a href="#forwarding">
Create a forwarding rule from your email account
</a>
</li>
</ul>
<h2 id="updating">Updating your account email</h2>
<p>
If you want all your substack newsletters sent to Omnivore, you can
login and change the address on{' '}
<a href="https://substack.com/account/settings">your account page</a>{' '}
in Substack.
</p>
<h2 id="directly">Subscribing Directly</h2>
<p>
Create your first email address by clicking the <AddEmailButton />{' '}
button on the <Link href="/settings/emails">emails page</Link>. Copy
the email address to your clipboard using the <CopyButton />
copy button, and enter that email address into an email subscription
box.
</p>
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
<img
src="/static/help/newsletter-email-signup.gif"
alt="Animated image setting up an Omnivore Email Address"
/>
</HStack>
<p>
If you are already logged into Substack you might need to logout to
use your new email address.
</p>
<h2 id="forwarding">Create a Forwarding Rule</h2>
<p>
If you are a Gmail user you can create a forwarding rule to send email
from your regular account to your Omnivore email address. This is
useful if you have an existing paid newsletter subscription and
don&apos;t want to update your account email address.
</p>
<p>
For free newsletters we recommend subscribing directly to the
newsletter with your Omnivore email address instead of setting up
forwarding rules.
</p>
<p>Before you start:</p>
<ul>
<li>
Create an Omnivore Email Address by clicking the <AddEmailButton />{' '}
button on the <Link href="/settings/emails">emails page</Link>.
</li>
<li>
Make a note of the Newsletter&apos;s sender email address. For
example <code>omnivore@substack.com</code>.
</li>
</ul>
<p>Create a forwarding rule:</p>
<ul>
<li>
On a computer open your{' '}
<Link href="https://mail.google.com/mail/u/0/#settings/fwdandpop">
Gmail Forwarding Rules
</Link>
. If this link does not work: click on the Gear icon in the upper
right corner of Gmail and select All Settings, then click the
Forwarding and POP/IMAP tab.
</li>
<li>
In the <b>Forwarding</b> section click{' '}
<b>Add a forwarding address</b>.
</li>
<li>
Enter your Omnivore Email Address (eg{' '}
<code>username-sdfsd@inbox.omnivore.app</code>) and click Next.
</li>
<li>Click Proceed and OK</li>
<li>
Refresh the Omnivore Newsletter Emails page and you should see a
code appear beside your address (eg 663421251). Copy this code to
your clipboard (click the <CopyButton /> button).
</li>
<li>
Return to your forwarding rules section and look for the confirm
code text box. Enter the confirmation code you copied and click{' '}
<b>Verify</b>.
</li>
<li>
In the forwarding section of Gmail, Click on{' '}
<b>Creating a Filter</b>
</li>
<li>
Add the email address of your newsletter (eg omnivore@substack.app)
in the <code>From</code> section.
</li>
<li>
Click <code>Create Filter</code>
</li>
<li>
Choose <b>Forward it to</b> and enter your Omnivore Email Address
(eg <code>username-sdfsd@inbox.omnivore.app</code>)
</li>
<li>
Click <code>Create Filter</code> at the bottom of the dialog.
</li>
</ul>
</Box>
<Box css={{ height: '120px' }} />
</SettingsLayout>
)
}

View file

@ -1,163 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import { Box, HStack } from '../../components/elements/LayoutPrimitives'
import { PrimaryLayout } from '../../components/templates/PrimaryLayout'
import { Button } from '../../components/elements/Button'
import Link from 'next/link'
import { SettingsLayout } from '../../components/templates/SettingsLayout'
export default function Colors(): JSX.Element {
return (
<SettingsLayout>
<Box
css={{
m: '42px',
maxWidth: '640px',
color: '$grayText',
img: {
maxWidth: '85%',
},
'@smDown': {
m: '16px',
maxWidth: '85%',
alignSelf: 'center',
},
}}
>
<h1>Saving Links to your Omnivore Library</h1>
<hr />
<h3>Learn how to save links to your Omnivore Library</h3>
<p>
Omnivore is a place to store everything you read. We keep it safe,
organized, and easy to share.
</p>
<p>
When you start using Omnivore, it is important to figure out the best
way to save content to your library.
</p>
<ul>
<li>
<a href="#savingfromyouriphone">Saving from your iPhone</a>
</li>
<li>
<a href="#savingfromyourandroiddevice">
Saving from your Android Device
</a>
</li>
<li>
<a href="#savingfromyourcomputer">Saving from your Computer</a>
</li>
<li>
<a href="#savingpdfswiththemacapp">Saving PDFs from your Mac</a>
</li>
</ul>
<h2 id="savingfromyouriphone">Saving from your iPhone</h2>
<p>
If you are using an iPhone or iPad, the best way to save links is by
installing the iOS app. You can find the iOS app here:{' '}
<a href="https://omnivore.app/install/ios">
https://omnivore.app/install/ios
</a>
</p>
<p>
With the iOS Share extension installed, you can save links from Safari
or any other app that supports sharing links.
</p>
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
<img
width={212}
height={347}
src="/static/help/share-module-ios.gif"
alt="Animated image of iOS share extension share action"
/>
</HStack>
<h2 id="savingfromyourandroiddevice">
Saving from your Android Device
</h2>
<p>
If you are using an Android device you can install the Omnivore
Progressive Web App. After logging in to Omnivore in Chrome you should
see an &#8220;Install Omnivore&#8221; option. Most Android versions
display this at the bottom of the screen.
</p>
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
<img
src="/static/help/android-bottom-bar.png"
alt="Save button shown on Android device"
/>
</HStack>
<p>
After installing Omnivore as a Progressive Web App it will be
displayed in your Sharing Menu on Chrome.
</p>
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
<img
src="/static/help/android-share.png"
alt="Android device with Omnivore progressive web app installed"
/>
</HStack>
<h2 id="savingfromyourcomputer">Saving from your computer</h2>
<p>
If you are saving from a computer, you will need to install the
Omnivore extension for the web browser(s) you use.
</p>
The browser extensions are available here:
<ul>
<li>
<a href="https://omnivore.app/install/chrome">Chrome</a>
</li>
<li>
<a href="https://omnivore.app/install/edge">Edge</a>
</li>
<li>
<a href="https://omnivore.app/install/firefox">FireFox</a>
</li>
<li>
<a href="https://omnivore.app/install/safari">Safari</a>
</li>
</ul>
<p>
With the browser extension(s) of your choice installed, you can tap
the Omnivore button on any page to save your link.
</p>
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
<img
src="/static/help/share-extension.gif"
alt="Animated image of browser plugin save action"
/>
</HStack>
<h2 id="savingpdfswiththemacapp">Saving PDFs with the Mac App</h2>
<p>
<a href="https://omnivore.app/install/mac">
https://omnivore.app/install/mac
</a>
</p>
<p>
With the MacOS App installed you can upload PDFs from your computer to
your Omnivore library by right-clicking and sharing to Omnivore.
</p>
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
<img
src="/static/help/saving-pdfs-mac.png"
alt="Animated image of macOS share extension link saving"
/>
</HStack>
<p>
You can enable sharing from Finder on the Mac in the Extensions
section of System Preferences.
</p>
<HStack distribution="center" css={{ width: '100%', my: '32px' }}>
<img
src="/static/help/enable-sharing-on-mac.gif"
alt="Animated image of enabling share extension for macOS Finder"
/>
</HStack>
<HStack alignment="center" css={{ mb: '32px', width: '100%' }}>
<Link passHref href="/home">
<Button style="ctaDarkYellow">Start Reading</Button>
</Link>
</HStack>
</Box>
<Box css={{ height: '120px' }} />
</SettingsLayout>
)
}

View file

@ -1,200 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import { Box } from '../../components/elements/LayoutPrimitives'
import { SettingsLayout } from '../../components/templates/SettingsLayout'
export default function Search(): JSX.Element {
return (
<SettingsLayout>
<Box
css={{
m: '42px',
maxWidth: '640px',
color: '$grayText',
img: {
maxWidth: '85%',
},
'@smDown': {
m: '16px',
maxWidth: '85%',
alignSelf: 'center',
},
}}
>
<h1>Search</h1>
<hr />
<p>
Omnivore uses search to filter items in your library. You can use a
simple keyword search or our advanced search syntax to find items.
</p>
<ul>
<li>
<a href="#text">Searching for text</a>
</li>
<li>
<a href="#label">Filtering by label</a>
</li>
<li>
<a href="#in">Filtering by archive status</a>
</li>
<li>
<a href="#is">Filtering by read state</a>
</li>
<li>
<a href="#type">Filtering by type</a>
</li>
<li>
<a href="#has">Finding highlights</a>
</li>
<li>
<a href="#dates">Filtering by save/publish dates</a>
</li>
<li>
<a href="#sort">Sorting</a>
</li>
</ul>
<h2 id="text">Searching for text</h2>
<p>
Omnivore will perform full text search across library item&apos;s
content, title, description, and site by default. You can search for
specific terms by quoting your terms. By default all results that
match your search will be returned in the order they were saved. To
change your search to relevance use the <code>sort:score</code>{' '}
parameter.
</p>
<h2 id="label">Filtering by label</h2>
<p>
You can filter your search based on labels using AND and OR clauses.
You can also negate a label search to find pages that do not have a
certain label.
</p>
<p>Some examples:</p>
<ul>
<li>
label:Newsletter finds all pages that have the label Newsletter
</li>
<li>
label:Cooking,Fitness finds all your pages with either the Cooking
or Fitness labels
</li>
<li>
label:Newsletter label:Surfing finds all pages with both the
Newsletter and Surfing labels
</li>
<li>
label:Coding -label:News finds all pages with the Coding label that
do not have the News label
</li>
</ul>
<h2 id="in">Filtering by archive status</h2>
<p>
The <code>in:</code> filter is used to filter search by archive
status. The options are:
</p>
<ul>
<li>
<code>in:inbox</code> (the default): show unarchived items
</li>
<li>
<code>in:archive</code>: show archived items
</li>
<li>
<code>in:all</code>: Show all items regardless of archive state
</li>
</ul>
<h2 id="is">Filtering by read state</h2>
<p>
The <code>is:</code> filter is used to filter search by read state.
Note that in Omnivore &apos;read&apos; means fully read, not just
opened.
</p>
<p>
The <code>is:</code> filter options are:{' '}
</p>
<ul>
<li>
<code>is:read</code>: Show only items that are fully read
</li>
<li>
<code>is:unread</code> (the default): show unread items
</li>
</ul>
<h2 id="type">Filtering by type</h2>
<p>
The <code>type:</code> filter is used to filter search by type.
</p>
<ul>
<li>
<code>type:article</code>: Show only articles
</li>
<li>
<code>type:file</code>: Show only files
</li>
<li>
<code>type:pdf</code>: Show only PDFs
</li>
<li>
<code>type:highlights</code>: Show your highlights
</li>
</ul>
<h2 id="has">Finding highlights</h2>
<p>
You can find your highlights by using the <code>type:highlights</code>{' '}
filter or find saved items with highlights using the{' '}
<code>has:highlights</code> filter.
</p>
<h2 id="dates">Filtering by save/publish dates</h2>
<p>
You can filter your searches based on the time they were saved or
published using the
<code>saved:</code> and <code>published:</code> filters. These filters
take two dates to create a date range. The <code>*</code> wildcard
will accept any date.
</p>
<p>For Example:</p>
<ul>
<li>
<code>saved:2022-04-21..*</code> All items saved since 2022-04-21
</li>
<li>
<code>published:2020-01-01..2022-02-02</code> All items published
between 2020-01-01 and 2022-02-02
</li>
<li>
<code>published:*..2020-01-01</code> All items published before
2020-01-01
</li>
</ul>
<h2 id="sort">Sorting</h2>
<p>
By default all search results in Omnivore are sorted by saved date.
This puts the most recently saved items at the top of your library.
You can use sort options to change the library order:
</p>
<ul>
<li>
<code>sort:saved</code>: Sort by saved date
</li>
<li>
<code>sort:updated</code>: Sort by time the item was updated, for
example having a label or highlight added.
</li>
<li>
<code>sort:score</code>: Sort by query term relevance.
</li>
</ul>
</Box>
<Box css={{ height: '120px' }} />
</SettingsLayout>
)
}

View file

@ -117,7 +117,7 @@ export default function EmailsPage(): JSX.Element {
<>
<SettingsTable
pageId="settings-emails-tag"
pageInfoLink="/help/newsletters"
pageInfoLink="https://docs.omnivore.app/using/inbox.html"
headerTitle="Address"
createTitle="Create a new email address"
createAction={createEmail}

View file

@ -8,7 +8,7 @@ import { ConfirmationModal } from '../../../components/patterns/ConfirmationModa
import {
EmptySettingsRow,
SettingsTable,
SettingsTableRow
SettingsTableRow,
} from '../../../components/templates/settings/SettingsTable'
import { theme } from '../../../components/tokens/stitches.config'
import { formattedDateTime } from '../../../lib/dateFormatting'
@ -17,7 +17,7 @@ import { updateSubscriptionMutation } from '../../../lib/networking/mutations/up
import {
SubscriptionStatus,
SubscriptionType,
useGetSubscriptionsQuery
useGetSubscriptionsQuery,
} from '../../../lib/networking/queries/useGetSubscriptionsQuery'
import { applyStoredTheme } from '../../../lib/themeUpdater'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
@ -90,17 +90,15 @@ export default function Rss(): JSX.Element {
return (
<SettingsTable
pageId={'feeds'}
pageInfoLink={''} // TODO: https://docs.omnivore.app/settings/feeds.html
headerTitle={'Subscribed feeds'}
createTitle={'Add feed'}
pageInfoLink="https://docs.omnivore.app/settings/feeds.html"
headerTitle="Subscribed feeds"
createTitle="Add feed"
createAction={() => {
router.push('/settings/feeds/add')
}}
>
{subscriptions.length === 0 ? (
<EmptySettingsRow
text={isValidating ? '-' : 'No feeds subscribed'}
/>
<EmptySettingsRow text={isValidating ? '-' : 'No feeds subscribed'} />
) : (
subscriptions.map((subscription, i) => {
return (
@ -218,9 +216,7 @@ export default function Rss(): JSX.Element {
{onDeleteId && (
<ConfirmationModal
message={
'Feed will be unsubscribed. This action cannot be undone.'
}
message={'Feed will be unsubscribed. This action cannot be undone.'}
onAccept={async () => {
await onDelete(onDeleteId)
setOnDeleteId('')

View file

@ -280,7 +280,7 @@ export default function LabelsPage(): JSX.Element {
<Box>
<StyledText style="fixedHeadline">Labels </StyledText>
</Box>
<InfoLink href="/help/labels" />
<InfoLink href="https://docs.omnivore.app/using/organizing.html#labels" />
<Box
css={{
display: 'flex',

View file

@ -1,7 +1,11 @@
import { useMemo, useState } from 'react'
import { applyStoredTheme } from '../../lib/themeUpdater'
import { ConfirmationModal } from '../../components/patterns/ConfirmationModal'
import { useGetSubscriptionsQuery } from '../../lib/networking/queries/useGetSubscriptionsQuery'
import {
Subscription,
SubscriptionType,
useGetSubscriptionsQuery,
} from '../../lib/networking/queries/useGetSubscriptionsQuery'
import { unsubscribeMutation } from '../../lib/networking/mutations/unsubscribeMutation'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
import {
@ -15,14 +19,13 @@ import { formattedShortDate } from '../../lib/dateFormatting'
export default function SubscriptionsPage(): JSX.Element {
const { subscriptions, revalidate, isValidating } = useGetSubscriptionsQuery()
const [confirmUnsubscribeName, setConfirmUnsubscribeName] = useState<
string | null
>(null)
const [confirmUnsubscribeSubscription, setConfirmUnsubscribeSubscription] =
useState<Subscription | null>(null)
applyStoredTheme(false)
async function onUnsubscribe(name: string): Promise<void> {
const result = await unsubscribeMutation(name)
async function onUnsubscribe(subscription: Subscription): Promise<void> {
const result = await unsubscribeMutation(subscription.name, subscription.id)
if (result) {
showSuccessToast('Unsubscribed', { position: 'bottom-right' })
} else {
@ -41,7 +44,7 @@ export default function SubscriptionsPage(): JSX.Element {
return (
<SettingsTable
pageId="settings-subscriptions-tag"
pageInfoLink="/help/newsletters"
pageInfoLink="https://docs.omnivore.app/using/inbox.html"
headerTitle="Subscriptions"
>
<>
@ -52,13 +55,14 @@ export default function SubscriptionsPage(): JSX.Element {
key={subscription.id}
title={subscription.name}
isLast={i === sortedSubscriptions.length - 1}
onDelete={() => setConfirmUnsubscribeName(subscription.name)}
onDelete={() => setConfirmUnsubscribeSubscription(subscription)}
deleteTitle="Unsubscribe"
sublineElement={
<StyledText
css={{
my: '5px',
fontSize: '11px',
a: {
color: '$omnivoreCtaYellow',
},
@ -66,12 +70,25 @@ export default function SubscriptionsPage(): JSX.Element {
>
{`Last received ${formattedShortDate(
subscription.updatedAt
)} at `}
<Link
href={`/settings/emails?address=${subscription.newsletterEmail}`}
>
{subscription.newsletterEmail}
</Link>
)}`}
{subscription.newsletterEmail && (
<>
{' '}
at{' '}
<Link
href={`/settings/emails?address=${subscription.newsletterEmail}`}
>
{subscription.newsletterEmail}
</Link>
</>
)}
{subscription.type == SubscriptionType.RSS &&
subscription.url && (
<>
{' '}
via <Link href={subscription.url}>RSS</Link>
</>
)}
</StyledText>
}
/>
@ -83,16 +100,18 @@ export default function SubscriptionsPage(): JSX.Element {
/>
)}
{confirmUnsubscribeName ? (
{confirmUnsubscribeSubscription ? (
<ConfirmationModal
message={
'Are you sure? You will stop receiving newsletters from this subscription.'
confirmUnsubscribeSubscription.type == SubscriptionType.NEWSLETTER
? 'Are you sure? You will stop receiving newsletters from this subscription.'
: 'Are you sure? You will stop receiving updates from this feed.'
}
onAccept={async () => {
await onUnsubscribe(confirmUnsubscribeName)
setConfirmUnsubscribeName(null)
await onUnsubscribe(confirmUnsubscribeSubscription)
setConfirmUnsubscribeSubscription(null)
}}
onOpenChange={() => setConfirmUnsubscribeName(null)}
onOpenChange={() => setConfirmUnsubscribeSubscription(null)}
/>
) : null}
</>

View file

@ -39,6 +39,7 @@ export default function Webhooks(): JSX.Element {
const [url, setUrl] = useState('')
const eventTypeOptions: EventTypeOption[] = [
{ label: 'PAGE_CREATED', value: 'PAGE_CREATED' },
{ label: 'PAGE_UPDATED', value: 'PAGE_UPDATED' },
{ label: 'HIGHLIGHT_CREATED', value: 'HIGHLIGHT_CREATED' },
{ label: 'LABEL_ADDED', value: 'LABEL_CREATED' },
]

View file

@ -12,10 +12,16 @@ export default {
},
} as ComponentMeta<typeof EmptyLibrary>
export const EmptyLibraryStory: ComponentStory<typeof EmptyLibrary> = (args: any) => {
export const EmptyLibraryStory: ComponentStory<typeof EmptyLibrary> = (
args: any
) => {
return (
<EmptyLibrary onAddLinkClicked={() => {
console.log('onAddLinkClicked')
}} />
<EmptyLibrary
layoutType="GRID_LAYOUT"
searchTerm=""
onAddLinkClicked={() => {
console.log('onAddLinkClicked')
}}
/>
)
}