spam fees

This commit is contained in:
keyan 2022-08-10 10:06:31 -05:00
parent 7cf5396ef3
commit ddb4a30c4b
15 changed files with 270 additions and 40 deletions

View File

@ -4,7 +4,8 @@ import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import domino from 'domino'
import { BOOST_MIN } from '../../lib/constants'
import { BOOST_MIN, ITEM_SPAM_INTERVAL } from '../../lib/constants'
import { mdHas } from '../../lib/md'
async function comments (models, id, sort) {
let orderBy
@ -68,6 +69,13 @@ function topClause (within) {
export default {
Query: {
itemRepetition: async (parent, { parentId }, { me, models }) => {
if (!me) return 0
// how many of the parents starting at parentId belong to me
const [{ item_spam: count }] = await models.$queryRaw(`SELECT item_spam($1, $2, '${ITEM_SPAM_INTERVAL}')`, Number(parentId), Number(me.id))
return count
},
items: async (parent, { sub, sort, cursor, name, within }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
let items; let user; let pins; let subFull
@ -851,6 +859,10 @@ const updateItem = async (parent, { id, data }, { me, models }) => {
throw new UserInputError('item can no longer be editted')
}
if (data?.text && !old.paidImgLink && mdHas(data.text, ['link', 'image'])) {
throw new UserInputError('adding links or images on edit is not allowed yet')
}
const item = await models.item.update({
where: { id: Number(id) },
data
@ -878,21 +890,16 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId }
}
}
const hasImgLink = mdHas(text, ['link', 'image'])
const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6) AS "Item"`,
title, url, text, Number(boost || 0), Number(parentId), Number(me.id)))
models.$queryRaw(
`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
title, url, text, Number(boost || 0), Number(parentId), Number(me.id),
Number(fwdUser?.id), hasImgLink))
await createMentions(item, models)
if (fwdUser) {
await models.item.update({
where: { id: item.id },
data: {
fwdUserId: fwdUser.id
}
})
}
item.comments = []
return item
}

View File

@ -11,6 +11,7 @@ export default gql`
allItems(cursor: String): Items
search(q: String, sub: String, cursor: String): Items
auctionPosition(sub: String, id: ID, bid: Int!): Int!
itemRepetition(parentId: ID): Int!
}
type ItemActResult {

View File

@ -2,11 +2,12 @@ import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
import { useRouter } from 'next/router'
import * as Yup from 'yup'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import ActionTooltip from '../components/action-tooltip'
import TextareaAutosize from 'react-textarea-autosize'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useState } from 'react'
import FeeButton from './fee-button'
export function DiscussionForm ({
item, editThreshold, titleLabel = 'title',
@ -15,6 +16,8 @@ export function DiscussionForm ({
}) {
const router = useRouter()
const client = useApolloClient()
const [hasImgLink, setHasImgLink] = useState()
// const me = useMe()
const [upsertDiscussion] = useMutation(
gql`
mutation upsertDiscussion($id: ID, $title: String!, $text: String, $boost: Int, $forward: String) {
@ -31,6 +34,8 @@ export function DiscussionForm ({
...AdvPostSchema(client)
})
// const cost = linkOrImg ? 10 : me?.freePosts ? 0 : 1
return (
<Form
initial={{
@ -70,11 +75,17 @@ export function DiscussionForm ({
hint={editThreshold
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
: null}
setHasImgLink={setHasImgLink}
/>
{!item && adv && <AdvPostForm />}
<ActionTooltip>
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : buttonText}</SubmitButton>
</ActionTooltip>
<div className='mt-3'>
{item
? <SubmitButton variant='secondary'>save</SubmitButton>
: <FeeButton
baseFee={1} hasImgLink={hasImgLink} parentId={null} text={buttonText}
ChildButton={SubmitButton} variant='secondary'
/>}
</div>
</Form>
)
}

64
components/fee-button.js Normal file
View File

@ -0,0 +1,64 @@
import { Table } from 'react-bootstrap'
import ActionTooltip from './action-tooltip'
import Info from './info'
import styles from './fee-button.module.css'
import { gql, useQuery } from '@apollo/client'
import { useFormikContext } from 'formik'
function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
return (
<Table className={styles.receipt} borderless size='sm'>
<tbody>
<tr>
<td>{baseFee} sats</td>
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
</tr>
{hasImgLink &&
<tr>
<td>x 10</td>
<td align='right' className='font-weight-light'>image/link fee</td>
</tr>}
{repetition > 0 &&
<tr>
<td>x 10<sup>{repetition}</sup></td>
<td className='font-weight-light' align='right'>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</td>
</tr>}
{boost > 0 &&
<tr>
<td>+ {boost} sats</td>
<td className='font-weight-light' align='right'>boost</td>
</tr>}
</tbody>
<tfoot>
<tr>
<td className='font-weight-bold'>{cost} sats</td>
<td align='right' className='font-weight-light'>total fee</td>
</tr>
</tfoot>
</Table>
)
}
export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow }) {
const query = parentId
? gql`{ itemRepetition(parentId: "${parentId}") }`
: gql`{ itemRepetition }`
const { data } = useQuery(query, { pollInterval: 1000 })
const repetition = data?.itemRepetition || 0
const formik = useFormikContext()
const boost = formik?.values?.boost || 0
const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost)
const show = alwaysShow || !formik?.isSubmitting
return (
<div className='d-flex align-items-center'>
<ActionTooltip overlayText={`${cost} sats`}>
<ChildButton variant={variant}>{text}{cost > baseFee && show && <small> {cost} sats</small>}</ChildButton>
</ActionTooltip>
{cost > baseFee && show &&
<Info>
<Receipt baseFee={baseFee} hasImgLink={hasImgLink} repetition={repetition} cost={cost} parentId={parentId} boost={boost} />
</Info>}
</div>
)
}

