Subscription management page (#1000)
* first pass of a subscription management page under settings * add tabs to settings ui * NymActionDropdown * update Apollo InMemoryCache to merge paginated list of my subscribed users * various updates * switch from UsersNullable to Users * bake the nym action dropdwon into the user component * add back fields to the user query * `meSubscriptionPosts`, `meSubscriptionComments`, `meMute` * Refetch my subscribed users when a user subscription is changed * update user list to hide stats in the subscribed list users * update my sub'd users fragment to remove unnecessary user fields * memoize subscribe user context provider value to avoid re-renders * use inner join instead of left join Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * throw error when unauthenticated Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
parent
15fb7f446b
commit
992fc54160
@ -140,6 +140,27 @@ export default {
|
|||||||
}
|
}
|
||||||
return user?.name?.toUpperCase() === name?.toUpperCase() || !(await models.user.findUnique({ where: { name } }))
|
return user?.name?.toUpperCase() === name?.toUpperCase() || !(await models.user.findUnique({ where: { name } }))
|
||||||
},
|
},
|
||||||
|
mySubscribedUsers: async (parent, { cursor }, { models, me }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new GraphQLError('You must be logged in to view subscribed users', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedCursor = decodeCursor(cursor)
|
||||||
|
const users = await models.$queryRaw`
|
||||||
|
SELECT users.*
|
||||||
|
FROM "UserSubscription"
|
||||||
|
JOIN users ON "UserSubscription"."followeeId" = users.id
|
||||||
|
WHERE "UserSubscription"."followerId" = ${me.id}
|
||||||
|
AND ("UserSubscription"."postsSubscribedAt" IS NOT NULL OR "UserSubscription"."commentsSubscribedAt" IS NOT NULL)
|
||||||
|
OFFSET ${decodedCursor.offset}
|
||||||
|
LIMIT ${LIMIT}
|
||||||
|
`
|
||||||
|
|
||||||
|
return {
|
||||||
|
cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||||
|
users
|
||||||
|
}
|
||||||
|
},
|
||||||
topCowboys: async (parent, { cursor }, { models, me }) => {
|
topCowboys: async (parent, { cursor }, { models, me }) => {
|
||||||
const decodedCursor = decodeCursor(cursor)
|
const decodedCursor = decodeCursor(cursor)
|
||||||
const range = whenRange('forever')
|
const range = whenRange('forever')
|
||||||
|
@ -12,6 +12,7 @@ export default gql`
|
|||||||
searchUsers(q: String!, limit: Limit, similarity: Float): [User!]!
|
searchUsers(q: String!, limit: Limit, similarity: Float): [User!]!
|
||||||
userSuggestions(q: String, limit: Limit): [User!]!
|
userSuggestions(q: String, limit: Limit): [User!]!
|
||||||
hasNewNotes: Boolean!
|
hasNewNotes: Boolean!
|
||||||
|
mySubscribedUsers(cursor: String): Users!
|
||||||
}
|
}
|
||||||
|
|
||||||
type UsersNullable {
|
type UsersNullable {
|
||||||
|
@ -1,14 +1,30 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
import { useMutation } from '@apollo/client'
|
import { useMutation } from '@apollo/client'
|
||||||
import { gql } from 'graphql-tag'
|
import { gql } from 'graphql-tag'
|
||||||
import Dropdown from 'react-bootstrap/Dropdown'
|
import Dropdown from 'react-bootstrap/Dropdown'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
|
|
||||||
|
const SubscribeUserContext = createContext(() => ({
|
||||||
|
refetchQueries: []
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const SubscribeUserContextProvider = ({ children, value }) => {
|
||||||
|
return (
|
||||||
|
<SubscribeUserContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SubscribeUserContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSubscribeUserContext = () => useContext(SubscribeUserContext)
|
||||||
|
|
||||||
export default function SubscribeUserDropdownItem ({ user, target = 'posts' }) {
|
export default function SubscribeUserDropdownItem ({ user, target = 'posts' }) {
|
||||||
const isPosts = target === 'posts'
|
const isPosts = target === 'posts'
|
||||||
const mutation = isPosts ? 'subscribeUserPosts' : 'subscribeUserComments'
|
const mutation = isPosts ? 'subscribeUserPosts' : 'subscribeUserComments'
|
||||||
const userField = isPosts ? 'meSubscriptionPosts' : 'meSubscriptionComments'
|
const userField = isPosts ? 'meSubscriptionPosts' : 'meSubscriptionComments'
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const { id, [userField]: meSubscription } = user
|
const { id, [userField]: meSubscription } = user
|
||||||
|
const { refetchQueries } = useSubscribeUserContext()
|
||||||
const [subscribeUser] = useMutation(
|
const [subscribeUser] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation ${mutation}($id: ID!) {
|
mutation ${mutation}($id: ID!) {
|
||||||
@ -16,6 +32,7 @@ export default function SubscribeUserDropdownItem ({ user, target = 'posts' }) {
|
|||||||
${userField}
|
${userField}
|
||||||
}
|
}
|
||||||
}`, {
|
}`, {
|
||||||
|
refetchQueries,
|
||||||
update (cache, { data: { [mutation]: subscribeUser } }) {
|
update (cache, { data: { [mutation]: subscribeUser } }) {
|
||||||
cache.modify({
|
cache.modify({
|
||||||
id: `User:${id}`,
|
id: `User:${id}`,
|
||||||
|
@ -182,14 +182,19 @@ function NymView ({ user, isMe, setEditting }) {
|
|||||||
<div className={styles.username}>@{user.name}<Hat className='' user={user} badge /></div>
|
<div className={styles.username}>@{user.name}<Hat className='' user={user} badge /></div>
|
||||||
{isMe &&
|
{isMe &&
|
||||||
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
|
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
|
||||||
{!isMe && me &&
|
{!isMe && me && <NymActionDropdown user={user} />}
|
||||||
<div className='ms-2'>
|
</div>
|
||||||
<ActionDropdown>
|
)
|
||||||
<SubscribeUserDropdownItem user={user} target='posts' />
|
}
|
||||||
<SubscribeUserDropdownItem user={user} target='comments' />
|
|
||||||
<MuteDropdownItem user={user} />
|
export function NymActionDropdown ({ user, className = 'ms-2' }) {
|
||||||
</ActionDropdown>
|
return (
|
||||||
</div>}
|
<div className={className}>
|
||||||
|
<ActionDropdown>
|
||||||
|
<SubscribeUserDropdownItem user={user} target='posts' />
|
||||||
|
<SubscribeUserDropdownItem user={user} target='comments' />
|
||||||
|
<MuteDropdownItem user={user} />
|
||||||
|
</ActionDropdown>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import { useData } from './use-data'
|
|||||||
import Hat from './hat'
|
import Hat from './hat'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { MEDIA_URL } from '@/lib/constants'
|
import { MEDIA_URL } from '@/lib/constants'
|
||||||
|
import { NymActionDropdown } from '@/components/user-header'
|
||||||
|
|
||||||
// all of this nonsense is to show the stat we are sorting by first
|
// all of this nonsense is to show the stat we are sorting by first
|
||||||
const Stacked = ({ user }) => (user.optional.stacked !== null && <span>{abbrNum(user.optional.stacked)} stacked</span>)
|
const Stacked = ({ user }) => (user.optional.stacked !== null && <span>{abbrNum(user.optional.stacked)} stacked</span>)
|
||||||
@ -38,8 +39,9 @@ function seperate (arr, seperator) {
|
|||||||
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x])
|
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x])
|
||||||
}
|
}
|
||||||
|
|
||||||
function User ({ user, rank, statComps, Embellish }) {
|
function User ({ user, rank, statComps, Embellish, nymActionDropdown = false }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
|
const showStatComps = statComps && statComps.length > 0
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{rank
|
{rank
|
||||||
@ -55,13 +57,17 @@ function User ({ user, rank, statComps, Embellish }) {
|
|||||||
className={`${userStyles.userimg} me-2`}
|
className={`${userStyles.userimg} me-2`}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className={styles.hunk}>
|
<div className={`${styles.hunk} ${!showStatComps && 'd-flex flex-column justify-content-around'}`}>
|
||||||
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
|
<div className='d-flex'>
|
||||||
@{user.name}<Hat className='ms-1 fill-grey' height={14} width={14} user={user} />
|
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
|
||||||
</Link>
|
@{user.name}<Hat className='ms-1 fill-grey' height={14} width={14} user={user} />
|
||||||
<div className={styles.other}>
|
</Link>
|
||||||
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
|
{nymActionDropdown && <NymActionDropdown user={user} className='' />}
|
||||||
</div>
|
</div>
|
||||||
|
{showStatComps &&
|
||||||
|
<div className={styles.other}>
|
||||||
|
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
|
||||||
|
</div>}
|
||||||
{Embellish && <Embellish rank={rank} />}
|
{Embellish && <Embellish rank={rank} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -96,19 +102,19 @@ function UserHidden ({ rank, Embellish }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListUsers ({ users, rank, statComps = seperate(STAT_COMPONENTS, Seperator), Embellish }) {
|
export function ListUsers ({ users, rank, statComps = seperate(STAT_COMPONENTS, Seperator), Embellish, nymActionDropdown }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{users.map((user, i) => (
|
{users.map((user, i) => (
|
||||||
user
|
user
|
||||||
? <User key={user.id} user={user} rank={rank && i + 1} statComps={statComps} Embellish={Embellish} />
|
? <User key={user.id} user={user} rank={rank && i + 1} statComps={statComps} Embellish={Embellish} nymActionDropdown={nymActionDropdown} />
|
||||||
: <UserHidden key={i} rank={rank && i + 1} Embellish={Embellish} />
|
: <UserHidden key={i} rank={rank && i + 1} Embellish={Embellish} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserList ({ ssrData, query, variables, destructureData, rank, footer = true }) {
|
export default function UserList ({ ssrData, query, variables, destructureData, rank, footer = true, nymActionDropdown, statCompsProp }) {
|
||||||
const { data, fetchMore } = useQuery(query, { variables })
|
const { data, fetchMore } = useQuery(query, { variables })
|
||||||
const dat = useData(data, ssrData)
|
const dat = useData(data, ssrData)
|
||||||
const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator))
|
const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator))
|
||||||
@ -134,7 +140,7 @@ export default function UserList ({ ssrData, query, variables, destructureData,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ListUsers users={users} rank={rank} statComps={statComps} />
|
<ListUsers users={users} rank={rank} statComps={statCompsProp ?? statComps} nymActionDropdown={nymActionDropdown} />
|
||||||
{footer &&
|
{footer &&
|
||||||
<MoreFooter cursor={cursor} count={users?.length} fetchMore={fetchMore} Skeleton={UsersSkeleton} noMoreText='NO MORE' />}
|
<MoreFooter cursor={cursor} count={users?.length} fetchMore={fetchMore} Skeleton={UsersSkeleton} noMoreText='NO MORE' />}
|
||||||
</>
|
</>
|
||||||
|
@ -206,6 +206,26 @@ export const USER_FIELDS = gql`
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
export const MY_SUBSCRIBED_USERS = gql`
|
||||||
|
query MySubscribedUsers($cursor: String) {
|
||||||
|
mySubscribedUsers(cursor: $cursor) {
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
photoId
|
||||||
|
meSubscriptionPosts
|
||||||
|
meSubscriptionComments
|
||||||
|
meMute
|
||||||
|
|
||||||
|
optional {
|
||||||
|
streak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const TOP_USERS = gql`
|
export const TOP_USERS = gql`
|
||||||
query TopUsers($cursor: String, $when: String, $from: String, $to: String, $by: String, ) {
|
query TopUsers($cursor: String, $when: String, $from: String, $to: String, $by: String, ) {
|
||||||
topUsers(cursor: $cursor, when: $when, from: $from, to: $to, by: $by) {
|
topUsers(cursor: $cursor, when: $when, from: $from, to: $to, by: $by) {
|
||||||
|
@ -86,6 +86,19 @@ function getClient (uri) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mySubscribedUsers: {
|
||||||
|
keyArgs: false,
|
||||||
|
merge (existing, incoming) {
|
||||||
|
if (isFirstPage(incoming.cursor, existing?.users)) {
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cursor: incoming.cursor,
|
||||||
|
users: [...(existing?.users || []), ...incoming.users]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
userSuggestions: {
|
userSuggestions: {
|
||||||
keyArgs: ['q', 'limit'],
|
keyArgs: ['q', 'limit'],
|
||||||
merge (existing, incoming) {
|
merge (existing, incoming) {
|
||||||
|
@ -2,6 +2,7 @@ import { Checkbox, Form, Input, SubmitButton, Select, VariableInput, CopyInput }
|
|||||||
import Alert from 'react-bootstrap/Alert'
|
import Alert from 'react-bootstrap/Alert'
|
||||||
import Button from 'react-bootstrap/Button'
|
import Button from 'react-bootstrap/Button'
|
||||||
import InputGroup from 'react-bootstrap/InputGroup'
|
import InputGroup from 'react-bootstrap/InputGroup'
|
||||||
|
import Nav from 'react-bootstrap/Nav'
|
||||||
import Layout from '@/components/layout'
|
import Layout from '@/components/layout'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||||
@ -29,6 +30,7 @@ import { INVOICE_RETENTION_DAYS } from '@/lib/constants'
|
|||||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
||||||
import DeleteIcon from '@/svgs/delete-bin-line.svg'
|
import DeleteIcon from '@/svgs/delete-bin-line.svg'
|
||||||
import { useField } from 'formik'
|
import { useField } from 'formik'
|
||||||
|
import styles from './settings.module.css'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
||||||
|
|
||||||
@ -36,6 +38,32 @@ function bech32encode (hexString) {
|
|||||||
return bech32.encode('npub', bech32.toWords(Buffer.from(hexString, 'hex')))
|
return bech32.encode('npub', bech32.toWords(Buffer.from(hexString, 'hex')))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SettingsHeader () {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathParts = router.asPath.split('/').filter(segment => !!segment)
|
||||||
|
const activeKey = pathParts.length === 1 ? 'general' : 'subscriptions'
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className='mb-2 text-start'>settings</h2>
|
||||||
|
<Nav
|
||||||
|
className={styles.nav}
|
||||||
|
activeKey={activeKey}
|
||||||
|
>
|
||||||
|
<Nav.Item>
|
||||||
|
<Link href='/settings' passHref legacyBehavior>
|
||||||
|
<Nav.Link eventKey='general'>general</Nav.Link>
|
||||||
|
</Link>
|
||||||
|
</Nav.Item>
|
||||||
|
<Nav.Item>
|
||||||
|
<Link href='/settings/subscriptions' passHref legacyBehavior>
|
||||||
|
<Nav.Link eventKey='subscriptions'>subscriptions</Nav.Link>
|
||||||
|
</Link>
|
||||||
|
</Nav.Item>
|
||||||
|
</Nav>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Settings ({ ssrData }) {
|
export default function Settings ({ ssrData }) {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
@ -61,7 +89,7 @@ export default function Settings ({ ssrData }) {
|
|||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className='pb-3 w-100 mt-2' style={{ maxWidth: '600px' }}>
|
<div className='pb-3 w-100 mt-2' style={{ maxWidth: '600px' }}>
|
||||||
<h2 className='mb-2 text-start'>settings</h2>
|
<SettingsHeader />
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
tipDefault: settings?.tipDefault || 21,
|
tipDefault: settings?.tipDefault || 21,
|
||||||
|
17
pages/settings/settings.module.css
Normal file
17
pages/settings/settings.module.css
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
.nav {
|
||||||
|
margin: 1rem 0;
|
||||||
|
justify-content: start;
|
||||||
|
font-size: 110%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav :global .nav-link {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav :global .nav-item:not(:first-child) {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav :global .active {
|
||||||
|
border-bottom: 2px solid var(--bs-primary);
|
||||||
|
}
|
31
pages/settings/subscriptions/index.js
Normal file
31
pages/settings/subscriptions/index.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||||
|
import Layout from '@/components/layout'
|
||||||
|
import UserList from '@/components/user-list'
|
||||||
|
import { MY_SUBSCRIBED_USERS } from '@/fragments/users'
|
||||||
|
import { SettingsHeader } from '../index'
|
||||||
|
import { SubscribeUserContextProvider } from '@/components/subscribeUser'
|
||||||
|
|
||||||
|
export const getServerSideProps = getGetServerSideProps({ query: MY_SUBSCRIBED_USERS, authRequired: true })
|
||||||
|
|
||||||
|
export default function MySubscribedUsers ({ ssrData }) {
|
||||||
|
const subscribeUserContextValue = useMemo(() => ({ refetchQueries: ['MySubscribedUsers'] }), [])
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className='pb-3 w-100 mt-2'>
|
||||||
|
<SettingsHeader />
|
||||||
|
<div className='mb-4 text-muted'>These here are stackers you've hitched your wagon to, pardner.</div>
|
||||||
|
<SubscribeUserContextProvider value={subscribeUserContextValue}>
|
||||||
|
<UserList
|
||||||
|
ssrData={ssrData} query={MY_SUBSCRIBED_USERS}
|
||||||
|
destructureData={data => data.mySubscribedUsers}
|
||||||
|
variables={{}}
|
||||||
|
rank
|
||||||
|
nymActionDropdown
|
||||||
|
statCompsProp={[]}
|
||||||
|
/>
|
||||||
|
</SubscribeUserContextProvider>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user