Merge pull request #2700 from omnivore-app/fix/subscriptions-cleanup

Subscriptions cleanup
This commit is contained in:
Jackson Harper 2023-08-30 11:23:56 +08:00 committed by GitHub
commit 85d5361d89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 271 additions and 154 deletions

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'>
@ -76,20 +77,26 @@ export const subscriptionsResolver = authorized<
user: { id: uid },
})
// Show all RSS, but only active newsletters
queryBuilder
.where({
type: SubscriptionType.Newsletter,
status: SubscriptionStatus.Active,
})
.orWhere({
type: SubscriptionType.Rss,
})
if (type) {
if (type && type == SubscriptionType.Newsletter) {
queryBuilder.andWhere({
type,
status: SubscriptionStatus.Active,
})
} else if (type && type == SubscriptionType.Rss) {
queryBuilder.where({
type,
})
} else {
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where({
type: SubscriptionType.Newsletter,
status: SubscriptionStatus.Active,
}).orWhere({
type: SubscriptionType.Rss,
})
})
)
}
const subscriptions = await queryBuilder

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'
@ -134,44 +137,52 @@ describe('Subscriptions API', () => {
undefined,
SubscriptionType.Rss
)
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,
}))
)
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('fakeUser')
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,
}))
)
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('responds status code 400 when invalid query', async () => {

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,9 +1,8 @@
import { ReactNode, useMemo, useState } from 'react'
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, Plus } 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'
@ -11,6 +10,11 @@ import { theme } from '../../tokens/stitches.config'
import { useRegisterActions } from 'kbar'
import { LogoBox } from '../../elements/LogoBox'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
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'
@ -103,6 +107,10 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
name: 'Unlabeled',
term: 'no:label',
},
{
name: 'Oldest First',
term: 'sort:saved-desc',
},
{
name: 'Files',
term: 'type:file',
@ -130,16 +138,26 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
[]
)
const [collapsed, setCollapsed] = usePersistedState<boolean>({
key: `--saved-searches-collapsed`,
initialValue: false,
})
return (
<MenuPanel title="Saved Searches">
{items.map((item) => (
<FilterButton
key={item.name}
text={item.name}
filterTerm={item.term}
{...props}
/>
))}
<MenuPanel
title="Saved Searches"
collapsed={collapsed}
setCollapsed={setCollapsed}
>
{!collapsed &&
items.map((item) => (
<FilterButton
key={item.name}
text={item.name}
filterTerm={item.term}
{...props}
/>
))}
<Box css={{ height: '10px' }}></Box>
</MenuPanel>
@ -148,8 +166,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,
})
@ -173,15 +191,10 @@ function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
return (
<MenuPanel
title="Subscriptions"
editTitle="Edit Subscriptions"
editFunc={() => {
window.location.href = '/settings/subscriptions'
}}
viewAll={() => {
setViewAll(true)
}}
collapsed={collapsed}
setCollapsed={setCollapsed}
>
{viewAll ? (
{!collapsed ? (
<>
<FilterButton filterTerm={`label:RSS`} text="Feeds" {...props} />
<FilterButton
@ -199,7 +212,10 @@ function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
/>
)
})}
<ViewAllButton state={viewAll} setState={setViewAll} />
<EditButton
title="Edit Subscriptions"
destination="/settings/subscriptions"
/>
</>
) : (
<SpanBox css={{ mb: '10px' }} />
@ -210,8 +226,8 @@ function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
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,
})
@ -224,16 +240,18 @@ function Labels(props: LibraryFilterMenuProps): JSX.Element {
return (
<MenuPanel
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>
)
}
@ -241,10 +259,11 @@ function Labels(props: LibraryFilterMenuProps): JSX.Element {
type MenuPanelProps = {
title: string
children: ReactNode
editFunc?: () => void
editTitle?: string
hideBottomBorder?: boolean
viewAll?: () => void
collapsed: boolean
setCollapsed: (collapsed: boolean) => void
}
function MenuPanel(props: MenuPanelProps): JSX.Element {
@ -261,7 +280,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',
@ -278,57 +297,32 @@ function MenuPanel(props: MenuPanelProps): JSX.Element {
</StyledText>
<SpanBox
css={{
mt: '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>
}
>
{props.viewAll && (
<DropdownOption
title="View All"
onSelect={() => {
if (props.viewAll) {
props.viewAll()
}
}}
/>
)}
<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}
@ -492,34 +486,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>
)
}