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:
SatsAllDay 2024-04-03 20:38:47 -04:00 committed by GitHub
parent 15fb7f446b
commit 992fc54160
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 179 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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