opt-in moderation for territory founders
This commit is contained in:
parent
7f512d6adb
commit
dc15be914c
|
@ -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`
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
`
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -16,6 +16,8 @@ export const SUB_FIELDS = gql`
|
|||
userId
|
||||
desc
|
||||
status
|
||||
moderated
|
||||
moderatedCount
|
||||
}`
|
||||
|
||||
export const SUB_FULL_FIELDS = gql`
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue