Add Territory Sub management tab in Subscriptions (#2191)

* Add Territory Sub management tab in Subscriptions

* don't use queryRawUnsafe

* auto width on select

* separate into pages for browser nav

* fix multiple separators

* simplify queries

---------

Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
Bryan Mutai 2025-06-13 23:01:25 +03:00 committed by GitHub
parent 874694eb10
commit dc01ebdb26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 195 additions and 72 deletions

View File

@ -127,7 +127,7 @@ export default {
subs
}
},
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models }) => {
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models, me }) => {
if (!name) {
throw new GqlInputError('must supply user name')
}
@ -150,26 +150,56 @@ export default {
}
const subs = await models.$queryRawUnsafe(`
SELECT "Sub".*,
"Sub".created_at as "createdAt",
COALESCE(floor(sum(msats_revenue)/1000), 0) as revenue,
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments
FROM ${viewGroup(range, 'sub_stats')}
JOIN "Sub" on "Sub".name = u.sub_name
WHERE "Sub"."userId" = $3
AND "Sub".status = 'ACTIVE'
GROUP BY "Sub".name
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
OFFSET $4
LIMIT $5`, ...range, user.id, decodedCursor.offset, limit)
SELECT "Sub".*,
"Sub".created_at as "createdAt",
COALESCE(floor(sum(msats_revenue)/1000), 0) as revenue,
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments,
ss."userId" IS NOT NULL as "meSubscription",
ms."userId" IS NOT NULL as "meMuteSub"
FROM ${viewGroup(range, 'sub_stats')}
JOIN "Sub" on "Sub".name = u.sub_name
LEFT JOIN "SubSubscription" ss ON ss."subName" = "Sub".name AND ss."userId" IS NOT DISTINCT FROM $4
LEFT JOIN "MuteSub" ms ON ms."subName" = "Sub".name AND ms."userId" IS NOT DISTINCT FROM $4
WHERE "Sub"."userId" = $3
AND "Sub".status = 'ACTIVE'
GROUP BY "Sub".name, ss."userId", ms."userId"
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
OFFSET $5
LIMIT $6
`, ...range, user.id, me?.id, decodedCursor.offset, limit)
return {
cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
subs
}
},
mySubscribedSubs: async (parent, { cursor }, { models, me }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const decodedCursor = decodeCursor(cursor)
const subs = await models.$queryRaw`
SELECT "Sub".*,
"MuteSub"."userId" IS NOT NULL as "meMuteSub",
TRUE as "meSubscription"
FROM "SubSubscription"
JOIN "Sub" ON "SubSubscription"."subName" = "Sub".name
LEFT JOIN "MuteSub" ON "MuteSub"."subName" = "Sub".name AND "MuteSub"."userId" = ${me.id}
WHERE "SubSubscription"."userId" = ${me.id}
AND "Sub".status <> 'STOPPED'
ORDER BY "Sub".name ASC
OFFSET ${decodedCursor.offset}
LIMIT ${LIMIT}
`
return {
cursor: subs.length === LIMIT ? nextCursorEncoded(decodedCursor, LIMIT) : null,
subs
}
}
},
Mutation: {

View File

@ -7,6 +7,7 @@ export default gql`
subs: [Sub!]!
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
mySubscribedSubs(cursor: String): Subs
subSuggestions(q: String!, limit: Limit): [Sub!]!
}

View File