View File

@ -0,0 +1,15 @@
.receipt {
background-color: var(--theme-inputBg);
max-width: 250px;
margin: auto;
table-layout: auto;
width: 100%;
}
.receipt td {
padding: .25rem .1rem;
}
.receipt tfoot {
border-top: 2px solid var(--theme-borderColor);
}

View File

@ -11,6 +11,7 @@ import Markdown from '../svgs/markdown-line.svg'
import styles from './form.module.css'
import Text from '../components/text'
import AddIcon from '../svgs/add-fill.svg'
import { mdHas } from '../lib/md'
export function SubmitButton ({
children, variant, value, onClick, ...props
@ -72,7 +73,7 @@ export function InputSkeleton ({ label, hint }) {
)
}
export function MarkdownInput ({ label, topLevel, groupClassName, ...props }) {
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, ...props }) {
const [tab, setTab] = useState('write')
const [, meta] = useField(props)
@ -99,7 +100,12 @@ export function MarkdownInput ({ label, topLevel, groupClassName, ...props }) {
</Nav>
<div className={tab !== 'write' ? 'd-none' : ''}>
<InputInner
{...props}
{...props} onChange={(formik, e) => {
if (onChange) onChange(formik, e)
if (setHasImgLink) {
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
}
}}
/>
</div>
<div className={tab !== 'preview' ? 'd-none' : 'form-group'}>

View File

@ -2,13 +2,13 @@ import { Form, Input, SubmitButton } from '../components/form'
import { useRouter } from 'next/router'
import * as Yup from 'yup'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import ActionTooltip from '../components/action-tooltip'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
import { ITEM_FIELDS } from '../fragments/items'
import Item from './item'
import AccordianItem from './accordian-item'
import { MAX_TITLE_LENGTH } from '../lib/constants'
import FeeButton from './fee-button'
// eslint-disable-next-line
const URL = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
@ -99,9 +99,14 @@ export function LinkForm ({ item, editThreshold }) {
}}
/>
{!item && <AdvPostForm />}
<ActionTooltip>
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
</ActionTooltip>
<div className='mt-3'>
{item
? <SubmitButton variant='secondary'>save</SubmitButton>
: <FeeButton
baseFee={1} parentId={null} text='post'
ChildButton={SubmitButton} variant='secondary'
/>}
</div>
{dupesData?.dupes?.length > 0 &&
<div className='mt-3'>
<AccordianItem

View File

@ -4,11 +4,10 @@ import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css'
import { COMMENTS } from '../fragments/comments'
import { useMe } from './me'
import ActionTooltip from './action-tooltip'
import TextareaAutosize from 'react-textarea-autosize'
import { useEffect, useState } from 'react'
import Info from './info'
import Link from 'next/link'
import FeeButton from './fee-button'
export const CommentSchema = Yup.object({
text: Yup.string().required('required').trim()
@ -25,6 +24,7 @@ export function ReplyOnAnotherPage ({ parentId }) {
export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) {
const [reply, setReply] = useState(replyOpen)
const me = useMe()
const [hasImgLink, setHasImgLink] = useState()
useEffect(() => {
setReply(replyOpen || !!localStorage.getItem('reply-' + parentId + '-' + 'text'))
@ -65,7 +65,7 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) {
}
)
const cost = me?.freeComments ? 0 : Math.pow(10, meComments)
// const cost = me?.freeComments ? 0 : Math.pow(10, meComments)
return (
<div>
@ -91,6 +91,7 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) {
}
resetForm({ text: '' })
setReply(replyOpen || false)
setHasImgLink(false)
}}
storageKeyPrefix={'reply-' + parentId}
>
@ -100,18 +101,16 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) {
minRows={6}
autoFocus={!replyOpen}
required
setHasImgLink={setHasImgLink}
hint={me?.freeComments ? <span className='text-success'>{me.freeComments} free comments left</span> : null}
/>
<div className='d-flex align-items-center mt-1'>
<ActionTooltip overlayText={`${cost} sats`}>
<SubmitButton variant='secondary'>reply{cost > 1 && <small> {cost} sats</small>}</SubmitButton>
</ActionTooltip>
{cost > 1 && (
<Info>
<div className='font-weight-bold'>Multiple replies on the same level get pricier, but we still love your thoughts!</div>
</Info>
)}
</div>
{reply &&
<div className='mt-1'>
<FeeButton
baseFee={1} hasImgLink={hasImgLink} parentId={parentId} text='reply'
ChildButton={SubmitButton} variant='secondary' alwaysShow
/>
</div>}
</Form>
</div>
</div>

View File

@ -82,6 +82,11 @@ export default function Text ({ topLevel, noFragments, nofollow, children }) {
)
},
a: ({ node, href, children, ...props }) => {
if (children?.some(e => e?.props?.node?.tagName === 'img')) {
return <>{children}</>
}
// map: fix any highlighted links
children = children?.map(e =>
typeof e === 'string'
? reactStringReplace(e, /:high\[([^\]]+)\]/g, (match, i) => {

View File

@ -12,3 +12,4 @@ export const UPLOAD_TYPES_ALLOW = [
export const COMMENT_DEPTH_LIMIT = 10
export const MAX_TITLE_LENGTH = 80
export const MAX_POLL_CHOICE_LENGTH = 30
export const ITEM_SPAM_INTERVAL = '10m'

19
lib/md.js Normal file
View File

@ -0,0 +1,19 @@
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { visit } from 'unist-util-visit'
import { gfm } from 'micromark-extension-gfm'
export function mdHas (md, test) {
const tree = fromMarkdown(md, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()]
})
let found = false
visit(tree, test, () => {
found = true
return false
})
return found
}

View File

@ -8,12 +8,12 @@ import { useState } from 'react'
import ItemFull from '../../components/item-full'
import * as Yup from 'yup'
import { Form, MarkdownInput, SubmitButton } from '../../components/form'
import ActionTooltip from '../../components/action-tooltip'
import TextareaAutosize from 'react-textarea-autosize'
import { useMe } from '../../components/me'
import { USER_FULL } from '../../fragments/users'
import { ITEM_FIELDS } from '../../fragments/items'
import { getGetServerSideProps } from '../../api/ssrApollo'
import FeeButton from '../../components/fee-button'
export const getServerSideProps = getGetServerSideProps(USER_FULL, null,
data => !data.user)
@ -23,6 +23,8 @@ const BioSchema = Yup.object({
})
export function BioForm ({ handleSuccess, bio }) {
const [hasImgLink, setHasImgLink] = useState()
const [upsertBio] = useMutation(
gql`
${ITEM_FIELDS}
@ -68,10 +70,16 @@ export function BioForm ({ handleSuccess, bio }) {
name='bio'
as={TextareaAutosize}
minRows={6}
setHasImgLink={setHasImgLink}
/>
<ActionTooltip>
<SubmitButton variant='secondary' className='mt-3'>{bio?.text ? 'save' : 'create'}</SubmitButton>
</ActionTooltip>
<div className='mt-3'>
{bio?.text
? <SubmitButton variant='secondary'>save</SubmitButton>
: <FeeButton
baseFee={1} hasImgLink={hasImgLink} parentId={null} text='create'
ChildButton={SubmitButton} variant='secondary'
/>}
</div>
</Form>
</div>
)

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "paidImgLink" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "freePosts" SET DEFAULT 0;

View File

@ -0,0 +1,83 @@
CREATE OR REPLACE FUNCTION item_spam(parent_id INTEGER, user_id INTEGER, within INTERVAL)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
repeats INTEGER;
self_replies INTEGER;
BEGIN
SELECT count(*) INTO repeats
FROM "Item"
WHERE (parent_id IS NULL AND "parentId" IS NULL OR "parentId" = parent_id)
AND "userId" = user_id
AND created_at > now_utc() - within;
IF parent_id IS NULL THEN
RETURN repeats;
END IF;
WITH RECURSIVE base AS (
SELECT "Item".id, "Item"."parentId", "Item"."userId"
FROM "Item"
WHERE id = parent_id AND "userId" = user_id AND created_at > now_utc() - within
UNION ALL
SELECT "Item".id, "Item"."parentId", "Item"."userId"
FROM base p
JOIN "Item" ON "Item".id = p."parentId" AND "Item"."userId" = p."userId" AND "Item".created_at > now_utc() - within)
SELECT count(*) INTO self_replies FROM base;
RETURN repeats + self_replies;
END;
$$;
CREATE OR REPLACE FUNCTION create_item(
title TEXT, url TEXT, text TEXT, boost INTEGER,
parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER,
has_img_link BOOLEAN, spam_within INTERVAL)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
cost INTEGER;
free_posts INTEGER;
free_comments INTEGER;
freebie BOOLEAN;
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats, "freePosts", "freeComments"
INTO user_msats, free_posts, free_comments
FROM users WHERE id = user_id;
freebie := (parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 0);
cost := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within)) * CASE WHEN has_img_link THEN 10 ELSE 1 END;
IF NOT freebie AND cost > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
INSERT INTO "Item" (title, url, text, "userId", "parentId", "fwdUserId", "paidImgLink", created_at, updated_at)
VALUES (title, url, text, user_id, parent_id, fwd_user_id, has_img_link, now_utc(), now_utc()) RETURNING * INTO item;
IF freebie THEN
IF parent_id IS NULL THEN
UPDATE users SET "freePosts" = "freePosts" - 1 WHERE id = user_id;
ELSE
UPDATE users SET "freeComments" = "freeComments" - 1 WHERE id = user_id;
END IF;
ELSE
UPDATE users SET msats = msats - cost WHERE id = user_id;
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
VALUES (cost / 1000, item.id, user_id, 'VOTE', now_utc(), now_utc());
END IF;
IF boost > 0 THEN
PERFORM item_act(item.id, user_id, 'BOOST', boost);
END IF;
RETURN item;
END;
$$;

View File

@ -33,7 +33,7 @@ model User {
msats Int @default(0)
stackedMsats Int @default(0)
freeComments Int @default(0)
freePosts Int @default(2)
freePosts Int @default(0)
checkedNotesAt DateTime?
tipDefault Int @default(10)
pubkey String? @unique
@ -166,6 +166,7 @@ model Item {
boost Int @default(0)
uploadId Int?
upload Upload?
paidImgLink Boolean @default(false)
// if sub is null, this is the main sub
sub Sub? @relation(fields: [subName], references: [name])