full powered editing

This commit is contained in:
keyan 2022-08-18 13:15:24 -05:00
parent 9b8b6078d6
commit 388c7d0240
21 changed files with 334 additions and 100 deletions

View File

@ -4,7 +4,7 @@ import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import domino from 'domino' import domino from 'domino'
import { BOOST_MIN, ITEM_SPAM_INTERVAL } from '../../lib/constants' import { BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES } from '../../lib/constants'
import { mdHas } from '../../lib/md' import { mdHas } from '../../lib/md'
async function comments (models, id, sort) { async function comments (models, id, sort) {
@ -450,8 +450,7 @@ export default {
data.url = ensureProtocol(data.url) data.url = ensureProtocol(data.url)
if (id) { if (id) {
const { forward, boost, ...remaining } = data return await updateItem(parent, { id, data }, { me, models })
return await updateItem(parent, { id, data: remaining }, { me, models })
} else { } else {
return await createItem(parent, data, { me, models }) return await createItem(parent, data, { me, models })
} }
@ -460,8 +459,7 @@ export default {
const { id, ...data } = args const { id, ...data } = args
if (id) { if (id) {
const { forward, boost, ...remaining } = data return await updateItem(parent, { id, data }, { me, models })
return await updateItem(parent, { id, data: remaining }, { me, models })
} else { } else {
return await createItem(parent, data, { me, models }) return await createItem(parent, data, { me, models })
} }
@ -475,37 +473,43 @@ export default {
throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' }) throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
} }
if (id) { let fwdUser
// TODO: this isn't ever called clientside, we edit like it's a discussion 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 models.item.update({ const hasImgLink = !!(text && mdHas(text, ['link', 'image']))
where: { id: Number(id) },
data: { title: title } if (id) {
const optionCount = await models.pollOption.count({
where: {
itemId: Number(id)
}
}) })
return item if (options.length + optionCount > MAX_POLL_NUM_CHOICES) {
} else { throw new UserInputError(`total choices must be <${MAX_POLL_NUM_CHOICES}`, { argumentName: 'options' })
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_poll($1, $2, $3, $4, $5, $6) AS "Item"`, models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
title, text, 1, Number(boost || 0), Number(me.id), options)) Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id), hasImgLink))
if (fwdUser) { return item
await models.item.update({ } else {
where: { id: item.id }, if (options.length < 2 || options.length > MAX_POLL_NUM_CHOICES) {
data: { throw new UserInputError(`choices must be >2 and <${MAX_POLL_NUM_CHOICES}`, { argumentName: 'options' })
fwdUserId: fwdUser.id
}
})
} }
const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id), hasImgLink))
await createMentions(item, models)
item.comments = [] item.comments = []
return item return item
} }
@ -847,26 +851,37 @@ export const createMentions = async (item, models) => {
} }
} }
const updateItem = async (parent, { id, data }, { me, models }) => { export const updateItem = async (parent, { id, data: { title, url, text, boost, forward, 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)) {
throw new AuthenticationError('item does not belong to you') throw new AuthenticationError('item does not belong to you')
} }
// if it's not the FAQ and older than 10 minutes // if it's not the FAQ, not their bio, and older than 10 minutes
if (old.id !== 349 && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) { const user = await models.user.findUnique({ where: { id: me.id } })
if (old.id !== 349 && user.bioId !== id && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) {
throw new UserInputError('item can no longer be editted') throw new UserInputError('item can no longer be editted')
} }
if (data?.text && !old.paidImgLink && mdHas(data.text, ['link', 'image'])) { if (boost && boost < BOOST_MIN) {
throw new UserInputError('adding links or images on edit is not allowed yet') throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
} }
const item = await models.item.update({ let fwdUser
where: { id: Number(id) }, if (forward) {
data fwdUser = await models.user.findUnique({ where: { name: forward } })
}) if (!fwdUser) {
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
}
}
const hasImgLink = !!(text && mdHas(text, ['link', 'image']))
const [item] = await serialize(models,
models.$queryRaw(
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id), hasImgLink))
await createMentions(item, models) await createMentions(item, models)
@ -934,7 +949,7 @@ 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"."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, "Item"."uploadId", "Item"."pollCost", ltree2text("Item"."path") AS "path"` "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item"."paidImgLink", ltree2text("Item"."path") AS "path"`
function newTimedOrderByWeightedSats (num) { function newTimedOrderByWeightedSats (num) {
return ` return `

View File

@ -1,6 +1,7 @@
import { AuthenticationError, UserInputError } from 'apollo-server-errors' import { AuthenticationError, UserInputError } from 'apollo-server-errors'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { createMentions, getItem, SELECT } from './item' import { mdHas } from '../../lib/md'
import { createMentions, getItem, SELECT, updateItem } from './item'
import serialize from './serial' import serialize from './serial'
export function topClause (within) { export function topClause (within) {
@ -188,21 +189,16 @@ export default {
const user = await models.user.findUnique({ where: { id: me.id } }) const user = await models.user.findUnique({ where: { id: me.id } })
let item
if (user.bioId) { if (user.bioId) {
item = await models.item.update({ await updateItem(parent, { id: user.bioId, data: { text: bio, title: `@${user.name}'s bio` } }, { me, models })
where: { id: Number(user.bioId) },
data: {
text: bio
}
})
} else { } else {
([item] = await serialize(models, const hasImgLink = !!(bio && mdHas(bio, ['link', 'image']))
models.$queryRaw(`${SELECT} FROM create_bio($1, $2, $3) AS "Item"`,
`@${user.name}'s bio`, bio, Number(me.id))))
}
await createMentions(item, models) const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM create_bio($1, $2, $3, $4) AS "Item"`,
`@${user.name}'s bio`, bio, Number(me.id), hasImgLink))
await createMentions(item, models)
}
return await models.user.findUnique({ where: { id: me.id } }) return await models.user.findUnique({ where: { id: me.id } })
}, },