@ -1,3 +1,4 @@
import { createContext, useContext } from 'react'
import { Badge, Button, CardFooter, Dropdown } from 'react-bootstrap'
import { AccordianCard } from './accordian-item'
import TerritoryPaymentDue, { TerritoryBillingLine } from './territory-payment-due'
@ -13,6 +14,16 @@ import { useToast } from './toast'
import ActionDropdown from './action-dropdown'
import { TerritoryTransferDropdownItem } from './territory-transfer'
const SubscribeTerritoryContext = createContext({ refetchQueries: [] })
export const SubscribeTerritoryContextProvider = ({ children, value }) => (
<SubscribeTerritoryContext.Provider value={value}>
{children}
</SubscribeTerritoryContext.Provider>
)
export const useSubscribeTerritoryContext = () => useContext(SubscribeTerritoryContext)
export function TerritoryDetails ({ sub, children }) {
return (
<AccordianCard
@ -149,12 +160,15 @@ export default function TerritoryHeader ({ sub }) {
export function MuteSubDropdownItem ({ item, sub }) {
const toaster = useToast()
const { refetchQueries } = useSubscribeTerritoryContext()
const [toggleMuteSub] = useMutation(
gql`
mutation toggleMuteSub($name: String!) {
toggleMuteSub(name: $name)
}`, {
refetchQueries,
awaitRefetchQueries: true,
update (cache, { data: { toggleMuteSub } }) {
cache.modify({
id: `Sub:{"name":"${sub.name}"}`,
@ -213,11 +227,14 @@ export function PinSubDropdownItem ({ item: { id, position } }) {
export function ToggleSubSubscriptionDropdownItem ({ sub: { name, meSubscription } }) {
const toaster = useToast()
const { refetchQueries } = useSubscribeTerritoryContext()
const [toggleSubSubscription] = useMutation(
gql`
mutation toggleSubSubscription($name: String!) {
toggleSubSubscription(name: $name)
}`, {
refetchQueries,
awaitRefetchQueries: true,
update (cache, { data: { toggleSubSubscription } }) {
cache.modify({
id: `Sub:{"name":"${name}"}`,

View File

@ -5,8 +5,10 @@ import React, { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@apollo/client'
import MoreFooter from './more-footer'
import { useData } from './use-data'
import { useMe } from './me'
import Info from './info'
import { TerritoryInfo } from './territory-header'
import ActionDropdown from './action-dropdown'
import { TerritoryInfo, ToggleSubSubscriptionDropdownItem, MuteSubDropdownItem } from './territory-header'
// all of this nonsense is to show the stat we are sorting by first
const Revenue = ({ sub }) => (sub.optional.revenue !== null && <span>{abbrNum(sub.optional.revenue)} revenue</span>)
@ -35,16 +37,17 @@ function separate (arr, separator) {
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, separator] : [x])
}
export default function TerritoryList ({ ssrData, query, variables, destructureData, rank }) {
export default function TerritoryList ({ ssrData, query, variables, destructureData, rank, subActionDropdown, statCompsProp = STAT_COMPONENTS }) {
const { data, fetchMore } = useQuery(query, { variables })
const dat = useData(data, ssrData)
const [statComps, setStatComps] = useState(separate(STAT_COMPONENTS, Separator))
const { me } = useMe()
const [statComps, setStatComps] = useState(separate(statCompsProp, Separator))
useEffect(() => {
// shift the stat we are sorting by to the front
const comps = [...STAT_COMPONENTS]
const comps = [...statCompsProp]
setStatComps(separate([...comps.splice(STAT_POS[variables?.by || 0], 1), ...comps], Separator))
}, [variables?.by])
}, [variables?.by], statCompsProp)
const { subs, cursor } = useMemo(() => {
if (!dat) return {}
@ -77,6 +80,12 @@ export default function TerritoryList ({ ssrData, query, variables, destructureD
{sub.name}
</Link>
<Info className='d-flex'><TerritoryInfo sub={sub} /></Info>
{me && subActionDropdown && (
<ActionDropdown>
<ToggleSubSubscriptionDropdownItem sub={sub} />
<MuteSubDropdownItem sub={sub} />
</ActionDropdown>
)}
</div>
<div className={styles.other}>
{statComps.map((Comp, i) => <Comp key={i} sub={sub} />)}

View File

@ -216,25 +216,6 @@ export const USER_FIELDS = gql`
...StreakFields
}`
export const MY_SUBSCRIBED_USERS = gql`
${STREAK_FIELDS}
query MySubscribedUsers($cursor: String) {
mySubscribedUsers(cursor: $cursor) {
users {
id
name
photoId
meSubscriptionPosts
meSubscriptionComments
meMute
...StreakFields
}
cursor
}
}
`
export const MY_MUTED_USERS = gql`
${STREAK_FIELDS}
query MyMutedUsers($cursor: String) {
@ -390,3 +371,33 @@ export const USER_STATS = gql`
}
}
}`
export const MY_SUBSCRIBED_USERS = gql`
${STREAK_FIELDS}
query MySubscribedUsers($cursor: String) {
mySubscribedUsers(cursor: $cursor) {
users {
id
name
photoId
meSubscriptionPosts
meSubscriptionComments
meMute
...StreakFields
}
cursor
}
}
`
export const MY_SUBSCRIBED_SUBS = gql`
${SUB_FULL_FIELDS}
query MySubscribedSubs($cursor: String) {
mySubscribedSubs(cursor: $cursor) {
subs {
...SubFullFields
}
cursor
}
}
`

View File

@ -25,6 +25,7 @@ export default function UserTerritories ({ ssrData }) {
query={USER_WITH_SUBS}
variables={variables}
destructureData={data => data.userSubs}
subActionDropdown
rank
/>
</div>

View File

@ -68,7 +68,7 @@ export function SettingsHeader () {
</Link>
</Nav.Item>
<Nav.Item>
<Link href='/settings/subscriptions' passHref legacyBehavior>
<Link href='/settings/subscriptions/stackers' passHref legacyBehavior>
<Nav.Link eventKey='subscriptions'>subscriptions</Nav.Link>
</Link>
</Nav.Item>

View File

@ -1,31 +0,0 @@
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>
)
}

View File

@ -0,0 +1,55 @@
import { useMemo } from 'react'
import { getGetServerSideProps } from '@/api/ssrApollo'
import Layout from '@/components/layout'
import { Select } from '@/components/form'
import UserList from '@/components/user-list'
import { MY_SUBSCRIBED_USERS } from '@/fragments/users'
import { SettingsHeader } from '../index'
import { SubscribeUserContextProvider } from '@/components/subscribeUser'
import { useRouter } from 'next/router'
export const getServerSideProps = getGetServerSideProps({
query: MY_SUBSCRIBED_USERS,
authRequired: true
})
export function SubscriptionLayout ({ subType, children }) {
const router = useRouter()
return (
<Layout>
<div className='pb-3 w-100 mt-2'>
<SettingsHeader />
<Select
name='subscriptionType'
size='sm'
className='w-auto'
noForm
items={['stackers', 'territories']}
value={subType}
onChange={(_, e) => router.push(`/settings/subscriptions/${e.target.value}`)}
/>
{children}
</div>
</Layout>
)
}
export default function MySubscribedUsers ({ ssrData }) {
const subscribeContextValue = useMemo(() => ({ refetchQueries: ['MySubscribedUsers'] }), [])
return (
<SubscriptionLayout subType='stackers'>
<SubscribeUserContextProvider value={subscribeContextValue}>
<UserList
ssrData={ssrData}
query={MY_SUBSCRIBED_USERS}
destructureData={data => data.mySubscribedUsers}
variables={{}}
rank
nymActionDropdown
statCompsProp={[]}
/>
</SubscribeUserContextProvider>
</SubscriptionLayout>
)
}

View File

@ -0,0 +1,30 @@
import { useMemo } from 'react'
import { getGetServerSideProps } from '@/api/ssrApollo'
import { MY_SUBSCRIBED_SUBS } from '@/fragments/users'
import TerritoryList from '@/components/territory-list'
import { SubscribeTerritoryContextProvider } from '@/components/territory-header'
import { SubscriptionLayout } from './stackers'
export const getServerSideProps = getGetServerSideProps({
query: MY_SUBSCRIBED_SUBS,
authRequired: true
})
export default function MySubscribedSubs ({ ssrData }) {
const subscribeContextValue = useMemo(() => ({ refetchQueries: ['MySubscribedSubs'] }), [])
return (
<SubscriptionLayout subType='territories'>
<SubscribeTerritoryContextProvider value={subscribeContextValue}>
<TerritoryList
ssrData={ssrData}
query={MY_SUBSCRIBED_SUBS}
variables={{}}
destructureData={data => data.mySubscribedSubs}
rank
subActionDropdown
statCompsProp={[]}
/>
</SubscribeTerritoryContextProvider>
</SubscriptionLayout>
)
}