forward tips from posts

This commit is contained in:
keyan 2022-04-19 13:32:39 -05:00
parent 822fa9113a
commit d978ff5ea5
15 changed files with 152 additions and 37 deletions

View File

@ -611,6 +611,12 @@ export default {
}, },
user: async (item, args, { models }) => user: async (item, args, { models }) =>
await models.user.findUnique({ where: { id: item.userId } }), await models.user.findUnique({ where: { id: item.userId } }),
fwdUser: async (item, args, { models }) => {
if (!item.fwdUserId) {
return null
}
return await models.user.findUnique({ where: { id: item.fwdUserId } })
},
ncomments: async (item, args, { models }) => { ncomments: async (item, args, { models }) => {
const [{ count }] = await models.$queryRaw` const [{ count }] = await models.$queryRaw`
SELECT count(*) SELECT count(*)
@ -793,12 +799,29 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId }
throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
} }
let fwdUser
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
}
}
const [item] = await serialize(models, const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6) AS "Item"`, models.$queryRaw(`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6) AS "Item"`,
title, url, text, Number(boost || 0), Number(parentId), Number(me.id))) title, url, text, Number(boost || 0), Number(parentId), Number(me.id)))
await createMentions(item, models) await createMentions(item, models)
if (fwdUser) {
await models.item.update({
where: { id: item.id },
data: {
fwdUserId: fwdUser.id
}
})
}
item.comments = [] item.comments = []
return item return item
} }
@ -831,7 +854,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"."parentId", "Item"."pinId", "Item"."maxBid", "Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
"Item".company, "Item".location, "Item".remote, "Item".company, "Item".location, "Item".remote,
"Item"."subName", "Item".status, ltree2text("Item"."path") AS "path"` "Item"."subName", "Item".status, ltree2text("Item"."path") AS "path"`

View File

@ -97,7 +97,9 @@ export default {
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE "ItemAct"."userId" <> $1 AND "ItemAct".act <> 'BOOST' WHERE "ItemAct"."userId" <> $1 AND "ItemAct".act <> 'BOOST'
AND "Item"."userId" = $1 AND "ItemAct".created_at <= $2 AND (("Item"."userId" = $1 AND "Item"."fwdUserId" IS NULL)
OR ("Item"."fwdUserId" = $1 AND "ItemAct"."userId" <> "Item"."userId"))
AND "ItemAct".created_at <= $2
GROUP BY "Item".id)`) GROUP BY "Item".id)`)
queries.push( queries.push(
`(SELECT ('earn' || "Earn".id) as id, "Earn".id as "factId", NULL as bolt11, `(SELECT ('earn' || "Earn".id) as id, "Earn".id as "factId", NULL as bolt11,

View File

@ -52,6 +52,7 @@ export default gql`
root: Item root: Item
user: User! user: User!
userId: Int! userId: Int!
fwdUser: User
depth: Int! depth: Int!
mine: Boolean! mine: Boolean!
boost: Int! boost: Int!

View File

@ -3,11 +3,23 @@ import * as Yup from 'yup'
import { Input } from './form' import { Input } from './form'
import { InputGroup } from 'react-bootstrap' import { InputGroup } from 'react-bootstrap'
import { BOOST_MIN } from '../lib/constants' import { BOOST_MIN } from '../lib/constants'
import { NAME_QUERY } from '../fragments/users'
export const AdvPostSchema = { export function AdvPostSchema (client) {
boost: Yup.number().typeError('must be a number') return {
.min(BOOST_MIN, `must be at least ${BOOST_MIN}`).integer('must be whole'), boost: Yup.number().typeError('must be a number')
forward: Yup.string().trim() .min(BOOST_MIN, `must be at least ${BOOST_MIN}`).integer('must be whole'),
forward: Yup.string()
.test({
name: 'name',
test: async name => {
if (!name || !name.length) return true
const { data } = await client.query({ query: NAME_QUERY, variables: { name }, fetchPolicy: 'network-only' })
return !data.nameAvailable
},
message: 'user does not exist'
})
}
} }
export const AdvPostInitial = { export const AdvPostInitial = {
@ -30,8 +42,9 @@ export default function AdvPostForm () {
<Input <Input
label='forward sats to' label='forward sats to'
name='forward' name='forward'
hint={<span className='text-muted'>100% of sats earned will be sent to this user</span>} hint={<span className='text-muted'>100% of sats will be sent to this user</span>}
prepend=<InputGroup.Text>@</InputGroup.Text> prepend=<InputGroup.Text>@</InputGroup.Text>
showValid
/> />
</> </>
} }

View File

@ -1,7 +1,7 @@
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form' import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import * as Yup from 'yup' import * as Yup from 'yup'
import { gql, useMutation } from '@apollo/client' import { gql, useApolloClient, useMutation } from '@apollo/client'
import ActionTooltip from '../components/action-tooltip' import ActionTooltip from '../components/action-tooltip'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import Countdown from './countdown' import Countdown from './countdown'
@ -18,6 +18,7 @@ export function DiscussionForm ({
adv, handleSubmit adv, handleSubmit
}) { }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient()
const [upsertDiscussion] = useMutation( const [upsertDiscussion] = useMutation(
gql` gql`
mutation upsertDiscussion($id: ID, $title: String!, $text: String, $boost: Int, $forward: String) { mutation upsertDiscussion($id: ID, $title: String!, $text: String, $boost: Int, $forward: String) {
@ -27,6 +28,11 @@ export function DiscussionForm ({
}` }`
) )
const DiscussionSchema = Yup.object({
title: Yup.string().required('required').trim(),
...AdvPostSchema(client)
})
return ( return (
<Form <Form
initial={{ initial={{

View File

@ -173,7 +173,7 @@ function InputInner ({
} }
}} }}
isInvalid={meta.touched && meta.error} isInvalid={meta.touched && meta.error}
isValid={showValid && meta.touched && !meta.error} isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
/> />
{append && ( {append && (
<InputGroup.Append> <InputGroup.Append>

View File

@ -83,7 +83,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
const ItemComponent = item.maxBid ? ItemJob : Item const ItemComponent = item.maxBid ? ItemJob : Item
return ( return (
<ItemComponent item={item} {...props}> <ItemComponent item={item} showFwdUser {...props}>
{item.text && <ItemText item={item} />} {item.text && <ItemText item={item} />}
{item.url && <ItemEmbed item={item} />} {item.url && <ItemEmbed item={item} />}
{!noReply && <Reply parentId={item.id} meComments={item.meComments} replyOpen />} {!noReply && <Reply parentId={item.id} meComments={item.meComments} replyOpen />}

View File

@ -97,7 +97,18 @@ export function ItemJob ({ item, rank, children }) {
) )
} }
export default function Item ({ item, rank, children }) { function FwdUser ({ user }) {
return (
<div className={styles.other}>
100% of tips are forwarded to{' '}
<Link href={`/${user.name}`} passHref>
<a>@{user.name}</a>
</Link>
</div>
)
}
export default function Item ({ item, rank, showFwdUser, children }) {
const mine = item.mine const mine = item.mine
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
const [canEdit, setCanEdit] = const [canEdit, setCanEdit] =
@ -187,6 +198,7 @@ export default function Item ({ item, rank, children }) {
</Link> </Link>
</>} </>}
</div> </div>
{showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />}
</div> </div>
</div> </div>
{children && ( {children && (

View File

@ -1,7 +1,7 @@
import { Form, Input, SubmitButton } from '../components/form' import { Form, Input, SubmitButton } from '../components/form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import * as Yup from 'yup' import * as Yup from 'yup'
import { gql, useLazyQuery, useMutation } from '@apollo/client' import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import ActionTooltip from '../components/action-tooltip' import ActionTooltip from '../components/action-tooltip'
import Countdown from './countdown' import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
@ -11,14 +11,10 @@ import AccordianItem from './accordian-item'
// eslint-disable-next-line // eslint-disable-next-line
const URL = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i const URL = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
export const LinkSchema = Yup.object({
title: Yup.string().required('required').trim(),
url: Yup.string().matches(URL, 'invalid url').required('required'),
...AdvPostSchema
})
export function LinkForm ({ item, editThreshold }) { export function LinkForm ({ item, editThreshold }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient()
const [getPageTitle, { data }] = useLazyQuery(gql` const [getPageTitle, { data }] = useLazyQuery(gql`
query PageTitle($url: String!) { query PageTitle($url: String!) {
@ -45,6 +41,12 @@ export function LinkForm ({ item, editThreshold }) {
}` }`
) )
const LinkSchema = Yup.object({
title: Yup.string().required('required').trim(),
url: Yup.string().matches(URL, 'invalid url').required('required'),
...AdvPostSchema(client)
})
return ( return (
<Form <Form
initial={{ initial={{

View File

@ -79,7 +79,7 @@ function Notification ({ n }) {
<> <>
{n.__typename === 'Votification' && {n.__typename === 'Votification' &&
<small className='font-weight-bold text-success ml-2'> <small className='font-weight-bold text-success ml-2'>
your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
</small>} </small>}
{n.__typename === 'Mention' && {n.__typename === 'Mention' &&
<small className='font-weight-bold text-info ml-2'> <small className='font-weight-bold text-info ml-2'>

View File

@ -76,6 +76,8 @@ export default function UpVote ({ item, className }) {
}` }`
) )
const fwd2me = me && me?.id === item?.fwdUser?.id
const setVoteShow = (yes) => { const setVoteShow = (yes) => {
if (!me) return if (!me) return
@ -155,7 +157,7 @@ export default function UpVote ({ item, className }) {
if (!item) return if (!item) return
// we can't tip ourselves // we can't tip ourselves
if (item?.mine) { if (item?.mine || fwd2me) {
return return
} }
@ -169,7 +171,7 @@ export default function UpVote ({ item, className }) {
if (!item) return if (!item) return
// we can't tip ourselves // we can't tip ourselves
if (item?.mine) { if (item?.mine || fwd2me) {
return return
} }
@ -201,9 +203,9 @@ export default function UpVote ({ item, className }) {
: signIn : signIn
} }
> >
<ActionTooltip notForm disable={item?.mine} overlayText={overlayText()}> <ActionTooltip notForm disable={item?.mine || fwd2me} overlayText={overlayText()}>
<div <div
className={`${item?.mine ? styles.noSelfTips : ''} className={`${item?.mine || fwd2me ? styles.noSelfTips : ''}
${styles.upvoteWrapper}`} ${styles.upvoteWrapper}`}
> >
<UpBolt <UpBolt
@ -212,7 +214,7 @@ export default function UpVote ({ item, className }) {
className={ className={
`${styles.upvote} `${styles.upvote}
${className || ''} ${className || ''}
${item?.mine ? styles.noSelfTips : ''} ${item?.mine || fwd2me ? styles.noSelfTips : ''}
${item?.meSats ? styles.voted : ''}` ${item?.meSats ? styles.voted : ''}`
} }
style={item?.meSats style={item?.meSats

View File

@ -9,20 +9,7 @@ import * as Yup from 'yup'
import { gql, useApolloClient, useMutation } from '@apollo/client' import { gql, useApolloClient, useMutation } from '@apollo/client'
import styles from './user-header.module.css' import styles from './user-header.module.css'
import { useMe } from './me' import { useMe } from './me'
import { NAME_MUTATION, NAME_QUERY } from '../fragments/users'
const NAME_QUERY =
gql`
query nameAvailable($name: String!) {
nameAvailable(name: $name)
}
`
const NAME_MUTATION =
gql`
mutation setName($name: String!) {
setName(name: $name)
}
`
export default function UserHeader ({ user }) { export default function UserHeader ({ user }) {
const [editting, setEditting] = useState(false) const [editting, setEditting] = useState(false)

View File

@ -12,6 +12,10 @@ export const ITEM_FIELDS = gql`
name name
id id
} }
fwdUser {
name
id
}
sats sats
upvotes upvotes
boost boost

View File

@ -22,6 +22,20 @@ export const ME = gql`
} }
}` }`
export const NAME_QUERY =
gql`
query nameAvailable($name: String!) {
nameAvailable(name: $name)
}
`
export const NAME_MUTATION =
gql`
mutation setName($name: String!) {
setName(name: $name)
}
`
export const USER_FIELDS = gql` export const USER_FIELDS = gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
fragment UserFields on User { fragment UserFields on User {

View File

@ -0,0 +1,49 @@
CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
user_sats INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT (msats / 1000) INTO user_sats FROM users WHERE id = user_id;
IF act_sats > user_sats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
-- deduct sats from actor
UPDATE users SET msats = msats - (act_sats * 1000) WHERE id = user_id;
IF act = 'BOOST' THEN
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_sats, item_id, user_id, 'BOOST', now_utc(), now_utc());
ELSE
-- add sats to actee's balance and stacked count
UPDATE users
SET msats = msats + (act_sats * 1000), "stackedMsats" = "stackedMsats" + (act_sats * 1000)
WHERE id = (SELECT COALESCE("fwdUserId", "userId") FROM "Item" WHERE id = item_id);
-- if they have already voted, this is a tip
IF EXISTS (SELECT 1 FROM "ItemAct" WHERE "itemId" = item_id AND "userId" = user_id AND "ItemAct".act = 'VOTE') THEN
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc());
ELSE
-- else this is a vote with a possible extra tip
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
VALUES (1, item_id, user_id, 'VOTE', now_utc(), now_utc());
act_sats := act_sats - 1;
-- if we have sats left after vote, leave them as a tip
IF act_sats > 0 THEN
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_sats, item_id, user_id, 'TIP', now_utc(), now_utc());
END IF;
RETURN 1;
END IF;
END IF;
RETURN 0;
END;
$$;