parent
8b6829df50
commit
e13e37744e
|
@ -4,6 +4,7 @@
|
|||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.cache
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
.pay {
|
||||
color: var(--success);
|
||||
margin-left: 1rem;
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -25,6 +25,8 @@ export const COMMENT_FIELDS = gql`
|
|||
root {
|
||||
id
|
||||
title
|
||||
bounty
|
||||
bountyPaidTo
|
||||
user {
|
||||
name
|
||||
id
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 />
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
$$;
|
|
@ -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)
|
||||
|
|
|
@ -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 |
Loading…
Reference in New Issue