stackernews bounties (#227)

bounties
This commit is contained in:
Austin Kelsay 2023-01-26 10:11:55 -06:00 committed by GitHub
parent 8b6829df50
commit e13e37744e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 641 additions and 30 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@
/node_modules
/.pnp
.pnp.js
.cache
# testing
/coverage

View File

@ -138,6 +138,8 @@ function recentClause (type) {
return ' AND "pollCost" IS NOT NULL'
case 'bios':
return ' AND bio = true'
case 'bounties':
return ' AND bounty IS NOT NULL'
default:
return ''
}
@ -399,6 +401,32 @@ export default {
items
}
},
getBountiesByUserName: async (parent, { name, cursor, limit }, { models }) => {
const decodedCursor = decodeCursor(cursor)
const user = await models.user.findUnique({ where: { name } })
if (!user) {
throw new UserInputError('user not found', {
argumentName: 'name'
})
}
const items = await models.$queryRaw(
`${SELECT}
FROM "Item"
WHERE "userId" = $1
AND "bounty" IS NOT NULL
ORDER BY created_at DESC
OFFSET $2
LIMIT $3`,
user.id, decodedCursor.offset, limit || LIMIT
)
return {
cursor: items.length === (limit || LIMIT) ? nextCursorEncoded(decodedCursor) : null,
items
}
},
moreFlatComments: async (parent, { cursor, name, sort, within }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
@ -589,6 +617,20 @@ export default {
return await createItem(parent, data, { me, models })
}
},
upsertBounty: async (parent, args, { me, models }) => {
const { id, ...data } = args
const { bounty } = data
if (bounty < 1000 || bounty > 1000000) {
throw new UserInputError('invalid bounty amount', { argumentName: 'bounty' })
}
if (id) {
return await updateItem(parent, { id, data }, { me, models })
} else {
return await createItem(parent, data, { me, models })
}
},
upsertPoll: async (parent, { id, forward, boost, title, text, options }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
@ -878,6 +920,50 @@ export default {
return (msats && msatsToSats(msats)) || 0
},
bountyPaid: async (item, args, { models }) => {
if (!item.bounty) {
return null
}
// if there's a child where the OP paid the amount, but it isn't the OP's own comment
const paid = await models.$queryRaw`
-- Sum up the sats and if they are greater than or equal to item.bounty than return true, else return false
SELECT "Item"."id"
FROM "ItemAct"
JOIN "Item" ON "ItemAct"."itemId" = "Item"."id"
WHERE "ItemAct"."userId" = ${item.userId}
AND "Item".path <@ text2ltree (${item.path})
AND "Item"."userId" <> ${item.userId}
AND act IN ('TIP', 'FEE')
GROUP BY "Item"."id"
HAVING coalesce(sum("ItemAct"."msats"), 0) >= ${item.bounty * 1000}
`
return paid.length > 0
},
bountyPaidTo: async (item, args, { models }) => {
if (!item.bounty) {
return []
}
const paidTo = await models.$queryRaw`
SELECT "Item"."id"
FROM "ItemAct"
JOIN "Item" ON "ItemAct"."itemId" = "Item"."id"
WHERE "ItemAct"."userId" = ${item.userId}
AND "Item".path <@ text2ltree (${item.path})
AND "Item"."userId" <> ${item.userId}
AND act IN ('TIP', 'FEE')
GROUP BY "Item"."id"
HAVING coalesce(sum("ItemAct"."msats"), 0) >= ${item.bounty * 1000}
`
if (paidTo.length === 0) {
return []
}
return paidTo.map(i => i.id)
},
meDontLike: async (item, args, { me, models }) => {
if (!me) return false
@ -967,7 +1053,7 @@ export const createMentions = async (item, models) => {
}
}
export const updateItem = async (parent, { id, data: { title, url, text, boost, forward, parentId } }, { me, models }) => {
export const updateItem = async (parent, { id, data: { title, url, text, boost, forward, bounty, parentId } }, { me, models }) => {
// update iff this item belongs to me
const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) {
@ -998,15 +1084,15 @@ export const updateItem = async (parent, { id, data: { title, url, text, boost,
const [item] = await serialize(models,
models.$queryRaw(
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6) AS "Item"`,
Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id)))
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
Number(id), title, url, text, Number(boost || 0), bounty ? Number(bounty) : null, Number(fwdUser?.id)))
await createMentions(item, models)
return item
}
const createItem = async (parent, { title, url, text, boost, forward, parentId }, { me, models }) => {
const createItem = async (parent, { title, url, text, boost, forward, bounty, parentId }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
@ -1027,10 +1113,17 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId }
}
}
const [item] = await serialize(models,
const [item] = await serialize(
models,
models.$queryRaw(
`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
title, url, text, Number(boost || 0), Number(parentId), Number(me.id),
`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
title,
url,
text,
Number(boost || 0),
bounty ? Number(bounty) : null,
Number(parentId),
Number(me.id),
Number(fwdUser?.id)))
await createMentions(item, models)
@ -1067,7 +1160,7 @@ function nestComments (flat, parentId) {
// we have to do our own query because ltree is unsupported
export const SELECT =
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
"Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
"Item".text, "Item".url, "Item"."bounty", "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
"Item".company, "Item".location, "Item".remote, "Item"."deletedAt",
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost",
"Item".msats, "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",

View File

@ -10,6 +10,7 @@ export default gql`
dupes(url: String!): [Item!]
related(cursor: String, title: String, id: ID, minMatch: String, limit: Int): Items
allItems(cursor: String): Items
getBountiesByUserName(name: String!, cursor: String, , limit: Int): Items
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String): Items
auctionPosition(sub: String, id: ID, bid: Int!): Int!
itemRepetition(parentId: ID): Int!
@ -34,6 +35,7 @@ export default gql`
deleteItem(id: ID): Item
upsertLink(id: ID, title: String!, url: String!, boost: Int, forward: String): Item!
upsertDiscussion(id: ID, title: String!, text: String, boost: Int, forward: String): Item!
upsertBounty(id: ID, title: String!, text: String, bounty: Int!, boost: Int, forward: String): Item!
upsertJob(id: ID, sub: ID!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item!
upsertPoll(id: ID, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item!
@ -87,6 +89,9 @@ export default gql`
depth: Int!
mine: Boolean!
boost: Int!
bounty: Int
bountyPaid: Boolean
bountyPaidTo: [Int]!
sats: Int!
commentSats: Int!
lastCommentAt: String

148
components/bounty-form.js Normal file
View File

@ -0,0 +1,148 @@
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 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 FeeButton, { EditFeeButton } from './fee-button'
import { InputGroup } from 'react-bootstrap'
export function BountyForm ({
item,
editThreshold,
titleLabel = 'title',
bountyLabel = 'bounty',
textLabel = 'text',
buttonText = 'post',
adv,
handleSubmit
}) {
const router = useRouter()
const client = useApolloClient()
const [upsertBounty] = useMutation(
gql`
mutation upsertBounty(
$id: ID
$title: String!
$bounty: Int!
$text: String
$boost: Int
$forward: String
) {
upsertBounty(
id: $id
title: $title
bounty: $bounty
text: $text
boost: $boost
forward: $forward
) {
id
}
}
`
)
const BountySchema = Yup.object({
title: Yup.string()
.required('required')
.trim()
.max(
MAX_TITLE_LENGTH,
({ max, value }) => `${Math.abs(max - value.length)} too many`
),
bounty: Yup.number()
.required('required')
.min(1000, 'must be at least 1000 sats')
.max(1000000, 'must be at most 1m sats')
.integer('must be whole'),
...AdvPostSchema(client)
})
return (
<Form
initial={{
title: item?.title || '',
text: item?.text || '',
bounty: item?.bounty || 1000,
suggest: '',
...AdvPostInitial({ forward: item?.fwdUser?.name })
}}
schema={BountySchema}
onSubmit={
handleSubmit ||
(async ({ boost, bounty, ...values }) => {
const { error } = await upsertBounty({
variables: {
id: item?.id,
boost: Number(boost),
bounty: Number(bounty),
...values
}
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
await router.push('/recent')
}
})
}
storageKeyPrefix={item ? undefined : 'discussion'}
>
<Input label={titleLabel} name='title' required autoFocus clear />
<Input
label={bountyLabel} name='bounty' required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<MarkdownInput
topLevel
label={
<>
{textLabel} <small className='text-muted ml-2'>optional</small>
</>
}
name='text'
as={TextareaAutosize}
minRows={6}
hint={
editThreshold
? (
<div className='text-muted font-weight-bold'>
<Countdown date={editThreshold} />
</div>
)
: null
}
/>
{adv && <AdvPostForm edit={!!item} />}
<div className='mt-3'>
{item
? (
<EditFeeButton
paidSats={item.meSats}
parentId={null}
text='save'
ChildButton={SubmitButton}
variant='secondary'
/>
)
: (
<FeeButton
baseFee={1}
parentId={null}
text={buttonText}
ChildButton={SubmitButton}
variant='secondary'
/>
)}
</div>
</Form>
)
}

View File

@ -13,6 +13,9 @@ import CommentEdit from './comment-edit'
import Countdown from './countdown'
import { COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants'
import { ignoreClick } from '../lib/clicks'
import PayBounty from './pay-bounty'
import BountyIcon from '../svgs/bounty-bag.svg'
import ActionTooltip from './action-tooltip'
import { useMe } from './me'
import DontLikeThis from './dont-link-this'
import Flag from '../svgs/flag-fill.svg'
@ -107,13 +110,16 @@ export default function Comment ({
const bottomedOut = depth === COMMENT_DEPTH_LIMIT
const op = item.root?.user.name === item.user.name
const bountyPaid = item.root?.bountyPaidTo?.includes(Number(item.id))
return (
<div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse ? styles.collapsed : ''}`}
>
<div className={`${itemStyles.item} ${styles.item}`}>
{item.meDontLike ? <Flag width={24} height={24} className={`${styles.dontLike}`} /> : <UpVote item={item} className={styles.upvote} />}
{item.meDontLike
? <Flag width={24} height={24} className={`${styles.dontLike}`} />
: <UpVote item={item} className={styles.upvote} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'>
<div className={`${itemStyles.other} ${styles.other}`}>
@ -136,6 +142,10 @@ export default function Comment ({
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link>
{includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${abbrNum(item.root.bounty)} sats paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>}
{me && !item.meSats && !item.meDontLike && !item.mine && !item.deletedAt && <DontLikeThis id={item.id} />}
{(item.outlawed && <Link href='/outlawed'><a>{' '}<Badge className={itemStyles.newComment} variant={null}>OUTLAWED</Badge></a></Link>) ||
(item.freebie && !item.mine && (me?.greeterMode) && <Link href='/freebie'><a>{' '}<Badge className={itemStyles.newComment} variant={null}>FREEBIE</Badge></a></Link>)}
@ -198,9 +208,9 @@ export default function Comment ({
: (
<div className={`${styles.children}`}>
{!noReply &&
<Reply
depth={depth + 1} item={item} replyOpen={replyOpen}
/>}
<Reply depth={depth + 1} item={item} replyOpen={replyOpen}>
{item.root?.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>}
{children}
<div className={`${styles.comments} ml-sm-1 ml-md-3`}>
{item.comments && !noComments

View File

@ -78,6 +78,12 @@
padding-bottom: .5rem;
}
.replyContainer {
display: flex;
justify-content: flex-start;
align-items: center;
}
.comment {
border-radius: .4rem;
padding-top: .5rem;
@ -85,6 +91,12 @@
background-color: var(--theme-commentBg);
}
.bountyIcon {
margin-left: 5px;
margin-right: 5px;
margin-top: -4px;
}
.hunk {
margin-bottom: 0;
margin-top: 0.15rem;

View File

@ -15,6 +15,8 @@ import { useEffect, useState } from 'react'
import Poll from './poll'
import { commentsViewed } from '../lib/new-comments'
import Related from './related'
import PastBounties from './past-bounties'
import Check from '../svgs/check-double-line.svg'
function BioItem ({ item, handleClick }) {
const me = useMe()
@ -97,10 +99,23 @@ function TopLevelItem ({ item, noReply, ...props }) {
{item.text && <ItemText item={item} />}
{item.url && <ItemEmbed item={item} />}
{item.poll && <Poll item={item} />}
{item.bounty &&
<div className='font-weight-bold mt-2 mb-3'>
{item.bountyPaid
? (
<div className='px-3 py-1 d-inline-block bg-grey-medium rounded text-success'>
<Check className='fill-success' /> {item.bounty} sats paid
</div>)
: (
<div className='px-3 py-1 d-inline-block bg-grey-darkmode rounded text-light'>
{item.bounty} sats bounty
</div>)}
</div>}
{!noReply &&
<>
<Reply item={item} replyOpen />
{!item.position && !item.isJob && !item.parentId && <Related title={item.title} itemId={item.id} />}
{!item.position && !item.isJob && !item.parentId && !item.bounty > 0 && <Related title={item.title} itemId={item.id} />}
{item.bounty > 0 && <PastBounties item={item} />}
</>}
</ItemComponent>
)

View File

@ -9,6 +9,8 @@ import Pin from '../svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace'
import Toc from './table-of-contents'
import PollIcon from '../svgs/bar-chart-horizontal-fill.svg'
import BountyIcon from '../svgs/bounty-bag.svg'
import ActionTooltip from './action-tooltip'
import { Badge } from 'react-bootstrap'
import { newComments } from '../lib/new-comments'
import { useMe } from './me'
@ -74,6 +76,12 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
<a ref={titleRef} className={`${styles.title} text-reset mr-2`}>
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
{item.pollCost && <span> <PollIcon className='fill-grey vertical-align-baseline' height={14} width={14} /></span>}
{item.bounty > 0 &&
<span>
<ActionTooltip notForm overlayText={`${abbrNum(item.bounty)} ${item.bountyPaid ? 'sats paid' : 'sats bounty'}`}>
<BountyIcon className={`${styles.bountyIcon} ${item.bountyPaid ? 'fill-success vertical-align-middle' : 'fill-grey vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>
</span>}
</a>
</Link>
{item.url &&

View File

@ -89,6 +89,12 @@ a.link:visited {
line-height: 1.06rem;
}
.bountyIcon {
margin-left: 5px;
margin-right: 5px;
margin-top: -2px;
}
/* .itemJob .hunk {
align-self: center;
}

View File

@ -0,0 +1,46 @@
import React from 'react'
import { useQuery } from '@apollo/client'
import AccordianItem from './accordian-item'
import Item, { ItemSkeleton } from './item'
import { BOUNTY_ITEMS_BY_USER_NAME } from '../fragments/items'
import Link from 'next/link'
import styles from './items.module.css'
export default function PastBounties ({ children, item }) {
const emptyItems = new Array(5).fill(null)
const { data, loading } = useQuery(BOUNTY_ITEMS_BY_USER_NAME, {
variables: {
name: item.user.name,
limit: 5
},
fetchPolicy: 'cache-first'
})
let items, cursor
if (data) {
({ getBountiesByUserName: { items, cursor } } = data)
items = items.filter(i => i.id !== item.id)
}
return (
<AccordianItem
header={<div className='font-weight-bold'>{item.user.name}'s bounties</div>}
body={
<>
<div className={styles.grid}>
{loading
? emptyItems.map((_, i) => <ItemSkeleton key={i} />)
: (items?.length
? items.map(bountyItem => {
return <Item key={bountyItem.id} item={bountyItem} />
})
: <div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>EMPTY</div>
)}
</div>
{cursor && <Link href={`/${item.user.name}/bounties`} query={{ parent: item }} passHref><a className='text-reset text-muted font-weight-bold'>view all past bounties</a></Link>}
</>
}
/>
)
}

114
components/pay-bounty.js Normal file
View File

@ -0,0 +1,114 @@
import React from 'react'
import { Button } from 'react-bootstrap'
import styles from './pay-bounty.module.css'
import ActionTooltip from './action-tooltip'
import ModalButton from './modal-button'
import { useMutation, gql } from '@apollo/client'
import { useMe } from './me'
import { abbrNum } from '../lib/format'
import { useShowModal } from './modal'
import FundError from './fund-error'
export default function PayBounty ({ children, item }) {
const me = useMe()
const showModal = useShowModal()
const [act] = useMutation(
gql`
mutation act($id: ID!, $sats: Int!) {
act(id: $id, sats: $sats) {
sats
}
}`, {
update (cache, { data: { act: { sats } } }) {
cache.modify({
id: `Item:${item.id}`,
fields: {
sats (existingSats = 0) {
return existingSats + sats
},
meSats (existingSats = 0) {
return existingSats + sats
}
}
})
// update all ancestor comment sats
item.path.split('.').forEach(id => {
if (Number(id) === Number(item.id)) return
cache.modify({
id: `Item:${id}`,
fields: {
commentSats (existingCommentSats = 0) {
return existingCommentSats + sats
}
}
})
})
// update root bounty status
cache.modify({
id: `Item:${item.root.id}`,
fields: {
bountyPaid () {
return true
},
bountyPaidTo (existingPaidTo = []) {
return [...existingPaidTo, Number(item.id)]
}
}
})
}
}
)
const handlePayBounty = async () => {
try {
await act({
variables: { id: item.id, sats: item.root.bounty },
optimisticResponse: {
act: {
id: `Item:${item.id}`,
sats: item.root.bounty
}
}
})
} catch (error) {
if (error.toString().includes('insufficient funds')) {
showModal(onClose => {
return <FundError onClose={onClose} />
})
return
}
throw new Error({ message: error.toString() })
}
}
if (!me || item.root.user.name !== me.name || item.mine || item.root.bountyPaid) {
return null
}
return (
<ActionTooltip
notForm
overlayText={`${item.root.bounty} sats`}
>
<ModalButton
clicker={
<div className={styles.pay}>
pay bounty
</div>
}
>
<div className='text-center font-weight-bold text-muted'>
Pay this bounty to {item.user.name}?
</div>
<div className='text-center'>
<Button className='mt-4' variant='primary' onClick={() => handlePayBounty()}>
pay <small>{abbrNum(item.root.bounty)} sats</small>
</Button>
</div>
</ModalButton>
</ActionTooltip>
)
}

View File

@ -0,0 +1,4 @@
.pay {
color: var(--success);
margin-left: 1rem;
}

View File

@ -16,7 +16,7 @@ export default function RecentHeader ({ type }) {
className='w-auto'
name='type'
size='sm'
items={['posts', 'comments', 'links', 'discussions', 'polls', 'bios']}
items={['posts', 'bounties', 'comments', 'links', 'discussions', 'polls', 'bios']}
onChange={(formik, e) => router.push(e.target.value === 'posts' ? '/recent' : `/recent/${e.target.value}`)}
/>
</div>

View File

@ -22,7 +22,7 @@ export function ReplyOnAnotherPage ({ parentId }) {
)
}
export default function Reply ({ item, onSuccess, replyOpen }) {
export default function Reply ({ item, onSuccess, replyOpen, children }) {
const [reply, setReply] = useState(replyOpen)
const me = useMe()
const parentId = item.id
@ -84,11 +84,14 @@ export default function Reply ({ item, onSuccess, replyOpen }) {
{replyOpen
? <div className={styles.replyButtons} />
: (
<div className={styles.replyButtons}>
<div
className={styles.replyButtons}
onClick={() => setReply(!reply)}
>
{reply ? 'cancel' : 'reply'}
</div>
{/* HACK if we need more items, we should probably do a comment toolbar */}
{children}
</div>)}
<div className={reply ? `${styles.reply}` : 'd-none'}>
<Form

View File

@ -8,7 +8,8 @@
font-size: 70%;
color: var(--theme-grey);
font-weight: bold;
display: inline-block;
display: flex;
align-items: center;
cursor: pointer;
padding-bottom: .5rem;
line-height: 1rem;

View File

@ -25,6 +25,8 @@ export const COMMENT_FIELDS = gql`
root {
id
title
bounty
bountyPaidTo
user {
name
id

View File

@ -20,6 +20,8 @@ export const ITEM_FIELDS = gql`
sats
upvotes
boost
bounty
bountyPaid
path
meSats
meDontLike
@ -211,6 +213,17 @@ export const ITEM_WITH_COMMENTS = gql`
}
}`
export const BOUNTY_ITEMS_BY_USER_NAME = gql`
${ITEM_FIELDS}
query getBountiesByUserName($name: String!) {
getBountiesByUserName(name: $name) {
cursor
items {
...ItemFields
}
}
}`
export const ITEM_SEARCH = gql`
${ITEM_FIELDS}
query Search($q: String, $cursor: String, $sort: String, $what: String, $when: String) {

23
pages/[name]/bounties.js Normal file
View File

@ -0,0 +1,23 @@
import { BOUNTY_ITEMS_BY_USER_NAME } from '../../fragments/items'
import { getGetServerSideProps } from '../../api/ssrApollo'
import Items from '../../components/items'
import Layout from '../../components/layout'
import { useRouter } from 'next/router'
export const getServerSideProps = getGetServerSideProps(BOUNTY_ITEMS_BY_USER_NAME)
export default function Bounties ({ data: { getBountiesByUserName: { items, cursor } } }) {
const router = useRouter()
return (
<Layout>
<div className='font-weight-bold my-2'>{router.query.name}'s bounties</div>
<Items
items={items} cursor={cursor}
variables={{ name: router.query.name }}
destructureData={data => data.getBountiesByUserName}
query={BOUNTY_ITEMS_BY_USER_NAME}
/>
</Layout>
)
}

View File

@ -5,6 +5,7 @@ import { LinkForm } from '../../../components/link-form'
import LayoutCenter from '../../../components/layout-center'
import JobForm from '../../../components/job-form'
import { PollForm } from '../../../components/poll-form'
import { BountyForm } from '../../../components/bounty-form'
export const getServerSideProps = getGetServerSideProps(ITEM, null,
data => !data.item)
@ -19,8 +20,10 @@ export default function PostEdit ({ data: { item } }) {
: (item.url
? <LinkForm item={item} editThreshold={editThreshold} adv />
: (item.pollCost
? <PollForm item={item} editThreshold={editThreshold} />
: <DiscussionForm item={item} editThreshold={editThreshold} adv />))}
? <PollForm item={item} editThreshold={editThreshold} adv />
: (item.bounty
? <BountyForm item={item} editThreshold={editThreshold} adv />
: <DiscussionForm item={item} editThreshold={editThreshold} adv />)))}
</LayoutCenter>
)
}

View File

@ -8,6 +8,7 @@ import { LinkForm } from '../components/link-form'
import { getGetServerSideProps } from '../api/ssrApollo'
import AccordianItem from '../components/accordian-item'
import { PollForm } from '../components/poll-form'
import { BountyForm } from '../components/bounty-form'
export const getServerSideProps = getGetServerSideProps()
@ -28,14 +29,20 @@ export function PostForm () {
<Link href='/post?type=discussion'>
<Button variant='secondary'>discussion</Button>
</Link>
<div className='d-flex justify-content-center mt-3'>
<div className='d-flex mt-3'>
<AccordianItem
headerColor='#6c757d'
header={<div className='font-weight-bold text-muted'>more</div>}
body={
<div className='align-items-center'>
<Link href='/post?type=poll'>
<Button variant='info'>poll</Button>
</Link>
<span className='mx-3 font-weight-bold text-muted'>or</span>
<Link href='/post?type=bounty'>
<Button variant='info'>bounty</Button>
</Link>
</div>
}
/>
</div>
@ -47,8 +54,10 @@ export function PostForm () {
return <DiscussionForm adv />
} else if (router.query.type === 'link') {
return <LinkForm />
} else {
} else if (router.query.type === 'poll') {
return <PollForm />
} else {
return <BountyForm adv />
}
}

View File

@ -0,0 +1,93 @@
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "bounty" INTEGER;
ALTER TABLE "Item" ADD CONSTRAINT "bounty" CHECK ("bounty" IS NULL OR "bounty" > 0) NOT VALID;
CREATE OR REPLACE FUNCTION create_item(
title TEXT, url TEXT, text TEXT, boost INTEGER, bounty INTEGER,
parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER,
spam_within INTERVAL)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats BIGINT;
cost_msats BIGINT;
free_posts INTEGER;
free_comments INTEGER;
freebie BOOLEAN;
item "Item";
med_votes FLOAT;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats, "freePosts", "freeComments"
INTO user_msats, free_posts, free_comments
FROM users WHERE id = user_id;
cost_msats := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within));
-- it's only a freebie if it's a 1 sat cost, they have < 1 sat, boost = 0, and they have freebies left
freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (boost = 0) AND ((parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 0));
IF NOT freebie AND cost_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
-- get this user's median item score
SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP(ORDER BY "weightedVotes" - "weightedDownVotes"), 0) INTO med_votes FROM "Item" WHERE "userId" = user_id;
-- if their median votes are positive, start at 0
-- if the median votes are negative, start their post with that many down votes
-- basically: if their median post is bad, presume this post is too
IF med_votes >= 0 THEN
med_votes := 0;
ELSE
med_votes := ABS(med_votes);
END IF;
INSERT INTO "Item" (title, url, text, bounty, "userId", "parentId", "fwdUserId", freebie, "weightedDownVotes", created_at, updated_at)
VALUES (title, url, text, bounty, user_id, parent_id, fwd_user_id, freebie, med_votes, 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_msats WHERE id = user_id;
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (cost_msats, item.id, user_id, 'FEE', now_utc(), now_utc());
END IF;
IF boost > 0 THEN
PERFORM item_act(item.id, user_id, 'BOOST', boost);
END IF;
RETURN item;
END;
$$;
CREATE OR REPLACE FUNCTION update_item(item_id INTEGER,
item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER,item_bounty INTEGER,
fwd_user_id INTEGER)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
UPDATE "Item" set title = item_title, url = item_url, text = item_text, bounty = item_bounty, "fwdUserId" = fwd_user_id
WHERE id = item_id
RETURNING * INTO item;
IF boost > 0 THEN
PERFORM item_act(item.id, item."userId", 'BOOST', boost);
END IF;
RETURN item;
END;
$$;

View File

@ -228,6 +228,7 @@ model Item {
pin Pin? @relation(fields: [pinId], references: [id])
pinId Int?
boost Int @default(0)
bounty Int?
uploadId Int?
upload Upload?
paidImgLink Boolean @default(false)

1
svgs/bounty-bag.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1033.333" viewBox="0 0 600 775" preserveAspectRatio="xMidYMid meet"><path d="M291.5 2.1c-12.4 1.3-32.4 5.4-50.3 10.4-18.3 5-20.9 6.1-24.5 10.5-5.1 6-4.7 10.7 2.1 30 4.8 13.4 17.1 58.4 16.2 59.4-.5.4-1.2.6-1.7.3-.4-.3-6.9-12.5-14.3-27.1-15.4-30.4-12.6-27.9-25.9-22.4-21.7 9.1-42.4 31-43.9 46.7-.8 9-2.4 6.9 57.6 77.8 22.8 27 26.8 31.3 29.1 31.3 1.5 0 11.1-.9 21.2-2 10.1-1.2 26.5-2.4 36.4-2.9 23.4-1 25.4-1.9 34.1-15.6 2.8-4.5 13.3-19.4 17.1-24.4.2-.3.7 0 1.2.5s-2.3 9.1-6.9 20.9l-7.9 20 3.2 1.4c1.8.7 6.7 1.6 10.9 1.8 8.9.7 15.6-1.1 22.4-5.8 4.9-3.3 64.9-71.9 71-81.2 6.4-9.6 8.7-16.7 8.8-26.7.1-9.5-2.2-15.8-8.3-23.5-6.4-7.9-24.1-20.1-26-17.9-.5.5-6.3 8.2-13.1 17.1-6.8 9-12.9 16.3-13.7 16.3-.7 0-1.3-.3-1.3-.6s5.7-14.4 12.6-31.2c13.5-32.7 15.2-38.6 11.8-42.3-2.8-3.2-9.5-6.2-23.4-10.7-29.9-9.6-64-13.3-94.5-10.1zm-3.3 233.4c-6.5 2.8-10.5 7.1-25.3 27-10.1 13.5-15.6 20.2-16.5 19.8-1.5-.5-1.8.1 13.7-28 6.7-12.1 6.7-12.3 4.9-14.5l-1.8-2.3-4.4 2.4c-25.1 14-39.1 23.7-61.3 42.6-62.8 53.4-112.6 114.8-152 187.5S-6.7 602.5 5.2 657.9c11.3 52.6 48.3 89.4 100.6 100.1 72.7 14.7 198.4 20.1 294.2 12.4 49.2-3.9 97.6-11 115.6-17 36.1-12 62.8-41.4 75.8-83.4 5.9-19.3 7.1-27 7.1-49.5 0-28.8-3.8-49.3-15.5-84.5-24-72.2-69.7-143.5-140.4-219.3-25.7-27.5-59.6-54.9-85.6-69-6.6-3.7-25-11-25.7-10.4-.2.2 2.3 12.7 5.6 27.8l5.6 27.7c-.7.7-9.4-14.4-17.1-30-8.9-17.7-13.2-23.6-19.5-26.9-4.5-2.3-12.9-2.5-17.7-.4zm-5.7 81.5l15.1 3.3c.1.1-1.9 11-4.6 24.2l-4.7 24.1c.4.4 36.1 7.4 36.3 7.1.1-.1 2.3-10.8 4.9-23.7l5.1-24.1c.2-.3 7.3.8 15.9 2.5 12.2 2.4 15.5 3.4 15.5 4.6 0 .9-2 11.7-4.5 24l-4.5 23.2c0 .4.8.8 1.8.8 3.1 0 24.8 7.8 32.3 11.6 23.6 11.8 40.2 29.9 45.4 49.4 3.2 11.9 1 28.9-5.5 42.8-5.3 11.4-19.4 23.6-36.7 32l-8 3.9 8 6.9c9.7 8.3 21.5 22.6 25.5 30.9 4.8 9.9 6.5 18.8 6 31.2-2 41.5-35 68.5-83.8 68.6-12 0-29-2.2-38.1-4.9-1.4-.4-2.4 3.3-6.4 23.8-2.6 13.4-5.1 24.7-5.6 25.2-.7.7-23.1-3-30-5-1.4-.4-1-3.3 3.3-24.7 2.6-13.3 4.6-24.7 4.4-25.2-.5-1.4-34.6-7.9-35.6-6.8-.4.4-2.9 11.7-5.5 25.1-4.6 23.1-4.9 24.3-6.9 23.8-1.2-.2-8.3-1.7-15.8-3.2-7.9-1.5-13.8-3.2-13.8-3.8s2.2-12 4.8-25.2l4.8-24.2-28.5-5.7-29.2-6.1c-.3-.2.7-7.1 2.4-15.4 3.4-17.1 2.3-16 13.7-13.5 8.3 1.8 14.2 1.9 18.4.1 5.1-2.1 10.3-7.3 12-11.9.8-2.3 9.2-42.8 18.6-90 12.7-63.8 16.9-86.7 16.4-89.5-.9-5.4-5-11.9-9.2-14.7-2.3-1.5-7.6-3.2-13.6-4.5-5.5-1.1-10.2-2.3-10.5-2.5-.9-.9 5.3-30.5 6.3-30.5.6 0 13.6 2.5 29 5.6 15.3 3 28.2 5.2 28.6 4.7.4-.4 2.6-10.5 4.9-22.3 4.7-24.2 4.9-25 6-25 .4 0 7.5 1.4 15.6 3zm11.7 128.2c-3.4 17.2-5.8 31.5-5.4 31.9.4.3 9.3 2.3 19.7 4.4 29.3 5.9 39.2 5.1 49.3-4 12.6-11.3 13.6-31.2 2.2-42.6-8.7-8.7-20.7-13.4-46.5-18.4l-13-2.5-6.3 31.2zm-15.7 79c-1.1 4.7-15.4 77.2-15.2 77.4.8.6 44.6 8.5 49.8 9 3.6.3 10.1.1 14.5-.6 14.4-2.1 23.6-8 29-18.7 2.7-5.5 2.9-6.5 2.9-18.3 0-12-.1-12.7-3-17.8-3.5-6.4-11.9-14.3-18.6-17.8-9.4-4.7-15.1-6.4-34.7-10.4l-21.9-4.5c-1.7-.4-2.4 0-2.8 1.7z"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB