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 } }))
|
||||
},
|
||||
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 }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
const range = whenRange('forever')
|
||||
|
|
|
@ -12,6 +12,7 @@ export default gql`
|
|||
searchUsers(q: String!, limit: Limit, similarity: Float): [User!]!
|
||||
userSuggestions(q: String, limit: Limit): [User!]!
|
||||
hasNewNotes: Boolean!
|
||||
mySubscribedUsers(cursor: String): Users!
|
||||
}
|
||||
|
||||
type UsersNullable {
|
||||
|
|
|
@ -1,14 +1,30 @@
|
|||
import { createContext, useContext } from 'react'
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { gql } from 'graphql-tag'
|
||||
import Dropdown from 'react-bootstrap/Dropdown'
|
||||
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' }) {
|
||||
const isPosts = target === 'posts'
|
||||
const mutation = isPosts ? 'subscribeUserPosts' : 'subscribeUserComments'
|
||||
const userField = isPosts ? 'meSubscriptionPosts' : 'meSubscriptionComments'
|
||||
const toaster = useToast()
|
||||
const { id, [userField]: meSubscription } = user
|
||||
const { refetchQueries } = useSubscribeUserContext()
|
||||
const [subscribeUser] = useMutation(
|
||||
gql`
|
||||
mutation ${mutation}($id: ID!) {
|
||||
|
@ -16,6 +32,7 @@ export default function SubscribeUserDropdownItem ({ user, target = 'posts' }) {
|
|||
${userField}
|
||||
}
|
||||
}`, {
|
||||
refetchQueries,
|
||||
update (cache, { data: { [mutation]: subscribeUser } }) {
|
||||
cache.modify({
|
||||
id: `User:${id}`,
|
||||
|
|
|
@ -182,14 +182,19 @@ function NymView ({ user, isMe, setEditting }) {
|
|||
<div className={styles.username}>@{user.name}<Hat className='' user={user} badge /></div>
|
||||
{isMe &&
|
||||
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
|
||||
{!isMe && me &&
|
||||
<div className='ms-2'>
|
||||
<ActionDropdown>
|
||||
<SubscribeUserDropdownItem user={user} target='posts' />
|
||||
<SubscribeUserDropdownItem user={user} target='comments' />
|
||||
<MuteDropdownItem user={user} />
|
||||
</ActionDropdown>
|
||||
</div>}
|
||||
{!isMe && me && <NymActionDropdown user={user} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NymActionDropdown ({ user, className = 'ms-2' }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ActionDropdown>
|
||||
<SubscribeUserDropdownItem user={user} target='posts' />
|
||||
<SubscribeUserDropdownItem user={user} target='comments' />
|
||||
<MuteDropdownItem user={user} />
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { useData } from './use-data'
|
|||
import Hat from './hat'
|
||||
import { useMe } from './me'
|
||||
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
|
||||
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])
|
||||
}
|
||||
|
||||
function User ({ user, rank, statComps, Embellish }) {
|
||||
function User ({ user, rank, statComps, Embellish, nymActionDropdown = false }) {
|
||||
const me = useMe()
|
||||
const showStatComps = statComps && statComps.length > 0
|
||||
return (
|
||||
<>
|
||||
{rank
|
||||
|
@ -55,13 +57,17 @@ function User ({ user, rank, statComps, Embellish }) {
|
|||
className={`${userStyles.userimg} me-2`}
|
||||
/>
|
||||
</Link>
|
||||
<div className={styles.hunk}>
|
||||
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
|
||||
@{user.name}<Hat className='ms-1 fill-grey' height={14} width={14} user={user} />
|
||||
</Link>
|
||||
<div className={styles.other}>
|
||||
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
|
||||
<div className={`${styles.hunk} ${!showStatComps && 'd-flex flex-column justify-content-around'}`}>
|
||||
<div className='d-flex'>
|
||||
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
|
||||
@{user.name}<Hat className='ms-1 fill-grey' height={14} width={14} user={user} />
|
||||
</Link>
|
||||
{nymActionDropdown && <NymActionDropdown user={user} className='' />}
|
||||
</div>
|
||||
{showStatComps &&
|
||||
<div className={styles.other}>
|
||||
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
|
||||
</div>}
|
||||
{Embellish && <Embellish rank={rank} />}
|
||||
</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 (
|
||||
<div className={styles.grid}>
|
||||
{users.map((user, i) => (
|
||||
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} />
|
||||
))}
|
||||
</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 dat = useData(data, ssrData)
|
||||
const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator))
|
||||
|
@ -134,7 +140,7 @@ export default function UserList ({ ssrData, query, variables, destructureData,
|
|||
|
||||
return (
|
||||
<>
|
||||
<ListUsers users={users} rank={rank} statComps={statComps} />
|
||||
<ListUsers users={users} rank={rank} statComps={statCompsProp ?? statComps} nymActionDropdown={nymActionDropdown} />
|
||||
{footer &&
|
||||
<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`
|
||||
query TopUsers($cursor: String, $when: String, $from: String, $to: String, $by: String, ) {
|
||||
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: {
|
||||
keyArgs: ['q', 'limit'],
|
||||
merge (existing, incoming) {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Checkbox, Form, Input, SubmitButton, Select, VariableInput, CopyInput }
|
|||
import Alert from 'react-bootstrap/Alert'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
import InputGroup from 'react-bootstrap/InputGroup'
|
||||
import Nav from 'react-bootstrap/Nav'
|
||||
import Layout from '@/components/layout'
|
||||
import { useState, useMemo } from 'react'
|
||||
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 DeleteIcon from '@/svgs/delete-bin-line.svg'
|
||||
import { useField } from 'formik'
|
||||
import styles from './settings.module.css'
|
||||
|
||||
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')))
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const toaster = useToast()
|
||||
const me = useMe()
|
||||
|
@ -61,7 +89,7 @@ export default function Settings ({ ssrData }) {
|
|||
return (
|
||||
<Layout>
|
||||
<div className='pb-3 w-100 mt-2' style={{ maxWidth: '600px' }}>
|
||||
<h2 className='mb-2 text-start'>settings</h2>
|
||||
<SettingsHeader />
|
||||
<Form
|
||||
initial={{
|
||||
tipDefault: settings?.tipDefault || 21,
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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…
Reference in New Issue