Add nsfw setting to territories (#788)

* add nsfw column to sub

* add nsfw boolean to territorySchema

* save nsfw value in upsertSub mutation

* return nsfw value from Sub query for correct value in edit territory form

* add nsfw checkbox to territory form

* add nsfw badge to territory header

* add nsfwMode to user

* show nsfw badge next to item territory

* exclude nsfw sub from items query

* show nsfw mode checkbox on settings page

* fix nsfw badge formatting

* separate user from current, signed in user

* update relationClause to join with sub table

* refactor to simplify hide nsfw sql

* filter nsfw items when viewing user items

* hide nsfw posts for logged out users

* filter nsfw subs based on user preference

* show nsfw sub name if logged out user is viewing the page

* show current sub at the top of the list instead of bottom

* always join item with sub to check nsfw

* check for sub presence before showing nsfw badge on item

* skip manually adding sub to select if sub is null

* fix relationClause to join with root item

* move moderation and nsfw into accordion

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
mzivil 2024-02-09 21:35:32 -05:00 committed by GitHub
parent b3d485e8c4
commit 6355d7eabc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 121 additions and 43 deletions

View File

@ -152,19 +152,19 @@ const relationClause = (type) => {
let clause = '' let clause = ''
switch (type) { switch (type) {
case 'comments': case 'comments':
clause += ' FROM "Item" JOIN "Item" root ON "Item"."rootId" = root.id ' clause += ' FROM "Item" JOIN "Item" root ON "Item"."rootId" = root.id LEFT JOIN "Sub" ON "Sub"."name" = root."subName" '
break break
case 'bookmarks': case 'bookmarks':
clause += ' FROM "Item" JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" ' clause += ' FROM "Item" JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" '
break break
case 'outlawed': case 'outlawed':
case 'borderland': case 'borderland':
case 'freebies': case 'freebies':
case 'all': case 'all':
clause += ' FROM "Item" LEFT JOIN "Item" root ON "Item"."rootId" = root.id ' clause += ' FROM "Item" LEFT JOIN "Item" root ON "Item"."rootId" = root.id LEFT JOIN "Sub" ON "Sub"."name" = root."subName" '
break break
default: default:
clause += ' FROM "Item" ' clause += ' FROM "Item" LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" '
} }
return clause return clause
@ -192,12 +192,20 @@ const activeOrMine = (me) => {
export const muteClause = me => export const muteClause = me =>
me ? `NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId")` : '' me ? `NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId")` : ''
const subClause = (sub, num, table, me) => { const HIDE_NSFW_CLAUSE = '"Sub"."nsfw" = FALSE'
return sub
? `${table ? `"${table}".` : ''}"subName" = $${num}::CITEXT` export const nsfwClause = showNsfw => showNsfw ? '' : HIDE_NSFW_CLAUSE
: me
? `NOT EXISTS (SELECT 1 FROM "MuteSub" WHERE "MuteSub"."userId" = ${me.id} AND "MuteSub"."subName" = ${table ? `"${table}".` : ''}"subName")` const subClause = (sub, num, table, me, showNsfw) => {
: '' // Intentionally show nsfw posts (i.e. no nsfw clause) when viewing a specific nsfw sub
if (sub) { return `${table ? `"${table}".` : ''}"subName" = $${num}::CITEXT` }
if (!me) { return HIDE_NSFW_CLAUSE }
const excludeMuted = `NOT EXISTS (SELECT 1 FROM "MuteSub" WHERE "MuteSub"."userId" = ${me.id} AND "MuteSub"."subName" = ${table ? `"${table}".` : ''}"subName")`
if (showNsfw) return excludeMuted
return excludeMuted + ' AND ' + HIDE_NSFW_CLAUSE
} }
export async function filterClause (me, models, type) { export async function filterClause (me, models, type) {
@ -307,6 +315,9 @@ export default {
// but the query planner doesn't like unused parameters // but the query planner doesn't like unused parameters
const subArr = sub ? [sub] : [] const subArr = sub ? [sub] : []
const currentUser = me ? await models.user.findUnique({ where: { id: me.id } }) : null
const showNsfw = currentUser ? currentUser.nsfwMode : false
switch (sort) { switch (sort) {
case 'user': case 'user':
if (!name) { if (!name) {
@ -329,6 +340,7 @@ export default {
`"${table}"."userId" = $3`, `"${table}"."userId" = $3`,
activeOrMine(me), activeOrMine(me),
await filterClause(me, models, type), await filterClause(me, models, type),
nsfwClause(showNsfw),
typeClause(type), typeClause(type),
whenClause(when || 'forever', table))} whenClause(when || 'forever', table))}
${orderByClause(by, me, models, type)} ${orderByClause(by, me, models, type)}
@ -346,7 +358,7 @@ export default {
${relationClause(type)} ${relationClause(type)}
${whereClause( ${whereClause(
'"Item".created_at <= $1', '"Item".created_at <= $1',
subClause(sub, 4, subClauseTable(type), me), subClause(sub, 4, subClauseTable(type), me, showNsfw),
activeOrMine(me), activeOrMine(me),
await filterClause(me, models, type), await filterClause(me, models, type),
typeClause(type), typeClause(type),
@ -370,7 +382,7 @@ export default {
${joinZapRankPersonalView(me, models)} ${joinZapRankPersonalView(me, models)}
${whereClause( ${whereClause(
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
subClause(sub, 5, subClauseTable(type), me), subClause(sub, 5, subClauseTable(type), me, showNsfw),
typeClause(type), typeClause(type),
whenClause(when, 'Item'), whenClause(when, 'Item'),
await filterClause(me, models, type), await filterClause(me, models, type),
@ -389,7 +401,7 @@ export default {
${relationClause(type)} ${relationClause(type)}
${whereClause( ${whereClause(
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
subClause(sub, 5, subClauseTable(type), me), subClause(sub, 5, subClauseTable(type), me, showNsfw),
typeClause(type), typeClause(type),
whenClause(when, 'Item'), whenClause(when, 'Item'),
await filterClause(me, models, type), await filterClause(me, models, type),
@ -440,13 +452,14 @@ export default {
query: ` query: `
${SELECT}, ${me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score'} AS rank ${SELECT}, ${me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score'} AS rank
FROM "Item" FROM "Item"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${joinZapRankPersonalView(me, models)} ${joinZapRankPersonalView(me, models)}
${whereClause( ${whereClause(
'"Item"."pinId" IS NULL', '"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL', '"Item"."parentId" IS NULL',
'"Item".bio = false', '"Item".bio = false',
subClause(sub, 3, 'Item', me), subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me))} muteClause(me))}
ORDER BY rank DESC ORDER BY rank DESC
OFFSET $1 OFFSET $1
@ -462,8 +475,9 @@ export default {
query: ` query: `
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${whereClause( ${whereClause(
subClause(sub, 3, 'Item', me), subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me), muteClause(me),
// in "home" (sub undefined), we want to show pinned items (but without the pin icon) // in "home" (sub undefined), we want to show pinned items (but without the pin icon)
sub ? '"Item"."pinId" IS NULL' : '', sub ? '"Item"."pinId" IS NULL' : '',

View File

@ -84,21 +84,24 @@ export default {
sub: getSub, sub: getSub,
subs: async (parent, args, { models, me }) => { subs: async (parent, args, { models, me }) => {
if (me) { if (me) {
return await models.$queryRaw` const currentUser = await models.user.findUnique({ where: { id: me.id } })
const showNsfw = currentUser ? currentUser.nsfwMode : false
return await models.$queryRawUnsafe(`
SELECT "Sub".*, COALESCE(json_agg("MuteSub".*) FILTER (WHERE "MuteSub"."userId" IS NOT NULL), '[]') AS "MuteSub" SELECT "Sub".*, COALESCE(json_agg("MuteSub".*) FILTER (WHERE "MuteSub"."userId" IS NOT NULL), '[]') AS "MuteSub"
FROM "Sub" FROM "Sub"
LEFT JOIN "MuteSub" ON "Sub".name = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}::INTEGER LEFT JOIN "MuteSub" ON "Sub".name = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}::INTEGER
WHERE status <> 'STOPPED' WHERE status <> 'STOPPED' ${showNsfw ? '' : 'AND "Sub"."nsfw" = FALSE'}
GROUP BY "Sub".name, "MuteSub"."userId" GROUP BY "Sub".name, "MuteSub"."userId"
ORDER BY "Sub".name ASC ORDER BY "Sub".name ASC
` `)
} }
return await models.sub.findMany({ return await models.sub.findMany({
where: { where: {
status: { status: {
not: 'STOPPED' not: 'STOPPED'
} },
nsfw: false
}, },
orderBy: { orderBy: {
name: 'asc' name: 'asc'
@ -193,7 +196,7 @@ export default {
} }
async function createSub (parent, data, { me, models, lnd, hash, hmac }) { async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
const { billingType } = data const { billingType, nsfw } = data
let billingCost = TERRITORY_COST_MONTHLY let billingCost = TERRITORY_COST_MONTHLY
let billAt = datePivot(new Date(), { months: 1 }) let billAt = datePivot(new Date(), { months: 1 })
@ -226,7 +229,8 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
...data, ...data,
billingCost, billingCost,
rankingType: 'WOT', rankingType: 'WOT',
userId: me.id userId: me.id,
nsfw
} }
}), }),
// record 'em // record 'em

View File

@ -11,7 +11,7 @@ export default gql`
upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!, upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!, postTypes: [String!]!, allowFreebies: Boolean!,
billingType: String!, billingAutoRenew: Boolean!, billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, hash: String, hmac: String): Sub moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): Sub
paySub(name: String!, hash: String, hmac: String): Sub paySub(name: String!, hash: String, hmac: String): Sub
toggleMuteSub(name: String!): Boolean! toggleMuteSub(name: String!): Boolean!
} }
@ -35,5 +35,6 @@ export default gql`
moderated: Boolean! moderated: Boolean!
moderatedCount: Int! moderatedCount: Int!
meMuteSub: Boolean! meMuteSub: Boolean!
nsfw: Boolean!
} }
` `

View File

@ -80,6 +80,7 @@ export default gql`
noteItemSats: Boolean! noteItemSats: Boolean!
noteJobIndicator: Boolean! noteJobIndicator: Boolean!
noteMentions: Boolean! noteMentions: Boolean!
nsfwMode: Boolean!
tipDefault: Int! tipDefault: Int!
turboTipping: Boolean! turboTipping: Boolean!
wildWestMode: Boolean! wildWestMode: Boolean!
@ -138,6 +139,7 @@ export default gql`
noteItemSats: Boolean! noteItemSats: Boolean!
noteJobIndicator: Boolean! noteJobIndicator: Boolean!
noteMentions: Boolean! noteMentions: Boolean!
nsfwMode: Boolean!
tipDefault: Int! tipDefault: Int!
turboTipping: Boolean! turboTipping: Boolean!
wildWestMode: Boolean! wildWestMode: Boolean!

View File

@ -122,6 +122,8 @@ export default function ItemInfo ({
<Link href={`/~${item.subName}`}> <Link href={`/~${item.subName}`}>
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge> {' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
</Link>} </Link>}
{sub?.nsfw &&
<Badge className={styles.newComment} bg={null}>nsfw</Badge>}
{(item.outlawed && !item.mine && {(item.outlawed && !item.mine &&
<Link href='/recent/outlawed'> <Link href='/recent/outlawed'>
{' '}<Badge className={styles.newComment} bg={null}>outlawed</Badge> {' '}<Badge className={styles.newComment} bg={null}>outlawed</Badge>

View File

@ -53,6 +53,10 @@ export default function SubSelect ({ prependSubs, sub, onChange, large, appendSu
overrideValue: sub overrideValue: sub
} }
// If logged out user directly visits a nsfw sub, subs will not contain `sub`, so manually add it
// to display the correct sub name in the sub selector
const subItems = !sub || subs.find((s) => s === sub) ? subs : [sub].concat(subs)
return ( return (
<Select <Select
onChange={onChange || ((_, e) => { onChange={onChange || ((_, e) => {
@ -102,7 +106,7 @@ export default function SubSelect ({ prependSubs, sub, onChange, large, appendSu
{...valueProps} {...valueProps}
{...props} {...props}
className={`${className} ${styles.subSelect} ${large ? 'me-2' : styles.subSelectSmall}`} className={`${className} ${styles.subSelect} ${large ? 'me-2' : styles.subSelectSmall}`}
items={subs} items={subItems}
/> />
) )
} }

View File

@ -1,3 +1,4 @@
import AccordianItem from './accordian-item'
import { Col, InputGroup, Row, Form as BootstrapForm, Badge } from 'react-bootstrap' import { Col, InputGroup, Row, Form as BootstrapForm, Badge } from 'react-bootstrap'
import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form' import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form'
import FeeButton, { FeeButtonProvider } from './fee-button' import FeeButton, { FeeButtonProvider } from './fee-button'
@ -17,10 +18,10 @@ export default function TerritoryForm ({ sub }) {
gql` gql`
mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!, mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!, $postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String) { $billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String, $nsfw: Boolean!) {
upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost, upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType, postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac) { billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac, nsfw: $nsfw) {
name name
} }
}` }`
@ -65,7 +66,8 @@ export default function TerritoryForm ({ sub }) {
allowFreebies: typeof sub?.allowFreebies === 'undefined' ? true : sub?.allowFreebies, allowFreebies: typeof sub?.allowFreebies === 'undefined' ? true : sub?.allowFreebies,
billingType: sub?.billingType || 'MONTHLY', billingType: sub?.billingType || 'MONTHLY',
billingAutoRenew: sub?.billingAutoRenew || false, billingAutoRenew: sub?.billingAutoRenew || false,
moderated: sub?.moderated || false moderated: sub?.moderated || false,
nsfw: sub?.nsfw || false
}} }}
schema={territorySchema({ client, me })} schema={territorySchema({ client, me })}
invoiceable invoiceable
@ -145,22 +147,6 @@ export default function TerritoryForm ({ sub }) {
</Col> </Col>
</Row> </Row>
</CheckboxGroup> </CheckboxGroup>
<BootstrapForm.Label>moderation</BootstrapForm.Label>
<Checkbox
inline
label={
<div className='d-flex align-items-center'>enable moderation
<Info>
<ol>
<li>Outlaw posts and comments with a click</li>
<li>Your territory will get a <Badge bg='secondary'>moderated</Badge> badge</li>
</ol>
</Info>
</div>
}
name='moderated'
groupClassName='ms-1'
/>
<CheckboxGroup <CheckboxGroup
label='billing' label='billing'
name='billing' name='billing'
@ -206,6 +192,46 @@ export default function TerritoryForm ({ sub }) {
name='billingAutoRenew' name='billingAutoRenew'
groupClassName='ms-1 mt-2' groupClassName='ms-1 mt-2'
/>} />}
<AccordianItem
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
body={
<>
<BootstrapForm.Label>moderation</BootstrapForm.Label>
<Checkbox
inline
label={
<div className='d-flex align-items-center'>enable moderation
<Info>
<ol>
<li>Outlaw posts and comments with a click</li>
<li>Your territory will get a <Badge bg='secondary'>moderated</Badge> badge</li>
</ol>
</Info>
</div>
}
name='moderated'
groupClassName='ms-1'
/>
<BootstrapForm.Label>nsfw</BootstrapForm.Label>
<Checkbox
inline
label={
<div className='d-flex align-items-center'>mark as nsfw
<Info>
<ol>
<li>Let stackers know that your territory may contain explicit content</li>
<li>Your territory will get a <Badge bg='secondary'>nsfw</Badge> badge</li>
</ol>
</Info>
</div>
}
name='nsfw'
groupClassName='ms-1'
/>
</>
}
/>
<div className='mt-3 d-flex justify-content-end'> <div className='mt-3 d-flex justify-content-end'>
<FeeButton <FeeButton
text={sub ? 'save' : 'found it'} text={sub ? 'save' : 'found it'}

View File

@ -19,6 +19,7 @@ export function TerritoryDetails ({ sub }) {
territory details territory details
{sub.status === 'STOPPED' && <Badge className='ms-2' bg='danger'>archived</Badge>} {sub.status === 'STOPPED' && <Badge className='ms-2' bg='danger'>archived</Badge>}
{(sub.moderated || sub.moderatedCount > 0) && <Badge className='ms-2' bg='secondary'>moderated{sub.moderatedCount > 0 && ` ${sub.moderatedCount}`}</Badge>} {(sub.moderated || sub.moderatedCount > 0) && <Badge className='ms-2' bg='secondary'>moderated{sub.moderatedCount > 0 && ` ${sub.moderatedCount}`}</Badge>}
{(sub.nsfw) && <Badge className='ms-2' bg='secondary'>nsfw</Badge>}
</small> </small>
} }
> >

View File

@ -22,6 +22,7 @@ export const ITEM_FIELDS = gql`
userId userId
moderated moderated
meMuteSub meMuteSub
nsfw
} }
otsHash otsHash
position position

View File

@ -19,6 +19,7 @@ export const SUB_FIELDS = gql`
moderated moderated
moderatedCount moderatedCount
meMuteSub meMuteSub
nsfw
}` }`
export const SUB_FULL_FIELDS = gql` export const SUB_FULL_FIELDS = gql`

View File

@ -82,6 +82,7 @@ export const SETTINGS_FIELDS = gql`
nostrRelays nostrRelays
wildWestMode wildWestMode
greeterMode greeterMode
nsfwMode
authMethods { authMethods {
lightning lightning
nostr nostr

View File

@ -304,7 +304,8 @@ export function territorySchema (args) {
.min(1, 'must be at least 1') .min(1, 'must be at least 1')
.max(100000, 'must be at most 100k'), .max(100000, 'must be at most 100k'),
postTypes: array().of(string().oneOf(POST_TYPES)).min(1, 'must support at least one post type'), postTypes: array().of(string().oneOf(POST_TYPES)).min(1, 'must support at least one post type'),
billingType: string().required('required').oneOf(TERRITORY_BILLING_TYPES, 'required') billingType: string().required('required').oneOf(TERRITORY_BILLING_TYPES, 'required'),
nsfw: boolean()
}) })
} }

View File

@ -82,6 +82,7 @@ export default function Settings ({ ssrData }) {
imgproxyOnly: settings?.imgproxyOnly, imgproxyOnly: settings?.imgproxyOnly,
wildWestMode: settings?.wildWestMode, wildWestMode: settings?.wildWestMode,
greeterMode: settings?.greeterMode, greeterMode: settings?.greeterMode,
nsfwMode: settings?.nsfwMode,
nostrPubkey: settings?.nostrPubkey ? bech32encode(settings.nostrPubkey) : '', nostrPubkey: settings?.nostrPubkey ? bech32encode(settings.nostrPubkey) : '',
nostrCrossposting: settings?.nostrCrossposting, nostrCrossposting: settings?.nostrCrossposting,
nostrRelays: settings?.nostrRelays?.length ? settings?.nostrRelays : [''], nostrRelays: settings?.nostrRelays?.length ? settings?.nostrRelays : [''],
@ -354,6 +355,19 @@ export default function Settings ({ ssrData }) {
</div> </div>
} }
name='greeterMode' name='greeterMode'
groupClassName='mb-0'
/>
<Checkbox
label={
<div className='d-flex align-items-center'>nsfw mode
<Info>
<ul className='fw-bold'>
<li>see posts from nsfw territories</li>
</ul>
</Info>
</div>
}
name='nsfwMode'
/> />
<h4>nostr</h4> <h4>nostr</h4>
<Checkbox <Checkbox

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Sub" ADD COLUMN "nsfw" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "nsfwMode" BOOLEAN NOT NULL DEFAULT false;

View File

@ -49,6 +49,7 @@ model User {
hideInvoiceDesc Boolean @default(false) hideInvoiceDesc Boolean @default(false)
wildWestMode Boolean @default(false) wildWestMode Boolean @default(false)
greeterMode Boolean @default(false) greeterMode Boolean @default(false)
nsfwMode Boolean @default(false)
fiatCurrency String @default("USD") fiatCurrency String @default("USD")
withdrawMaxFeeDefault Int @default(10) withdrawMaxFeeDefault Int @default(10)
autoDropBolt11s Boolean @default(false) autoDropBolt11s Boolean @default(false)
@ -423,6 +424,7 @@ model Sub {
billedLastAt DateTime @default(now()) billedLastAt DateTime @default(now())
moderated Boolean @default(false) moderated Boolean @default(false)
moderatedCount Int @default(0) moderatedCount Int @default(0)
nsfw Boolean @default(false)
parent Sub? @relation("ParentChildren", fields: [parentName], references: [name]) parent Sub? @relation("ParentChildren", fields: [parentName], references: [name])
children Sub[] @relation("ParentChildren") children Sub[] @relation("ParentChildren")