diff --git a/api/resolvers/item.js b/api/resolvers/item.js
index 6dbfa555..5540ced2 100644
--- a/api/resolvers/item.js
+++ b/api/resolvers/item.js
@@ -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
}
diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js
index 931435e3..9a743173 100644
--- a/api/typeDefs/item.js
+++ b/api/typeDefs/item.js
@@ -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 {
diff --git a/components/discussion-form.js b/components/discussion-form.js
index 82a62374..43c1caf7 100644
--- a/components/discussion-form.js
+++ b/components/discussion-form.js
@@ -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 (
)
}
diff --git a/components/fee-button.js b/components/fee-button.js
new file mode 100644
index 00000000..07426df8
--- /dev/null
+++ b/components/fee-button.js
@@ -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 (
+
+
+
+ {baseFee} sats |
+ {parentId ? 'reply' : 'post'} fee |
+
+ {hasImgLink &&
+
+ x 10 |
+ image/link fee |
+
}
+ {repetition > 0 &&
+
+ x 10{repetition} |
+ {repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m |
+
}
+ {boost > 0 &&
+
+ + {boost} sats |
+ boost |
+
}
+
+
+
+ {cost} sats |
+ total fee |
+
+
+
+ )
+}
+
+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 (
+
+
+ {text}{cost > baseFee && show && {cost} sats}
+
+ {cost > baseFee && show &&
+
+
+ }
+
+ )
+}
diff --git a/components/fee-button.module.css b/components/fee-button.module.css
new file mode 100644
index 00000000..87c27ed9
--- /dev/null
+++ b/components/fee-button.module.css
@@ -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);
+}
\ No newline at end of file
diff --git a/components/form.js b/components/form.js
index 7373bbc3..b2a5e0ea 100644
--- a/components/form.js
+++ b/components/form.js
@@ -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 }) {
{
+ if (onChange) onChange(formik, e)
+ if (setHasImgLink) {
+ setHasImgLink(mdHas(e.target.value, ['link', 'image']))
+ }
+ }}
/>
diff --git a/components/link-form.js b/components/link-form.js
index e6c2e296..867b27b2 100644
--- a/components/link-form.js
+++ b/components/link-form.js
@@ -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 &&
}
-
- {item ? 'save' : 'post'}
-
+
+ {item
+ ? save
+ : }
+
{dupesData?.dupes?.length > 0 &&
{
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 (
@@ -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 ?
{me.freeComments} free comments left : null}
/>
-
-
- reply{cost > 1 && {cost} sats}
-
- {cost > 1 && (
-
- Multiple replies on the same level get pricier, but we still love your thoughts!
-
- )}
-
+ {reply &&
+
+
+
}
diff --git a/components/text.js b/components/text.js
index ba3f9c80..ec9cd7fb 100644
--- a/components/text.js
+++ b/components/text.js
@@ -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) => {
diff --git a/lib/constants.js b/lib/constants.js
index ae6e033e..f6fb514b 100644
--- a/lib/constants.js
+++ b/lib/constants.js
@@ -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'
diff --git a/lib/md.js b/lib/md.js
new file mode 100644
index 00000000..8c37bc64
--- /dev/null
+++ b/lib/md.js
@@ -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
+}
diff --git a/pages/[name]/index.js b/pages/[name]/index.js
index 0a830a75..e88dafd2 100644
--- a/pages/[name]/index.js
+++ b/pages/[name]/index.js
@@ -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}
/>
-
- {bio?.text ? 'save' : 'create'}
-
+
+ {bio?.text
+ ? save
+ : }
+
)
diff --git a/prisma/migrations/20220810162813_item_spam/migration.sql b/prisma/migrations/20220810162813_item_spam/migration.sql
new file mode 100644
index 00000000..1ebc30b7
--- /dev/null
+++ b/prisma/migrations/20220810162813_item_spam/migration.sql
@@ -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;
\ No newline at end of file
diff --git a/prisma/migrations/20220810203210_item_spam2/migration.sql b/prisma/migrations/20220810203210_item_spam2/migration.sql
new file mode 100644
index 00000000..1b2652e6
--- /dev/null
+++ b/prisma/migrations/20220810203210_item_spam2/migration.sql
@@ -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;
+$$;
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c07c9b83..75e8c3a4 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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])