opt-in moderation for territory founders

This commit is contained in:
keyan 2023-12-23 14:26:16 -06:00
parent 7f512d6adb
commit dc15be914c
15 changed files with 185 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }) {
</DownZapper>
)
}
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 (
<Dropdown.Item onClick={async () => {
try {
await toggleOutlaw({ variables: { id: item.id } })
} catch {
toaster.danger('failed to outlaw')
return
}
toaster.success('item outlawed')
}}
>
outlaw
</Dropdown.Item>
)
}

View File

@ -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
<BootstrapForm.Control.Feedback type='invalid'>
{meta.touched && meta.error}
</BootstrapForm.Control.Feedback>
{hint &&
<BootstrapForm.Text>
{hint}
</BootstrapForm.Text>}
</FormGroup>
)
}

View File

@ -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 ({
<Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'>
opentimestamp
</Link>}
{me && !item.position &&
!item.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats
? <DropdownItemUpVote item={item} />
: <DontLikeThisDropdownItem id={item.id} />)}
{me && item?.noteId && (
<Dropdown.Item onClick={() => window.open(`https://nostr.com/${item.noteId}`, '_blank', 'noopener')}>
nostr note
</Dropdown.Item>
)}
{me && !item.position &&
!item.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats
? <DropdownItemUpVote item={item} />
: <DontLikeThisDropdownItem id={item.id} />)}
{item.mine && !item.position && !item.deletedAt && !item.bio &&
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />}
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
<>
<hr className='dropdown-divider' />
<OutlawDropdownItem item={item} />
</>}
{me && !item.mine &&
<>
<hr className='dropdown-divider' />

View File

@ -103,7 +103,8 @@ export function PostForm ({ type, sub, children }) {
<Alert className='position-absolute' style={{ top: '-6rem' }} variant='danger' onClose={() => setErrorMessage(undefined)} dismissible>
{errorMessage}
</Alert>}
<SubSelect prependSubs={['pick territory']} className='w-auto d-flex' noForm sub={sub?.name} />
<SubSelect prependSubs={['pick territory']} className='w-auto d-flex' noForm sub={sub?.name} hint={sub.moderated && 'this territory is moderated'} />
{postButtons}
<div className='d-flex mt-4'>
<AccordianItem
@ -168,7 +169,8 @@ export default function Post ({ sub }) {
territory
<SubInfo />
</span>
}
}
hint={sub.moderated && 'this territory is moderated'}
/>}
</PostForm>
</>

View File

@ -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'}
/>
<ItemButtonBar createText='reply' hasCancel={false} />
</Form>

View File

@ -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 }) {
</Col>
</Row>
</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
label='billing'
name='billing'

View File

@ -17,6 +17,11 @@ export const ITEM_FIELDS = gql`
}
meMute
}
sub {
name
userId
moderated
}
otsHash
position
sats
@ -69,6 +74,11 @@ export const ITEM_FULL_FIELDS = gql`
streak
}
}
sub {
name
userId
moderated
}
}
forwards {
userId

View File

@ -16,6 +16,8 @@ export const SUB_FIELDS = gql`
userId
desc
status
moderated
moderatedCount
}`
export const SUB_FULL_FIELDS = gql`

View File

@ -42,7 +42,13 @@ export default function Sub ({ ssrData }) {
<div className='mb-3 d-flex'>
<div className='flex-grow-1'>
<AccordianCard
header={<small className='text-muted fw-bold'>territory details{sub.status === 'STOPPED' && <Badge className='ms-2' bg='danger'>archived</Badge>}</small>}
header={
<small className='text-muted fw-bold align-items-center d-flex'>
territory details
{sub.status === 'STOPPED' && <Badge className='ms-2' bg='danger'>archived</Badge>}
{(sub.moderated || sub.moderatedCount) && <Badge className='ms-2' bg='secondary'>moderated{sub.moderatedCount && ` ${sub.moderatedCount}`}</Badge>}
</small>
}
>
<div className='py-2'>
<Text>{sub.desc}</Text>

View File

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

View File

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