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 /node_modules
/.pnp /.pnp
.pnp.js .pnp.js
.cache
# testing # testing
/coverage /coverage

View File

@ -138,6 +138,8 @@ function recentClause (type) {
return ' AND "pollCost" IS NOT NULL' return ' AND "pollCost" IS NOT NULL'
case 'bios': case 'bios':
return ' AND bio = true' return ' AND bio = true'
case 'bounties':
return ' AND bounty IS NOT NULL'
default: default:
return '' return ''
} }
@ -399,6 +401,32 @@ export default {
items 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 }) => { moreFlatComments: async (parent, { cursor, name, sort, within }, { me, models }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
@ -589,6 +617,20 @@ export default {
return await createItem(parent, data, { me, models }) 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 }) => { upsertPoll: async (parent, { id, forward, boost, title, text, options }, { me, models }) => {
if (!me) { if (!me) {
throw new AuthenticationError('you must be logged in') throw new AuthenticationError('you must be logged in')
@ -878,6 +920,50 @@ export default {
return (msats && msatsToSats(msats)) || 0 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 }) => { meDontLike: async (item, args, { me, models }) => {
if (!me) return false 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 // update iff this item belongs to me
const old = await models.item.findUnique({ where: { id: Number(id) } }) const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.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, const [item] = await serialize(models,
models.$queryRaw( models.$queryRaw(
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6) AS "Item"`, `${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id))) Number(id), title, url, text, Number(boost || 0), bounty ? Number(bounty) : null, Number(fwdUser?.id)))
await createMentions(item, models) await createMentions(item, models)
return item 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) { if (!me) {
throw new AuthenticationError('you must be logged in') throw new AuthenticationError('you must be logged in')
} }
@ -1027,11 +1113,18 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId }
} }
} }
const [item] = await serialize(models, const [item] = await serialize(
models,
models.$queryRaw( models.$queryRaw(
`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, '${ITEM_SPAM_INTERVAL}') AS "Item"`, `${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), title,
Number(fwdUser?.id))) url,
text,
Number(boost || 0),
bounty ? Number(bounty) : null,
Number(parentId),
Number(me.id),
Number(fwdUser?.id)))
await createMentions(item, models) await createMentions(item, models)
@ -1067,7 +1160,7 @@ function nestComments (flat, parentId) {
// we have to do our own query because ltree is unsupported // we have to do our own query because ltree is unsupported
export const SELECT = export const SELECT =
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title, `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".company, "Item".location, "Item".remote, "Item"."deletedAt",
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost",
"Item".msats, "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes", "Item".msats, "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",

View File

@ -10,6 +10,7 @@ export default gql`
dupes(url: String!): [Item!] dupes(url: String!): [Item!]
related(cursor: String, title: String, id: ID, minMatch: String, limit: Int): Items related(cursor: String, title: String, id: ID, minMatch: String, limit: Int): Items
allItems(cursor: String): 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 search(q: String, sub: String, cursor: String, what: String, sort: String, when: String): Items
auctionPosition(sub: String, id: ID, bid: Int!): Int! auctionPosition(sub: String, id: ID, bid: Int!): Int!
itemRepetition(parentId: ID): Int! itemRepetition(parentId: ID): Int!
@ -34,6 +35,7 @@ export default gql`
deleteItem(id: ID): Item deleteItem(id: ID): Item
upsertLink(id: ID, title: String!, url: String!, boost: Int, forward: String): 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! 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, upsertJob(id: ID, sub: ID!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! 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! upsertPoll(id: ID, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item!
@ -87,6 +89,9 @@ export default gql`
depth: Int! depth: Int!
mine: Boolean! mine: Boolean!
boost: Int! boost: Int!
bounty: Int
bountyPaid: Boolean
bountyPaidTo: [Int]!
sats: Int! sats: Int!
commentSats: Int! commentSats: Int!
lastCommentAt: String 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 Countdown from './countdown'
import { COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants' import { COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants'
import { ignoreClick } from '../lib/clicks' 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 { useMe } from './me'
import DontLikeThis from './dont-link-this' import DontLikeThis from './dont-link-this'
import Flag from '../svgs/flag-fill.svg' import Flag from '../svgs/flag-fill.svg'
@ -107,13 +110,16 @@ export default function Comment ({
const bottomedOut = depth === COMMENT_DEPTH_LIMIT const bottomedOut = depth === COMMENT_DEPTH_LIMIT
const op = item.root?.user.name === item.user.name const op = item.root?.user.name === item.user.name
const bountyPaid = item.root?.bountyPaidTo?.includes(Number(item.id))
return ( return (
<div <div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse ? styles.collapsed : ''}`} ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse ? styles.collapsed : ''}`}
> >
<div className={`${itemStyles.item} ${styles.item}`}> <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={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
<div className={`${itemStyles.other} ${styles.other}`}> <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> <a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link> </Link>
{includeParent && <Parent item={item} rootText={rootText} />} {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} />} {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.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>)} (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}`}> <div className={`${styles.children}`}>
{!noReply && {!noReply &&
<Reply <Reply depth={depth + 1} item={item} replyOpen={replyOpen}>
depth={depth + 1} item={item} replyOpen={replyOpen} {item.root?.bounty && !bountyPaid && <PayBounty item={item} />}
/>} </Reply>}
{children} {children}
<div className={`${styles.comments} ml-sm-1 ml-md-3`}> <div className={`${styles.comments} ml-sm-1 ml-md-3`}>
{item.comments && !noComments {item.comments && !noComments

View File

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

View File

@ -15,6 +15,8 @@ import { useEffect, useState } from 'react'
import Poll from './poll' import Poll from './poll'
import { commentsViewed } from '../lib/new-comments' import { commentsViewed } from '../lib/new-comments'
import Related from './related' import Related from './related'
import PastBounties from './past-bounties'
import Check from '../svgs/check-double-line.svg'
function BioItem ({ item, handleClick }) { function BioItem ({ item, handleClick }) {
const me = useMe() const me = useMe()
@ -97,10 +99,23 @@ function TopLevelItem ({ item, noReply, ...props }) {
{item.text && <ItemText item={item} />} {item.text && <ItemText item={item} />}
{item.url && <ItemEmbed item={item} />} {item.url && <ItemEmbed item={item} />}
{item.poll && <Poll 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 && {!noReply &&
<> <>
<Reply item={item} replyOpen /> <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> </ItemComponent>
) )

View File

@ -9,6 +9,8 @@ import Pin from '../svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace' import reactStringReplace from 'react-string-replace'
import Toc from './table-of-contents' import Toc from './table-of-contents'
import PollIcon from '../svgs/bar-chart-horizontal-fill.svg' 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 { Badge } from 'react-bootstrap'
import { newComments } from '../lib/new-comments' import { newComments } from '../lib/new-comments'
import { useMe } from './me' 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`}> <a ref={titleRef} className={`${styles.title} text-reset mr-2`}>
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title} {item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
{item.pollCost && <span> <PollIcon className='fill-grey vertical-align-baseline' height={14} width={14} /></span>} {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> </a>
</Link> </Link>
{item.url && {item.url &&

View File

@ -89,6 +89,12 @@ a.link:visited {
line-height: 1.06rem; line-height: 1.06rem;
} }
.bountyIcon {
margin-left: 5px;
margin-right: 5px;
margin-top: -2px;
}
/* .itemJob .hunk { /* .itemJob .hunk {
align-self: center; 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' className='w-auto'
name='type' name='type'
size='sm' 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}`)} onChange={(formik, e) => router.push(e.target.value === 'posts' ? '/recent' : `/recent/${e.target.value}`)}
/> />
</div> </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 [reply, setReply] = useState(replyOpen)
const me = useMe() const me = useMe()
const parentId = item.id const parentId = item.id
@ -84,11 +84,14 @@ export default function Reply ({ item, onSuccess, replyOpen }) {
{replyOpen {replyOpen
? <div className={styles.replyButtons} /> ? <div className={styles.replyButtons} />
: ( : (
<div <div className={styles.replyButtons}>
className={styles.replyButtons} <div
onClick={() => setReply(!reply)} onClick={() => setReply(!reply)}
> >
{reply ? 'cancel' : 'reply'} {reply ? 'cancel' : 'reply'}
</div>
{/* HACK if we need more items, we should probably do a comment toolbar */}
{children}
</div>)} </div>)}
<div className={reply ? `${styles.reply}` : 'd-none'}> <div className={reply ? `${styles.reply}` : 'd-none'}>
<Form <Form

View File

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

View File

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

View File

@ -20,6 +20,8 @@ export const ITEM_FIELDS = gql`
sats sats
upvotes upvotes
boost boost
bounty
bountyPaid
path path
meSats meSats
meDontLike 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` export const ITEM_SEARCH = gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
query Search($q: String, $cursor: String, $sort: String, $what: String, $when: String) { 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 LayoutCenter from '../../../components/layout-center'
import JobForm from '../../../components/job-form' import JobForm from '../../../components/job-form'
import { PollForm } from '../../../components/poll-form' import { PollForm } from '../../../components/poll-form'
import { BountyForm } from '../../../components/bounty-form'
export const getServerSideProps = getGetServerSideProps(ITEM, null, export const getServerSideProps = getGetServerSideProps(ITEM, null,
data => !data.item) data => !data.item)
@ -19,8 +20,10 @@ export default function PostEdit ({ data: { item } }) {
: (item.url : (item.url
? <LinkForm item={item} editThreshold={editThreshold} adv /> ? <LinkForm item={item} editThreshold={editThreshold} adv />
: (item.pollCost : (item.pollCost
? <PollForm item={item} editThreshold={editThreshold} /> ? <PollForm item={item} editThreshold={editThreshold} adv />
: <DiscussionForm item={item} editThreshold={editThreshold} adv />))} : (item.bounty
? <BountyForm item={item} editThreshold={editThreshold} adv />
: <DiscussionForm item={item} editThreshold={editThreshold} adv />)))}
</LayoutCenter> </LayoutCenter>
) )
} }

View File

@ -8,6 +8,7 @@ import { LinkForm } from '../components/link-form'
import { getGetServerSideProps } from '../api/ssrApollo' import { getGetServerSideProps } from '../api/ssrApollo'
import AccordianItem from '../components/accordian-item' import AccordianItem from '../components/accordian-item'
import { PollForm } from '../components/poll-form' import { PollForm } from '../components/poll-form'
import { BountyForm } from '../components/bounty-form'
export const getServerSideProps = getGetServerSideProps() export const getServerSideProps = getGetServerSideProps()
@ -28,15 +29,21 @@ export function PostForm () {
<Link href='/post?type=discussion'> <Link href='/post?type=discussion'>
<Button variant='secondary'>discussion</Button> <Button variant='secondary'>discussion</Button>
</Link> </Link>
<div className='d-flex justify-content-center mt-3'> <div className='d-flex mt-3'>
<AccordianItem <AccordianItem
headerColor='#6c757d' headerColor='#6c757d'
header={<div className='font-weight-bold text-muted'>more</div>} header={<div className='font-weight-bold text-muted'>more</div>}
body={ body={
<Link href='/post?type=poll'> <div className='align-items-center'>
<Button variant='info'>poll</Button> <Link href='/post?type=poll'>
</Link> <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> </div>
</div> </div>
@ -47,8 +54,10 @@ export function PostForm () {
return <DiscussionForm adv /> return <DiscussionForm adv />
} else if (router.query.type === 'link') { } else if (router.query.type === 'link') {
return <LinkForm /> return <LinkForm />
} else { } else if (router.query.type === 'poll') {
return <PollForm /> 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]) pin Pin? @relation(fields: [pinId], references: [id])
pinId Int? pinId Int?
boost Int @default(0) boost Int @default(0)
bounty Int?
uploadId Int? uploadId Int?
upload Upload? upload Upload?
paidImgLink Boolean @default(false) 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