View File

@ -76,6 +76,7 @@ export default gql`
sats: Int! sats: Int!
upvotes: Int! upvotes: Int!
meSats: Int! meSats: Int!
paidImgLink: Boolean
meComments: Int! meComments: Int!
ncomments: Int! ncomments: Int!
comments: [Item!]! comments: [Item!]!

View File

@ -9,7 +9,14 @@ import Info from './info'
export function AdvPostSchema (client) { export function AdvPostSchema (client) {
return { return {
boost: Yup.number().typeError('must be a number') boost: Yup.number().typeError('must be a number')
.min(BOOST_MIN, `must be blank or at least ${BOOST_MIN}`).integer('must be whole'), .min(BOOST_MIN, `must be blank or at least ${BOOST_MIN}`).integer('must be whole').test({
name: 'boost',
test: async boost => {
if (!boost || boost % BOOST_MIN === 0) return true
return false
},
message: `must be divisble be ${BOOST_MIN}`
}),
forward: Yup.string() forward: Yup.string()
.test({ .test({
name: 'name', name: 'name',
@ -23,12 +30,14 @@ export function AdvPostSchema (client) {
} }
} }
export const AdvPostInitial = { export function AdvPostInitial ({ forward }) {
boost: '', return {
forward: '' boost: '',
forward: forward || ''
}
} }
export default function AdvPostForm () { export default function AdvPostForm ({ edit }) {
return ( return (
<AccordianItem <AccordianItem
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>} header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
@ -36,7 +45,7 @@ export default function AdvPostForm () {
<> <>
<Input <Input
label={ label={
<div className='d-flex align-items-center'>boost <div className='d-flex align-items-center'>{edit ? 'add boost' : 'boost'}
<Info> <Info>
<ol className='font-weight-bold'> <ol className='font-weight-bold'>
<li>Boost ranks posts higher temporarily based on the amount</li> <li>Boost ranks posts higher temporarily based on the amount</li>

View File

@ -3,17 +3,22 @@ import * as Yup from 'yup'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css' import styles from './reply.module.css'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { useState } from 'react'
import { EditFeeButton } from './fee-button'
export const CommentSchema = Yup.object({ export const CommentSchema = Yup.object({
text: Yup.string().required('required').trim() text: Yup.string().required('required').trim()
}) })
export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) { export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) {
const [hasImgLink, setHasImgLink] = useState()
const [updateComment] = useMutation( const [updateComment] = useMutation(
gql` gql`
mutation updateComment($id: ID! $text: String!) { mutation updateComment($id: ID! $text: String!) {
updateComment(id: $id, text: $text) { updateComment(id: $id, text: $text) {
text text
paidImgLink
} }
}`, { }`, {
update (cache, { data: { updateComment } }) { update (cache, { data: { updateComment } }) {
@ -22,6 +27,9 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
fields: { fields: {
text () { text () {
return updateComment.text return updateComment.text
},
paidImgLink () {
return updateComment.paidImgLink
} }
} }
}) })
@ -51,9 +59,13 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
as={TextareaAutosize} as={TextareaAutosize}
minRows={6} minRows={6}
autoFocus autoFocus
setHasImgLink={setHasImgLink}
required required
/> />
<SubmitButton variant='secondary' className='mt-1'>save</SubmitButton> <EditFeeButton
paidSats={comment.meSats} hadImgLink={comment.paidImgLink} hasImgLink={hasImgLink}
parentId={comment.parentId} text='save' ChildButton={SubmitButton} variant='secondary'
/>
</Form> </Form>
</div> </div>
) )

View File

@ -7,7 +7,7 @@ import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form'
import { MAX_TITLE_LENGTH } from '../lib/constants' import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useState } from 'react' import { useState } from 'react'
import FeeButton from './fee-button' import FeeButton, { EditFeeButton } from './fee-button'
export function DiscussionForm ({ export function DiscussionForm ({
item, editThreshold, titleLabel = 'title', item, editThreshold, titleLabel = 'title',
@ -41,7 +41,7 @@ export function DiscussionForm ({
initial={{ initial={{
title: item?.title || '', title: item?.title || '',
text: item?.text || '', text: item?.text || '',
...AdvPostInitial ...AdvPostInitial({ forward: item?.fwdUser?.name })
}} }}
schema={DiscussionSchema} schema={DiscussionSchema}
onSubmit={handleSubmit || (async ({ boost, ...values }) => { onSubmit={handleSubmit || (async ({ boost, ...values }) => {
@ -77,10 +77,13 @@ export function DiscussionForm ({
: null} : null}
setHasImgLink={setHasImgLink} setHasImgLink={setHasImgLink}
/> />
{!item && adv && <AdvPostForm />} {adv && <AdvPostForm edit={!!item} />}
<div className='mt-3'> <div className='mt-3'>
{item {item
? <SubmitButton variant='secondary'>save</SubmitButton> ? <EditFeeButton
paidSats={item.meSats} hadImgLink={item.paidImgLink} hasImgLink={hasImgLink}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/>
: <FeeButton : <FeeButton
baseFee={1} hasImgLink={hasImgLink} parentId={null} text={buttonText} baseFee={1} hasImgLink={hasImgLink} parentId={null} text={buttonText}
ChildButton={SubmitButton} variant='secondary' ChildButton={SubmitButton} variant='secondary'

View File

@ -62,3 +62,58 @@ export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton,
</div> </div>
) )
} }
function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) {
return (
<Table className={styles.receipt} borderless size='sm'>
<tbody>
{addImgLink &&
<>
<tr>
<td>{paidSats} sats</td>
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
</tr>
<tr>
<td>x 10</td>
<td align='right' className='font-weight-light'>image/link fee</td>
</tr>
<tr>
<td>- {paidSats} sats</td>
<td align='right' className='font-weight-light'>already paid</td>
</tr>
</>}
{boost > 0 &&
<tr>
<td>+ {boost} sats</td>
<td className='font-weight-light' align='right'>boost</td>
</tr>}
</tbody>
<tfoot>
<tr>
<td className='font-weight-bold'>{cost} sats</td>
<td align='right' className='font-weight-light'>total fee</td>
</tr>
</tfoot>
</Table>
)
}
export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, variant, text, alwaysShow, parentId }) {
const formik = useFormikContext()
const boost = formik?.values?.boost || 0
const addImgLink = hasImgLink && !hadImgLink
const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost)
const show = alwaysShow || !formik?.isSubmitting
return (
<div className='d-flex align-items-center'>
<ActionTooltip overlayText={`${cost} sats`}>
<ChildButton variant={variant}>{text}{cost > 0 && show && <small> {cost} sats</small>}</ChildButton>
</ActionTooltip>
{cost > 0 && show &&
<Info>
<EditReceipt paidSats={paidSats} addImgLink={addImgLink} cost={cost} parentId={parentId} boost={boost} />
</Info>}
</div>
)
}

View File

@ -173,6 +173,7 @@ export default function Footer ({ noLinks }) {
size='sm' size='sm'
groupClassName='mb-0 w-100' groupClassName='mb-0 w-100'
readOnly readOnly
noForm
placeholder={data.connectAddress} placeholder={data.connectAddress}
/> />
</div>} </div>}

View File

@ -129,10 +129,10 @@ function FormGroup ({ className, label, children }) {
function InputInner ({ function InputInner ({
prepend, append, hint, showValid, onChange, overrideValue, prepend, append, hint, showValid, onChange, overrideValue,
innerRef, storageKeyPrefix, ...props innerRef, storageKeyPrefix, noForm, ...props
}) { }) {
const [field, meta, helpers] = props.readOnly ? [{}, {}, {}] : useField(props) const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
const formik = props.readOnly ? null : useFormikContext() const formik = noForm ? null : useFormikContext()
const storageKey = storageKeyPrefix ? storageKeyPrefix + '-' + props.name : undefined const storageKey = storageKeyPrefix ? storageKeyPrefix + '-' + props.name : undefined
@ -208,19 +208,19 @@ export function Input ({ label, groupClassName, ...props }) {
) )
} }
export function VariableInput ({ label, groupClassName, name, hint, max, ...props }) { export function VariableInput ({ label, groupClassName, name, hint, max, readOnlyLen, ...props }) {
return ( return (
<FormGroup label={label} className={groupClassName}> <FormGroup label={label} className={groupClassName}>
<FieldArray name={name}> <FieldArray name={name}>
{({ form, ...fieldArrayHelpers }) => { {({ form, ...fieldArrayHelpers }) => {
const options = form.values.options const options = form.values[name]
return ( return (
<> <>
{options.map((_, i) => ( {options.map((_, i) => (
<div key={i}> <div key={i}>
<BootstrapForm.Row className='mb-2'> <BootstrapForm.Row className='mb-2'>
<Col> <Col>
<InputInner name={`${name}[${i}]`} {...props} placeholder={i > 1 ? 'optional' : undefined} /> <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i > 1 ? 'optional' : undefined} />
</Col> </Col>
{options.length - 1 === i && options.length !== max {options.length - 1 === i && options.length !== max
? <AddIcon className='fill-grey align-self-center pointer mx-2' onClick={() => fieldArrayHelpers.push('')} /> ? <AddIcon className='fill-grey align-self-center pointer mx-2' onClick={() => fieldArrayHelpers.push('')} />

View File

@ -21,7 +21,7 @@ export default function Invite ({ invite, active }) {
<CopyInput <CopyInput
groupClassName='mb-1' groupClassName='mb-1'
size='sm' type='text' size='sm' type='text'
placeholder={`https://stacker.news/invites/${invite.id}`} readOnly placeholder={`https://stacker.news/invites/${invite.id}`} readOnly noForm
/> />
<div className={styles.other}> <div className={styles.other}>
<span>{invite.gift} sat gift</span> <span>{invite.gift} sat gift</span>

