diff --git a/api/resolvers/item.js b/api/resolvers/item.js index aa5cf04ac..3db842bc2 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -112,18 +112,20 @@ export function joinZapRankPersonalView (me, models) { async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ...args) { if (!me) { return await models.$queryRawUnsafe(` - SELECT "Item".*, to_json(users.*) as user + SELECT "Item".*, to_json(users.*) as user, to_jsonb("Sub".*) as sub FROM ( ${query} ) "Item" JOIN users ON "Item"."userId" = users.id + LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" ${orderBy}`, ...args) } else { return await models.$queryRawUnsafe(` SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user, COALESCE("ItemAct"."meMsats", 0) as "meMsats", COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark", - "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward" + "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward", + to_jsonb("Sub".*) as sub FROM ( ${query} ) "Item" @@ -132,6 +134,7 @@ async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ...args) LEFT JOIN "Bookmark" b ON b."itemId" = "Item".id AND b."userId" = ${me.id} LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."itemId" = "Item".id AND "ThreadSubscription"."userId" = ${me.id} LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "ItemForward"."userId" = ${me.id} + LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" LEFT JOIN LATERAL ( SELECT "itemId", sum("ItemAct".msats) FILTER (WHERE act = 'FEE' OR act = 'TIP') AS "meMsats", sum("ItemAct".msats) FILTER (WHERE act = 'DONT_LIKE_THIS') AS "meDontLikeMsats" @@ -224,7 +227,7 @@ export async function filterClause (me, models, type) { // handle outlawed // if the item is above the threshold or is mine - const outlawClauses = [`"Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`] + const outlawClauses = [`"Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD} AND NOT "Item".outlawed`] if (me) { outlawClauses.push(`"Item"."userId" = ${me.id}`) } @@ -250,7 +253,7 @@ function typeClause (type) { case 'freebies': return '"Item".freebie' case 'outlawed': - return `"Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD}` + return `"Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD} OR "Item".outlawed` case 'borderland': return '"Item"."weightedVotes" - "Item"."weightedDownVotes" < 0' case 'all': @@ -787,6 +790,57 @@ export default { act, path: item.path } + }, + toggleOutlaw: async (parent, { id }, { me, models }) => { + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + } + + const item = await models.item.findUnique({ + where: { id: Number(id) }, + include: { + sub: true, + root: { + include: { + sub: true + } + } + } + }) + + const sub = item.sub || item.root?.sub + + if (Number(sub.userId) !== Number(me.id)) { + throw new GraphQLError('you cant do this broh', { extensions: { code: 'FORBIDDEN' } }) + } + + if (item.outlawed) { + return item + } + + const [result] = await models.$transaction( + [ + models.item.update({ + where: { + id: Number(id) + }, + data: { + outlawed: true + } + }), + models.sub.update({ + where: { + name: sub.name + }, + data: { + moderatedCount: { + increment: 1 + } + } + }) + ]) + + return result } }, Item: { @@ -967,7 +1021,7 @@ export default { if (me && Number(item.userId) === Number(me.id)) { return false } - return item.weightedVotes - item.weightedDownVotes <= -ITEM_FILTER_THRESHOLD + return item.outlawed || item.weightedVotes - item.weightedDownVotes <= -ITEM_FILTER_THRESHOLD }, mine: async (item, args, { me, models }) => { return me?.id === item.userId @@ -979,7 +1033,10 @@ export default { if (item.root) { return item.root } - return await models.item.findUnique({ where: { id: item.rootId } }) + return await models.item.findUnique({ + where: { id: item.rootId }, + include: { sub: true } + }) }, parent: async (item, args, { models }) => { if (!item.parentId) { @@ -1207,7 +1264,7 @@ export const SELECT = "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats, "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes", "Item"."weightedDownVotes", "Item".freebie, "Item".bio, "Item"."otsHash", "Item"."bountyPaidTo", - ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls"` + ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls", "Item".outlawed` function topOrderByWeightedSats (me, models) { return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC` diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index 5d8923da5..fc04b2a58 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -124,7 +124,7 @@ export default { await ssValidate(territorySchema, data, { models, me }) if (old) { - return await updateSub(parent, data, { me, models, lnd, hash, hmac, old }) + return await updateSub(parent, data, { me, models, lnd, hash, hmac }) } else { return await createSub(parent, data, { me, models, lnd, hash, hmac }) } diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 2e783b374..2bff9a714 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -38,6 +38,7 @@ export default gql` upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item! act(id: ID!, sats: Int, act: String, idempotent: Boolean, hash: String, hmac: String): ItemActResult! pollVote(id: ID!, hash: String, hmac: String): ID! + toggleOutlaw(id: ID!): Item! } type PollOption { diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index d30a772a0..02b6b3280 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -11,7 +11,7 @@ export default gql` upsertSub(name: String!, desc: String, baseCost: Int!, postTypes: [String!]!, allowFreebies: Boolean!, billingType: String!, billingAutoRenew: Boolean!, - hash: String, hmac: String): Sub + moderated: Boolean!, hash: String, hmac: String): Sub paySub(name: String!, hash: String, hmac: String): Sub } @@ -31,5 +31,7 @@ export default gql` billedLastAt: Date! baseCost: Int! status: String! + moderated: Boolean! + moderatedCount: Int! } ` diff --git a/components/dont-link-this.js b/components/dont-link-this.js index 01ef14e60..a5a9f1045 100644 --- a/components/dont-link-this.js +++ b/components/dont-link-this.js @@ -6,6 +6,7 @@ import AccordianItem from './accordian-item' import Flag from '../svgs/flag-fill.svg' import { useMemo } from 'react' import getColor from '../lib/rainbow' +import { gql, useMutation } from '@apollo/client' export function DownZap ({ id, meDontLikeSats, ...props }) { const style = useMemo(() => (meDontLikeSats @@ -64,3 +65,41 @@ export default function DontLikeThisDropdownItem ({ id }) { ) } + +export function OutlawDropdownItem ({ item }) { + const toaster = useToast() + + const [toggleOutlaw] = useMutation( + gql` + mutation toggleOutlaw($id: ID!) { + toggleOutlaw(id: $id) { + outlawed + } + }`, { + update (cache, { data: { toggleOutlaw } }) { + cache.modify({ + id: `Item:${item.id}`, + fields: { + outlawed: () => true + } + }) + } + } + ) + + return ( + { + try { + await toggleOutlaw({ variables: { id: item.id } }) + } catch { + toaster.danger('failed to outlaw') + return + } + + toaster.success('item outlawed') + }} + > + outlaw + + ) +} diff --git a/components/form.js b/components/form.js index 81ad9cba9..081b036a9 100644 --- a/components/form.js +++ b/components/form.js @@ -825,7 +825,7 @@ export function Form ({ ) } -export function Select ({ label, items, groupClassName, onChange, noForm, overrideValue, ...props }) { +export function Select ({ label, items, groupClassName, onChange, noForm, overrideValue, hint, ...props }) { const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props) const formik = noForm ? null : useFormikContext() const invalid = meta.touched && meta.error @@ -866,6 +866,10 @@ export function Select ({ label, items, groupClassName, onChange, noForm, overri {meta.touched && meta.error} + {hint && + + {hint} + } ) } diff --git a/components/item-info.js b/components/item-info.js index e747b8d61..113098717 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -10,7 +10,7 @@ import { timeSince } from '../lib/time' import { DeleteDropdownItem } from './delete' import styles from './item.module.css' import { useMe } from './me' -import DontLikeThisDropdownItem from './dont-link-this' +import DontLikeThisDropdownItem, { OutlawDropdownItem } from './dont-link-this' import BookmarkDropdownItem from './bookmark' import SubscribeDropdownItem from './subscribe' import { CopyLinkDropdownItem } from './share' @@ -19,6 +19,7 @@ import { AD_USER_ID } from '../lib/constants' import ActionDropdown from './action-dropdown' import MuteDropdownItem from './mute' import { DropdownItemUpVote } from './upvote' +import { useRoot } from './root' export default function ItemInfo ({ item, full, commentsText = 'comments', @@ -32,6 +33,8 @@ export default function ItemInfo ({ useState(item.mine && (Date.now() < editThreshold)) const [hasNewComments, setHasNewComments] = useState(false) const [meTotalSats, setMeTotalSats] = useState(0) + const root = useRoot() + const sub = item?.sub || root?.sub useEffect(() => { if (!full) { @@ -146,18 +149,23 @@ export default function ItemInfo ({ opentimestamp } - {me && !item.position && - !item.mine && !item.deletedAt && - (item.meDontLikeSats > meTotalSats - ? - : )} {me && item?.noteId && ( window.open(`https://nostr.com/${item.noteId}`, '_blank', 'noopener')}> nostr note )} + {me && !item.position && + !item.mine && !item.deletedAt && + (item.meDontLikeSats > meTotalSats + ? + : )} {item.mine && !item.position && !item.deletedAt && !item.bio && } + {me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated && + <> +
+ + } {me && !item.mine && <>
diff --git a/components/post.js b/components/post.js index 5b020d5fb..960bde79e 100644 --- a/components/post.js +++ b/components/post.js @@ -103,7 +103,8 @@ export function PostForm ({ type, sub, children }) { setErrorMessage(undefined)} dismissible> {errorMessage} } - + + {postButtons}
-} + } + hint={sub.moderated && 'this territory is moderated'} />} diff --git a/components/reply.js b/components/reply.js index bbc98eb8d..fa07b900a 100644 --- a/components/reply.js +++ b/components/reply.js @@ -14,6 +14,7 @@ import { toastDeleteScheduled } from '../lib/form' import { ItemButtonBar } from './post' import { useShowModal } from './modal' import { Button } from 'react-bootstrap' +import { useRoot } from './root' export function ReplyOnAnotherPage ({ item }) { const path = item.path.split('.') @@ -38,6 +39,8 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children const replyInput = useRef(null) const toaster = useToast() const showModal = useShowModal() + const root = useRoot() + const sub = item?.sub || root?.sub useEffect(() => { if (replyOpen || quote || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) { @@ -174,6 +177,7 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children required appendValue={quote} placeholder={placeholder} + hint={sub?.moderated && 'this territory is moderated'} /> diff --git a/components/territory-form.js b/components/territory-form.js index 81eaeb8b5..487026b53 100644 --- a/components/territory-form.js +++ b/components/territory-form.js @@ -1,4 +1,4 @@ -import { Col, InputGroup, Row } from 'react-bootstrap' +import { Col, InputGroup, Row, Form as BootstrapForm, Badge } from 'react-bootstrap' import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form' import FeeButton, { FeeButtonProvider } from './fee-button' import { gql, useApolloClient, useMutation } from '@apollo/client' @@ -7,6 +7,7 @@ import { useRouter } from 'next/router' import { MAX_TERRITORY_DESC_LENGTH, POST_TYPES, TERRITORY_BILLING_OPTIONS } from '../lib/constants' import { territorySchema } from '../lib/validate' import { useMe } from './me' +import Info from './info' export default function TerritoryForm ({ sub }) { const router = useRouter() @@ -16,10 +17,10 @@ export default function TerritoryForm ({ sub }) { gql` mutation upsertSub($name: String!, $desc: String, $baseCost: Int!, $postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!, - $billingAutoRenew: Boolean!, $hash: String, $hmac: String) { + $billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String) { upsertSub(name: $name, desc: $desc, baseCost: $baseCost, postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType, - billingAutoRenew: $billingAutoRenew, hash: $hash, hmac: $hmac) { + billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac) { name } }` @@ -63,7 +64,8 @@ export default function TerritoryForm ({ sub }) { postTypes: sub?.postTypes || POST_TYPES, allowFreebies: typeof sub?.allowFreebies === 'undefined' ? true : sub?.allowFreebies, billingType: sub?.billingType || 'MONTHLY', - billingAutoRenew: sub?.billingAutoRenew || false + billingAutoRenew: sub?.billingAutoRenew || false, + moderated: sub?.moderated || false }} schema={territorySchema({ client, me })} invoiceable @@ -148,6 +150,22 @@ export default function TerritoryForm ({ sub }) { + moderation + enable moderation + +
    +
  1. Outlaw posts and comments with a click
  2. +
  3. Your territory will get a moderated badge
  4. +
+
+
+ } + name='moderated' + groupClassName='ms-1' + />
territory details{sub.status === 'STOPPED' && archived}} + header={ + + territory details + {sub.status === 'STOPPED' && archived} + {(sub.moderated || sub.moderatedCount) && moderated{sub.moderatedCount && ` ${sub.moderatedCount}`}} + + } >
{sub.desc} diff --git a/prisma/migrations/20231229204305_moderate/migration.sql b/prisma/migrations/20231229204305_moderate/migration.sql new file mode 100644 index 000000000..4f167cdae --- /dev/null +++ b/prisma/migrations/20231229204305_moderate/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "outlawed" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "Sub" ADD COLUMN "moderated" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "moderatedCount" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 31f89e53d..2b41ac822 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -320,6 +320,7 @@ model Item { itemForwards ItemForward[] ItemUpload ItemUpload[] uploadId Int? + outlawed Boolean @default(false) @@index([uploadId]) @@index([bio], map: "Item.bio_index") @@ -415,6 +416,8 @@ model Sub { billingCost Int billingAutoRenew Boolean @default(false) billedLastAt DateTime @default(now()) + moderated Boolean @default(false) + moderatedCount Int @default(0) parent Sub? @relation("ParentChildren", fields: [parentName], references: [name]) children Sub[] @relation("ParentChildren")