diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 6d20c9976..a7cb90de3 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -190,6 +190,51 @@ export default { users } }, + userMutedUsers: async (parent, { name, cursor }, { models, me }) => { + const user = await models.user.findUnique({ where: { name } }) + if (!user) { + throw new GqlInputError('no such user') + } + if (!user.showMutedUsers && (!me || me.id !== user.id)) { + return null + } + const decodedCursor = decodeCursor(cursor) + const users = await models.$queryRaw` + SELECT users.* + FROM "Mute" + JOIN users ON "Mute"."mutedId" = users.id + WHERE "Mute"."muterId" = ${user.id} + OFFSET ${decodedCursor.offset} + LIMIT ${LIMIT} + ` + return { + cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + users + } + }, + userSubscribedUsers: async (parent, { name, cursor }, { models, me }) => { + const user = await models.user.findUnique({ where: { name } }) + if (!user) { + throw new GqlInputError('no such user') + } + if (!user.showSubscribedUsers && (!me || me.id !== user.id)) { + return null + } + const decodedCursor = decodeCursor(cursor) + const users = await models.$queryRaw` + SELECT users.* + FROM "UserSubscription" + JOIN users ON "UserSubscription"."followeeId" = users.id + WHERE "UserSubscription"."followerId" = ${user.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 { users, cursor: topCowboysCursor } = await topUsers(parent, { cursor, when: 'forever', by: 'streak', limit: LIMIT }, { models, me }) const cowboys = users.map(u => (u?.hideCowboyHat && (!me || me.id !== u.id)) ? null : u).filter(u => u?.streak !== null) @@ -857,6 +902,26 @@ export default { return await isMuted({ models, muterId: me.id, mutedId: user.id }) }, + showSubscribedUsers: user => user.showSubscribedUsers ?? false, + showMutedUsers: user => user.showMutedUsers ?? false, + nsubscribed: async (user, args, { models }) => { + if (typeof user.nsubscribed !== 'undefined') return user.nsubscribed + return await models.userSubscription.count({ + where: { + followerId: user.id, + OR: [ + { postsSubscribedAt: { not: null } }, + { commentsSubscribedAt: { not: null } } + ] + } + }) + }, + nmuted: async (user, args, { models }) => { + if (typeof user.nmuted !== 'undefined') return user.nmuted + return await models.mute.count({ + where: { muterId: user.id } + }) + }, since: async (user, args, { models }) => { // get the user's first item const item = await models.item.findFirst({ diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 8a63285ce..1cf6ed1dc 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -14,6 +14,8 @@ export default gql` hasNewNotes: Boolean! mySubscribedUsers(cursor: String): Users! myMutedUsers(cursor: String): Users! + userMutedUsers(name: String!, cursor: String): Users + userSubscribedUsers(name: String!, cursor: String): Users } type UsersNullable { @@ -75,6 +77,10 @@ export default gql` meMute: Boolean! meSubscriptionPosts: Boolean! meSubscriptionComments: Boolean! + showSubscribedUsers: Boolean! + showMutedUsers: Boolean! + nsubscribed: Int! + nmuted: Int! } input SettingsInput { @@ -91,6 +97,8 @@ export default gql` hideInvoiceDesc: Boolean! imgproxyOnly: Boolean! showImagesAndVideos: Boolean! + showMutedUsers: Boolean! + showSubscribedUsers: Boolean! nostrCrossposting: Boolean! nostrPubkey: String nostrRelays: [String!] diff --git a/components/user-header.js b/components/user-header.js index ef30f15e2..dd78ea61a 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -36,10 +36,25 @@ const MEDIA_URL = process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NE export default function UserHeader ({ user }) { const router = useRouter() + const { me } = useMe() const pathParts = router.asPath.split('/') - const activeKey = pathParts[2] === 'territories' ? 'territories' : pathParts.length === 2 ? 'bio' : 'items' + let activeKey = 'bio' + if (pathParts[2] === 'territories') { + activeKey = 'territories' + } else if (pathParts[2] === 'subscribed') { + activeKey = 'subscribed' + } else if (pathParts[2] === 'muted') { + activeKey = 'muted' + } else if (pathParts.length > 2) { + activeKey = 'items' + } + + const isMe = me?.name === user.name const showTerritoriesTab = activeKey === 'territories' || user.nterritories > 0 + // Show subscribed/muted tabs only if: viewing own profile OR list is public + const showSubscribedTab = isMe || user.showSubscribedUsers === true + const showMutedTab = isMe || user.showMutedUsers === true return ( <> @@ -77,6 +92,32 @@ export default function UserHeader ({ user }) { )} + {showSubscribedTab && ( + + + + {numWithUnits(user.nsubscribed, { + abbreviate: false, + unitSingular: 'subscription', + unitPlural: 'subscriptions' + })} + + + + )} + {showMutedTab && ( + + + + {numWithUnits(user.nmuted, { + abbreviate: false, + unitSingular: 'muted', + unitPlural: 'muted' + })} + + + + )} > ) diff --git a/fragments/users.js b/fragments/users.js index 30b5c7895..bac035c42 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -58,6 +58,8 @@ ${STREAK_FIELDS} export const SETTINGS_FIELDS = gql` fragment SettingsFields on User { + showSubscribedUsers + showMutedUsers privates { tipDefault tipRandom @@ -170,6 +172,10 @@ export const USER_FIELDS = gql` meSubscriptionPosts meSubscriptionComments meMute + showSubscribedUsers + showMutedUsers + nsubscribed + nmuted optional { stacked @@ -345,6 +351,50 @@ export const MY_SUBSCRIBED_SUBS = gql` } ` +export const USER_SUBSCRIBED_USERS = gql` + ${USER_FIELDS} + ${STREAK_FIELDS} + query UserSubscribedUsers($name: String!, $cursor: String) { + user(name: $name) { + ...UserFields + } + userSubscribedUsers(name: $name, cursor: $cursor) { + users { + id + name + photoId + meSubscriptionPosts + meSubscriptionComments + meMute + ...StreakFields + } + cursor + } + } +` + +export const USER_MUTED_USERS = gql` + ${USER_FIELDS} + ${STREAK_FIELDS} + query UserMutedUsers($name: String!, $cursor: String) { + user(name: $name) { + ...UserFields + } + userMutedUsers(name: $name, cursor: $cursor) { + users { + id + name + photoId + meSubscriptionPosts + meSubscriptionComments + meMute + ...StreakFields + } + cursor + } + } +` + export const SET_DIAGNOSTICS = gql` mutation setDiagnostics($diagnostics: Boolean!) { setDiagnostics(diagnostics: $diagnostics) diff --git a/lib/validate.js b/lib/validate.js index f66f8a136..5091478f1 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -402,7 +402,9 @@ export const settingsSchema = object().shape({ hideTwitter: boolean(), noReferralLinks: boolean(), satsFilter: intValidator.required('required').min(0, 'must be at least 0').max(1000, 'must be at most 1000'), - zapUndos: intValidator.nullable().min(0, 'must be greater or equal to 0') + zapUndos: intValidator.nullable().min(0, 'must be greater or equal to 0'), + showMutedUsers: boolean(), + showSubscribedUsers: boolean() // exclude from cyclic analysis. see https://github.com/jquense/yup/issues/720 }, [['tipRandomMax', 'tipRandomMin']]) diff --git a/pages/[name]/muted.js b/pages/[name]/muted.js new file mode 100644 index 000000000..c40a543d7 --- /dev/null +++ b/pages/[name]/muted.js @@ -0,0 +1,51 @@ +import { useMemo } from 'react' +import { getGetServerSideProps } from '@/api/ssrApollo' +import { useRouter } from 'next/router' +import { USER_MUTED_USERS } from '@/fragments/users' +import { useQuery } from '@apollo/client' +import PageLoading from '@/components/page-loading' +import { UserLayout } from '.' +import UserList from '@/components/user-list' +import { useMe } from '@/components/me' +import { MuteUserContextProvider } from '@/components/mute' + +export const getServerSideProps = getGetServerSideProps({ query: USER_MUTED_USERS }) + +export default function UserMuted ({ ssrData }) { + const router = useRouter() + const variables = { ...router.query } + const { me } = useMe() + const { data } = useQuery(USER_MUTED_USERS, { variables }) + const muteContextValue = useMemo(() => ({ refetchQueries: ['UserMutedUsers'] }), []) + if (!data && !ssrData) return + const { user } = data || ssrData + const mutedData = data?.userMutedUsers || ssrData?.userMutedUsers + const isPrivate = mutedData === null + const isMe = me?.name === user.name + return ( + + + {isPrivate && !isMe + ? ( + + Private List + @{user.name} has chosen to keep their muted stackers private. + + ) + : ( + + data.userMutedUsers || { users: [], cursor: null }} + rank + nymActionDropdown + statCompsProp={[]} + /> + + )} + + + ) +} diff --git a/pages/[name]/subscribed.js b/pages/[name]/subscribed.js new file mode 100644 index 000000000..77e320cce --- /dev/null +++ b/pages/[name]/subscribed.js @@ -0,0 +1,51 @@ +import { useMemo } from 'react' +import { getGetServerSideProps } from '@/api/ssrApollo' +import { useRouter } from 'next/router' +import { USER_SUBSCRIBED_USERS } from '@/fragments/users' +import { useQuery } from '@apollo/client' +import PageLoading from '@/components/page-loading' +import { UserLayout } from '.' +import UserList from '@/components/user-list' +import { useMe } from '@/components/me' +import { SubscribeUserContextProvider } from '@/components/subscribeUser' + +export const getServerSideProps = getGetServerSideProps({ query: USER_SUBSCRIBED_USERS }) + +export default function UserSubscribed ({ ssrData }) { + const router = useRouter() + const variables = { ...router.query } + const { me } = useMe() + const { data } = useQuery(USER_SUBSCRIBED_USERS, { variables }) + const subscribeContextValue = useMemo(() => ({ refetchQueries: ['UserSubscribedUsers'] }), []) + if (!data && !ssrData) return + const { user } = data || ssrData + const subscribedData = data?.userSubscribedUsers || ssrData?.userSubscribedUsers + const isPrivate = subscribedData === null + const isMe = me?.name === user.name + return ( + + + {isPrivate && !isMe + ? ( + + Private List + @{user.name} has chosen to keep their subscribed stackers private. + + ) + : ( + + data.userSubscribedUsers || { users: [], cursor: null }} + rank + nymActionDropdown + statCompsProp={[]} + /> + + )} + + + ) +} diff --git a/pages/settings/index.js b/pages/settings/index.js index ec9a7e9f6..1c2f6ac6a 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -98,7 +98,9 @@ export default function Settings ({ ssrData }) { }) const { data } = useQuery(SETTINGS) - const { settings: { privates: settings } } = useMemo(() => data ?? ssrData, [data, ssrData]) + const settingsData = useMemo(() => data ?? ssrData, [data, ssrData]) + const settings = settingsData?.settings?.privates + const user = settingsData?.settings // if we switched to anon, me is null before the page is reloaded if ((!data && !ssrData) || !me) return @@ -136,6 +138,8 @@ export default function Settings ({ ssrData }) { hideGithub: settings?.hideGithub, hideNostr: settings?.hideNostr, hideTwitter: settings?.hideTwitter, + showSubscribedUsers: user?.showSubscribedUsers, + showMutedUsers: user?.showMutedUsers, imgproxyOnly: settings?.imgproxyOnly, showImagesAndVideos: settings?.showImagesAndVideos, wildWestMode: settings?.wildWestMode, @@ -403,6 +407,36 @@ export default function Settings ({ ssrData }) { name='hideTwitter' groupClassName='mb-0' /> + show my subscribed stackers on my profile + + + Your subscribed stackers list is private by default + Check this to make it visible on your public profile + Others will be able to see who you are subscribed to + + + + } + name='showSubscribedUsers' + groupClassName='mb-0' + /> + show my muted stackers on my profile + + + Your muted stackers list is private by default + Check this to make it visible on your public profile + Others will be able to see who you have muted + + + + } + name='showMutedUsers' + groupClassName='mb-0' + /> do not load images, videos, or content from external sites diff --git a/prisma/migrations/20260116175457_add_show_list_flag/migration.sql b/prisma/migrations/20260116175457_add_show_list_flag/migration.sql new file mode 100644 index 000000000..a540b5632 --- /dev/null +++ b/prisma/migrations/20260116175457_add_show_list_flag/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "showMutedUsers" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "showSubscribedUsers" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1ced2d3fc..3f9a0fa2f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,6 +110,8 @@ model User { muteds Mute[] @relation("muted") Sub Sub[] MuteSub MuteSub[] + showMutedUsers Boolean @default(false) + showSubscribedUsers Boolean @default(false) wallets Wallet[] TerritoryTransfers TerritoryTransfer[] @relation("TerritoryTransfer_oldUser") TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser")
@{user.name} has chosen to keep their muted stackers private.
@{user.name} has chosen to keep their subscribed stackers private.