Add anon comments and posts (link, discussion, poll)

This commit is contained in:
ekzyis 2023-07-19 19:06:52 +02:00
parent 5415c6b0f6
commit 74893b09dd
12 changed files with 201 additions and 90 deletions

View File

@ -7,7 +7,8 @@ import domino from 'domino'
import {
BOOST_MIN, ITEM_SPAM_INTERVAL,
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD,
DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY
DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE
} from '../../lib/constants'
import { msatsToSats } from '../../lib/format'
import { parse } from 'tldts'
@ -571,7 +572,7 @@ export default {
if (id) {
return await updateItem(parent, { id, data }, { me, models })
} else {
return await createItem(parent, data, { me, models })
return await createItem(parent, data, { me, models, invoiceId: args.invoiceId })
}
},
upsertDiscussion: async (parent, args, { me, models }) => {
@ -582,7 +583,7 @@ export default {
if (id) {
return await updateItem(parent, { id, data }, { me, models })
} else {
return await createItem(parent, data, { me, models })
return await createItem(parent, data, { me, models, invoiceId: args.invoiceId })
}
},
upsertBounty: async (parent, args, { me, models }) => {
@ -597,8 +598,16 @@ export default {
}
},
upsertPoll: async (parent, { id, ...data }, { me, models }) => {
const { forward, sub, boost, title, text, options } = data
if (!me) {
const { sub, forward, boost, title, text, options, invoiceId } = data
let author = me
const trx = []
if (!me && invoiceId) {
const invoice = await checkInvoice(models, invoiceId, ANON_POST_FEE)
author = invoice.user
trx.push(models.invoice.delete({ where: { id: Number(invoiceId) } }))
}
if (!author) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
@ -622,7 +631,7 @@ export default {
if (id) {
const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) {
if (Number(old.userId) !== Number(author.id)) {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
}
const [item] = await serialize(models,
@ -633,9 +642,10 @@ export default {
item.comments = []
return item
} else {
const [item] = await serialize(models,
const [query] = await serialize(models,
models.$queryRawUnsafe(`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::INTEGER, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id)))
sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(author.id), options, Number(fwdUser?.id)), ...trx)
const item = trx.length > 0 ? query[0] : query
await createMentions(item, models)
item.comments = []
@ -679,13 +689,13 @@ export default {
},
createComment: async (parent, data, { me, models }) => {
await ssValidate(commentSchema, data)
const item = await createItem(parent, data, { me, models })
const item = await createItem(parent, data, { me, models, invoiceId: data.invoiceId })
// fetch user to get up-to-date name
const user = await models.user.findUnique({ where: { id: me.id } })
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } })
const parents = await models.$queryRawUnsafe(
'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2',
Number(item.parentId), Number(me.id))
Number(item.parentId), Number(user.id))
Promise.allSettled(
parents.map(({ userId }) => sendUserNotification(userId, {
title: `@${user.name} replied to you`,
@ -1065,8 +1075,16 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo
return item
}
const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models }) => {
if (!me) {
const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceId }) => {
let author = me
const trx = []
if (!me && invoiceId) {
const invoice = await checkInvoice(models, invoiceId, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE)
author = invoice.user
trx.push(models.invoice.delete({ where: { id: Number(invoiceId) } }))
}
if (!author) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
@ -1089,7 +1107,7 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
url = await proxyImages(url)
text = await proxyImages(text)
const [item] = await serialize(
const [query] = await serialize(
models,
models.$queryRawUnsafe(
`${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::INTEGER, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
@ -1100,8 +1118,10 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
Number(boost || 0),
bounty ? Number(bounty) : null,
Number(parentId),
Number(me.id),
Number(fwdUser?.id)))
Number(author.id),
Number(fwdUser?.id)),
...trx)
const item = trx.length > 0 ? query[0] : query
await createMentions(item, models)

View File

@ -26,13 +26,13 @@ export default gql`
bookmarkItem(id: ID): Item
subscribeItem(id: ID): Item
deleteItem(id: ID): Item
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String): Item!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String): Item!
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String, invoiceId: ID): Item!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceId: ID): Item!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: String): Item!
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item!
createComment(text: String!, parentId: ID!): Item!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String, invoiceId: ID): Item!
createComment(text: String!, parentId: ID!, invoiceId: ID): Item!
updateComment(id: ID!, text: String!): Item!
dontLikeThis(id: ID!): Boolean!
act(id: ID!, sats: Int, invoiceId: ID): ItemActResult!

View File

@ -12,6 +12,9 @@ import Button from 'react-bootstrap/Button'
import { discussionSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button'
import { useCallback } from 'react'
import { useAnonymous } from '../lib/anonymous'
import { ANON_POST_FEE } from '../lib/constants'
export function DiscussionForm ({
item, sub, editThreshold, titleLabel = 'title',
@ -27,13 +30,32 @@ export function DiscussionForm ({
// const me = useMe()
const [upsertDiscussion] = useMutation(
gql`
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String) {
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward) {
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String, $invoiceId: ID) {
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceId: $invoiceId) {
id
}
}`
)
const submitUpsertDiscussion = useCallback(
async (_, boost, values, invoiceId) => {
const { error } = await upsertDiscussion({
variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceId }
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
}, [upsertDiscussion, router])
const anonUpsertDiscussion = useAnonymous(submitUpsertDiscussion)
const [getRelated, { data: relatedData }] = useLazyQuery(gql`
${ITEM_FIELDS}
query related($title: String!) {
@ -58,19 +80,7 @@ export function DiscussionForm ({
}}
schema={schema}
onSubmit={handleSubmit || (async ({ boost, ...values }) => {
const { error } = await upsertDiscussion({
variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values }
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
await anonUpsertDiscussion(ANON_POST_FEE, boost, values)
})}
storageKeyPrefix={item ? undefined : 'discussion'}
>

View File

@ -4,6 +4,8 @@ import Info from './info'
import styles from './fee-button.module.css'
import { gql, useQuery } from '@apollo/client'
import { useFormikContext } from 'formik'
import { useMe } from './me'
import { ANON_COMMENT_FEE, ANON_POST_FEE } from '../lib/constants'
function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
return (
@ -40,11 +42,13 @@ function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
}
export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow, disabled }) {
const me = useMe()
baseFee = me ? baseFee : (parentId ? ANON_COMMENT_FEE : ANON_POST_FEE)
const query = parentId
? gql`{ itemRepetition(parentId: "${parentId}") }`
: gql`{ itemRepetition }`
const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
const repetition = data?.itemRepetition || 0
const repetition = me ? data?.itemRepetition || 0 : 0
const formik = useFormikContext()
const boost = Number(formik?.values?.boost) || 0
const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost)

View File

@ -212,9 +212,6 @@ function NavItems ({ className, sub, prefix }) {
}
function PostItem ({ className, prefix }) {
const me = useMe()
if (!me) return null
return (
<Link href={prefix + '/post'} className={`${className} btn btn-md btn-primary px-3 py-1 `}>
post

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { Form, Input, SubmitButton } from '../components/form'
import { useRouter } from 'next/router'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
@ -14,6 +14,8 @@ import { linkSchema } from '../lib/validate'
import Moon from '../svgs/moon-fill.svg'
import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button'
import { useAnonymous } from '../lib/anonymous'
import { ANON_POST_FEE } from '../lib/constants'
export function LinkForm ({ item, sub, editThreshold, children }) {
const router = useRouter()
@ -66,13 +68,31 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const [upsertLink] = useMutation(
gql`
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String) {
upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward) {
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String, $invoiceId: ID) {
upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceId: $invoiceId) {
id
}
}`
)
const submitUpsertLink = useCallback(
async (_, boost, title, values, invoiceId) => {
const { error } = await upsertLink({
variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceId, ...values }
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
}, [upsertLink, router])
const anonUpsertLink = useAnonymous(submitUpsertLink)
useEffect(() => {
if (data?.pageTitleAndUnshorted?.title) {
setTitleOverride(data.pageTitleAndUnshorted.title)
@ -100,18 +120,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
}}
schema={schema}
onSubmit={async ({ boost, title, ...values }) => {
const { error } = await upsertLink({
variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), ...values }
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
await anonUpsertLink(ANON_POST_FEE, boost, title, values)
}}
storageKeyPrefix={item ? undefined : 'link'}
>

View File

@ -3,13 +3,15 @@ import { useRouter } from 'next/router'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { MAX_POLL_NUM_CHOICES } from '../lib/constants'
import { ANON_POST_FEE, MAX_POLL_NUM_CHOICES } from '../lib/constants'
import FeeButton, { EditFeeButton } from './fee-button'
import Delete from './delete'
import Button from 'react-bootstrap/Button'
import { pollSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button'
import { useCallback } from 'react'
import { useAnonymous } from '../lib/anonymous'
export function PollForm ({ item, sub, editThreshold, children }) {
const router = useRouter()
@ -19,14 +21,41 @@ export function PollForm ({ item, sub, editThreshold, children }) {
const [upsertPoll] = useMutation(
gql`
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
$options: [String!]!, $boost: Int, $forward: String) {
$options: [String!]!, $boost: Int, $forward: String, $invoiceId: ID) {
upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
options: $options, boost: $boost, forward: $forward) {
options: $options, boost: $boost, forward: $forward, invoiceId: $invoiceId) {
id
}
}`
)
const submitUpsertPoll = useCallback(
async (_, boost, title, options, values, invoiceId) => {
const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
const { error } = await upsertPoll({
variables: {
id: item?.id,
sub: item?.subName || sub?.name,
boost: boost ? Number(boost) : undefined,
title: title.trim(),
options: optionsFiltered,
...values,
invoiceId
}
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
}, [upsertPoll, router])
const anonUpsertPoll = useAnonymous(submitUpsertPoll)
const initialOptions = item?.poll?.options.map(i => i.option)
return (
@ -40,26 +69,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
}}
schema={schema}
onSubmit={async ({ boost, title, options, ...values }) => {
const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
const { error } = await upsertPoll({
variables: {
id: item?.id,
sub: item?.subName || sub?.name,
boost: boost ? Number(boost) : undefined,
title: title.trim(),
options: optionsFiltered,
...values
}
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
await anonUpsertPoll(ANON_POST_FEE, boost, title, options, values)
}}
storageKeyPrefix={item ? undefined : 'poll'}
>

View File

@ -1,6 +1,7 @@
import JobForm from './job-form'
import Link from 'next/link'
import Button from 'react-bootstrap/Button'
import Alert from 'react-bootstrap/Alert'
import AccordianItem from './accordian-item'
import { useMe } from './me'
import { useRouter } from 'next/router'
@ -10,6 +11,7 @@ import { PollForm } from './poll-form'
import { BountyForm } from './bounty-form'
import SubSelect from './sub-select-form'
import Info from './info'
import { useCallback, useState } from 'react'
function FreebieDialog () {
return (
@ -28,12 +30,24 @@ function FreebieDialog () {
export function PostForm ({ type, sub, children }) {
const me = useMe()
const [errorMessage, setErrorMessage] = useState()
const prefix = sub?.name ? `/~${sub.name}` : ''
const checkSession = useCallback((e) => {
if (!me) {
e.preventDefault()
setErrorMessage('you must be logged in')
}
}, [me, setErrorMessage])
if (!type) {
return (
<div className='align-items-center'>
<div className='position-relative align-items-center'>
{errorMessage &&
<Alert className='position-absolute' style={{ top: '-6rem' }} variant='danger' onClose={() => setErrorMessage(undefined)} dismissible>
{errorMessage}
</Alert>}
{me?.sats < 1 && <FreebieDialog />}
<SubSelect noForm sub={sub?.name} />
<Link href={prefix + '/post?type=link'}>
@ -54,11 +68,11 @@ export function PostForm ({ type, sub, children }) {
</Link>
<span className='mx-3 fw-bold text-muted'>or</span>
<Link href={prefix + '/post?type=bounty'}>
<Button variant='info'>bounty</Button>
<Button onClick={checkSession} variant='info'>bounty</Button>
</Link>
<div className='mt-3 d-flex justify-content-center'>
<Link href='/~jobs/post'>
<Button variant='info'>job</Button>
<Button onClick={checkSession} variant='info'>job</Button>
</Link>
</div>
</div>

View File

@ -3,12 +3,14 @@ import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css'
import { COMMENTS } from '../fragments/comments'
import { useMe } from './me'
import { useEffect, useState, useRef } from 'react'
import { useEffect, useState, useRef, useCallback } from 'react'
import Link from 'next/link'
import FeeButton from './fee-button'
import { commentsViewedAfterComment } from '../lib/new-comments'
import { commentSchema } from '../lib/validate'
import Info from './info'
import { useAnonymous } from '../lib/anonymous'
import { ANON_COMMENT_FEE } from '../lib/constants'
export function ReplyOnAnotherPage ({ parentId }) {
return (
@ -45,8 +47,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
const [createComment] = useMutation(
gql`
${COMMENTS}
mutation createComment($text: String!, $parentId: ID!) {
createComment(text: $text, parentId: $parentId) {
mutation createComment($text: String!, $parentId: ID!, $invoiceId: ID) {
createComment(text: $text, parentId: $parentId, invoiceId: $invoiceId) {
...CommentFields
comments {
...CommentsRecursive
@ -90,6 +92,18 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
}
)
const submitComment = useCallback(
async (_, values, parentId, resetForm, invoiceId) => {
const { error } = await createComment({ variables: { ...values, parentId, invoiceId } })
if (error) {
throw new Error({ message: error.toString() })
}
resetForm({ text: '' })
setReply(replyOpen || false)
}, [createComment, setReply])
const anonCreateComment = useAnonymous(submitComment)
const replyInput = useRef(null)
useEffect(() => {
if (replyInput.current && reply && !replyOpen) replyInput.current.focus()
@ -117,12 +131,7 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
}}
schema={commentSchema}
onSubmit={async (values, { resetForm }) => {
const { error } = await createComment({ variables: { ...values, parentId } })
if (error) {
throw new Error({ message: error.toString() })
}
resetForm({ text: '' })
setReply(replyOpen || false)
await anonCreateComment(ANON_COMMENT_FEE, values, parentId, resetForm)
}}
storageKeyPrefix={'reply-' + parentId}
>

View File

@ -58,7 +58,7 @@ export const useAnonymous = (fn) => {
if (me) return fn(amount, ...args)
setFnArgs(args)
return createInvoice({ variables: { amount } })
})
}, [fn, setFnArgs, createInvoice])
return anonFn
}

View File

@ -46,3 +46,5 @@ export const ITEM_TYPES = context => {
export const OLD_ITEM_DAYS = 3
export const ANON_USER_ID = 27
export const ANON_POST_FEE = 1000
export const ANON_COMMENT_FEE = 100

View File

@ -0,0 +1,36 @@
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
IF user_id = 27 THEN
-- disable fee escalation for anon user
RETURN 0;
END IF;
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;
$$;