View File

@ -8,7 +8,7 @@ import { ITEM_FIELDS } from '../fragments/items'
import Item from './item' import Item from './item'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import { MAX_TITLE_LENGTH } from '../lib/constants' import { MAX_TITLE_LENGTH } from '../lib/constants'
import FeeButton from './fee-button' import FeeButton, { EditFeeButton } from './fee-button'
// 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
@ -55,7 +55,7 @@ export function LinkForm ({ item, editThreshold }) {
initial={{ initial={{
title: item?.title || '', title: item?.title || '',
url: item?.url || '', url: item?.url || '',
...AdvPostInitial ...AdvPostInitial({ forward: item?.fwdUser?.name })
}} }}
schema={LinkSchema} schema={LinkSchema}
onSubmit={async ({ boost, title, ...values }) => { onSubmit={async ({ boost, title, ...values }) => {
@ -98,10 +98,13 @@ export function LinkForm ({ item, editThreshold }) {
}) })
}} }}
/> />
{!item && <AdvPostForm />} <AdvPostForm edit={!!item} />
<div className='mt-3'> <div className='mt-3'>
{item {item
? <SubmitButton variant='secondary'>save</SubmitButton> ? <EditFeeButton
paidSats={item.meSats}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/>
: <FeeButton : <FeeButton
baseFee={1} parentId={null} text='post' baseFee={1} parentId={null} text='post'
ChildButton={SubmitButton} variant='secondary' ChildButton={SubmitButton} variant='secondary'

View File

@ -26,7 +26,7 @@ export default function LnQR ({ value, webLn, statusVariant, status }) {
/> />
</a> </a>
<div className='mt-3 w-100'> <div className='mt-3 w-100'>
<CopyInput type='text' placeholder={value} readOnly /> <CopyInput type='text' placeholder={value} readOnly noForm />
</div> </div>
<InvoiceStatus variant={statusVariant} status={status} /> <InvoiceStatus variant={statusVariant} status={status} />
</> </>

View File

@ -2,15 +2,17 @@ import { Form, Input, MarkdownInput, SubmitButton, VariableInput } from '../comp
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import * as Yup from 'yup' import * as Yup from 'yup'
import { gql, useApolloClient, useMutation } from '@apollo/client' import { gql, useApolloClient, useMutation } from '@apollo/client'
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'
import { MAX_TITLE_LENGTH, MAX_POLL_CHOICE_LENGTH } from '../lib/constants' import { MAX_TITLE_LENGTH, MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES } from '../lib/constants'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { useState } from 'react'
import FeeButton, { EditFeeButton } from './fee-button'
export function PollForm ({ item, editThreshold }) { export function PollForm ({ item, editThreshold }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const [hasImgLink, setHasImgLink] = useState()
const [upsertPoll] = useMutation( const [upsertPoll] = useMutation(
gql` gql`
@ -36,16 +38,19 @@ export function PollForm ({ item, editThreshold }) {
...AdvPostSchema(client) ...AdvPostSchema(client)
}) })
const initialOptions = item?.poll?.options.map(i => i.option)
return ( return (
<Form <Form
initial={{ initial={{
title: item?.title || '', title: item?.title || '',
options: item?.options || ['', ''], text: item?.text || '',
...AdvPostInitial options: initialOptions || ['', ''],
...AdvPostInitial({ forward: item?.fwdUser?.name })
}} }}
schema={PollSchema} schema={PollSchema}
onSubmit={async ({ boost, title, options, ...values }) => { onSubmit={async ({ boost, title, options, ...values }) => {
const optionsFiltered = options.filter(word => word.trim().length > 0) const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
const { error } = await upsertPoll({ const { error } = await upsertPoll({
variables: { variables: {
id: item?.id, id: item?.id,
@ -77,20 +82,29 @@ export function PollForm ({ item, editThreshold }) {
name='text' name='text'
as={TextareaAutosize} as={TextareaAutosize}
minRows={2} minRows={2}
setHasImgLink={setHasImgLink}
/> />
<VariableInput <VariableInput
label='choices' label='choices'
name='options' name='options'
max={5} readOnlyLen={initialOptions?.length}
max={MAX_POLL_NUM_CHOICES}
hint={editThreshold hint={editThreshold
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div> ? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
: null} : null}
/> />
{!item && <AdvPostForm />} <AdvPostForm edit={!!item} />
<ActionTooltip> <div className='mt-3'>
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton> {item
</ActionTooltip> ? <EditFeeButton
paidSats={item.meSats} hadImgLink={item.paidImgLink} hasImgLink={hasImgLink}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/>
: <FeeButton
baseFee={1} hasImgLink={hasImgLink} parentId={null} text='post'
ChildButton={SubmitButton} variant='secondary'
/>}
</div>
</Form> </Form>
) )
} }

View File

@ -17,6 +17,7 @@ export const COMMENT_FIELDS = gql`
meComments meComments
path path
mine mine
paidImgLink
ncomments ncomments
root { root {
id id

View File

@ -34,6 +34,7 @@ export const ITEM_FIELDS = gql`
status status
uploadId uploadId
mine mine
paidImgLink
root { root {
id id
title title
@ -64,12 +65,28 @@ export const ITEMS = gql`
} }
}` }`
export const POLL_FIELDS = gql`
fragment PollFields on Item {
poll {
meVoted
count
options {
id
option
count
meVoted
}
}
}`
export const ITEM = gql` export const ITEM = gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
${POLL_FIELDS}
query Item($id: ID!) { query Item($id: ID!) {
item(id: $id) { item(id: $id) {
...ItemFields ...ItemFields
...PollFields
text text
} }
}` }`
@ -86,6 +103,7 @@ export const COMMENTS_QUERY = gql`
export const ITEM_FULL = gql` export const ITEM_FULL = gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
${POLL_FIELDS}
${COMMENTS} ${COMMENTS}
query Item($id: ID!) { query Item($id: ID!) {
item(id: $id) { item(id: $id) {
@ -94,16 +112,7 @@ export const ITEM_FULL = gql`
meComments meComments
position position
text text
poll { ...PollFields
meVoted
count
options {
id
option
count
meVoted
}
}
comments { comments {
...CommentsRecursive ...CommentsRecursive
} }

View File

@ -13,3 +13,4 @@ export const COMMENT_DEPTH_LIMIT = 10
export const MAX_TITLE_LENGTH = 80 export const MAX_TITLE_LENGTH = 80
export const MAX_POLL_CHOICE_LENGTH = 30 export const MAX_POLL_CHOICE_LENGTH = 30
export const ITEM_SPAM_INTERVAL = '10m' export const ITEM_SPAM_INTERVAL = '10m'
export const MAX_POLL_NUM_CHOICES = 10

View File

@ -13,7 +13,7 @@ import { useMe } from '../../components/me'
import { USER_FULL } from '../../fragments/users' import { USER_FULL } from '../../fragments/users'
import { ITEM_FIELDS } from '../../fragments/items' import { ITEM_FIELDS } from '../../fragments/items'
import { getGetServerSideProps } from '../../api/ssrApollo' import { getGetServerSideProps } from '../../api/ssrApollo'
import FeeButton from '../../components/fee-button' import FeeButton, { EditFeeButton } from '../../components/fee-button'
export const getServerSideProps = getGetServerSideProps(USER_FULL, null, export const getServerSideProps = getGetServerSideProps(USER_FULL, null,
data => !data.user) data => !data.user)
@ -74,7 +74,10 @@ export function BioForm ({ handleSuccess, bio }) {
/> />
<div className='mt-3'> <div className='mt-3'>
{bio?.text {bio?.text
? <SubmitButton variant='secondary'>save</SubmitButton> ? <EditFeeButton
paidSats={bio?.meSats} hadImgLink={bio?.paidImgLink} hasImgLink={hasImgLink}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/>
: <FeeButton : <FeeButton
baseFee={1} hasImgLink={hasImgLink} parentId={null} text='create' baseFee={1} hasImgLink={hasImgLink} parentId={null} text='create'
ChildButton={SubmitButton} variant='secondary' ChildButton={SubmitButton} variant='secondary'

View File

@ -4,6 +4,7 @@ import { DiscussionForm } from '../../../components/discussion-form'
import { LinkForm } from '../../../components/link-form' 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'
export const getServerSideProps = getGetServerSideProps(ITEM, null, export const getServerSideProps = getGetServerSideProps(ITEM, null,
data => !data.item) data => !data.item)
@ -16,8 +17,10 @@ export default function PostEdit ({ data: { item } }) {
{item.maxBid {item.maxBid
? <JobForm item={item} sub={item.sub} /> ? <JobForm item={item} sub={item.sub} />
: (item.url : (item.url
? <LinkForm item={item} editThreshold={editThreshold} /> ? <LinkForm item={item} editThreshold={editThreshold} adv />
: <DiscussionForm item={item} editThreshold={editThreshold} />)} : (item.pollCost
? <PollForm item={item} editThreshold={editThreshold} />
: <DiscussionForm item={item} editThreshold={editThreshold} adv />))}
</LayoutCenter> </LayoutCenter>
) )
} }

View File

@ -241,6 +241,7 @@ function AuthMethods ({ methods }) {
placeholder={methods.email} placeholder={methods.email}
groupClassName='mb-0' groupClassName='mb-0'
readOnly readOnly
noForm
/> />
<Button <Button
className='ml-2' variant='secondary' onClick={ className='ml-2' variant='secondary' onClick={

View File

@ -81,13 +81,13 @@ function LoadWithdrawl () {
<div className='w-100'> <div className='w-100'>
<CopyInput <CopyInput
label='invoice' type='text' label='invoice' type='text'
placeholder={data.withdrawl.bolt11} readOnly placeholder={data.withdrawl.bolt11} readOnly noForm
/> />
</div> </div>
<div className='w-100'> <div className='w-100'>
<Input <Input
label='max fee' type='text' label='max fee' type='text'
placeholder={data.withdrawl.satsFeePaying} readOnly placeholder={data.withdrawl.satsFeePaying} readOnly noForm
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/> />
</div> </div>

View File

@ -0,0 +1,107 @@
CREATE OR REPLACE FUNCTION update_item(item_id INTEGER,
item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER,
fwd_user_id INTEGER, has_img_link BOOLEAN)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
prior_cost INTEGER;
prior_act_id INTEGER;
cost INTEGER;
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT * INTO item FROM "Item" WHERE id = item_id;
-- if has_img_link we need to figure out new costs, which is their prior_cost * 9
IF has_img_link AND NOT item."paidImgLink" THEN
SELECT sats * 1000, id INTO prior_cost, prior_act_id
FROM "ItemAct"
WHERE act = 'VOTE' AND "itemId" = item.id AND "userId" = item."userId";
cost := prior_cost * 9;
IF cost > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
UPDATE users SET msats = msats - cost WHERE id = item."userId";
UPDATE "ItemAct" SET sats = (prior_cost + cost) / 1000 WHERE id = prior_act_id;
END IF;
UPDATE "Item" set title = item_title, url = item_url, text = item_text, "fwdUserId" = fwd_user_id, "paidImgLink" = has_img_link
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;
$$;
CREATE OR REPLACE FUNCTION create_poll(
title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER,
options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN, spam_within INTERVAL)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := create_item(title, null, text, boost, null, user_id, fwd_user_id, has_img_link, spam_within);
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
FOREACH option IN ARRAY options LOOP
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
END LOOP;
RETURN item;
END;
$$;
CREATE OR REPLACE FUNCTION update_poll(
id INTEGER, title TEXT, text TEXT, boost INTEGER,
options TEXT[], fwd_user_id INTEGER, has_img_link BOOLEAN)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := update_item(id, title, null, text, boost, fwd_user_id, has_img_link);
FOREACH option IN ARRAY options LOOP
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
END LOOP;
RETURN item;
END;
$$;
CREATE OR REPLACE FUNCTION create_bio(title TEXT, text TEXT, user_id INTEGER, has_img_link BOOLEAN)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT * INTO item FROM create_item(title, NULL, text, 0, NULL, user_id, NULL, has_img_link, '0');
UPDATE users SET "bioId" = item.id WHERE id = user_id;
RETURN item;
END;
$$;