idempotent zaps

This commit is contained in:
keyan 2023-12-26 20:27:52 -06:00
parent 374a7985da
commit 73ad93f2bb
10 changed files with 301 additions and 101 deletions

View File

@ -786,6 +786,52 @@ export default {
notifyZapped({ models, id }) notifyZapped({ models, id })
return {
id,
sats,
act,
path: item.path
}
},
idempotentAct: async (parent, { id, sats, act = 'TIP', hash, hmac }, { me, models, lnd, headers }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
await ssValidate(actSchema, { sats, act })
await assertGofacYourself({ models, headers })
const [item] = await models.$queryRawUnsafe(`
${SELECT}
FROM "Item"
WHERE id = $1`, Number(id))
if (Number(item.userId) === Number(me.id)) {
throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } })
}
// Disallow tips if me is one of the forward user recipients
if (act === 'TIP') {
const existingForwards = await models.itemForward.findMany({ where: { itemId: Number(id) } })
if (existingForwards.some(fwd => Number(fwd.userId) === Number(me.id))) {
throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } })
}
}
await serializeInvoicable(
models.$queryRaw`
SELECT
item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, ${act}::"ItemActType",
(SELECT ${Number(sats)}::INTEGER - COALESCE(sum(msats) / 1000, 0)
FROM "ItemAct"
WHERE act IN ('TIP', 'FEE')
AND "itemId" = ${Number(id)}::INTEGER
AND "userId" = ${me.id}::INTEGER)::INTEGER)`,
{ me, models, lnd, hash, hmac, enforceFee: sats }
)
notifyZapped({ models, id })
return { return {
id, id,
sats, sats,

View File

@ -37,6 +37,7 @@ export default gql`
updateNoteId(id: ID!, noteId: String!): Item! updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item! upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item!
act(id: ID!, sats: Int, act: String, hash: String, hmac: String): ItemActResult! act(id: ID!, sats: Int, act: String, hash: String, hmac: String): ItemActResult!
idempotentAct(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult!
pollVote(id: ID!, hash: String, hmac: String): ID! pollVote(id: ID!, hash: String, hmac: String): ID!
} }

View File

@ -108,7 +108,6 @@ export default function Comment ({
const ref = useRef(null) const ref = useRef(null)
const router = useRouter() const router = useRouter()
const root = useRoot() const root = useRoot()
const [pendingSats, setPendingSats] = useState(0)
const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text }) const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text })
useEffect(() => { useEffect(() => {
@ -148,7 +147,7 @@ export default function Comment ({
<div className={`${itemStyles.item} ${styles.item}`}> <div className={`${itemStyles.item} ${styles.item}`}>
{item.meDontLikeSats > item.meSats {item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} /> ? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
: <UpVote item={item} className={styles.upvote} pendingSats={pendingSats} setPendingSats={setPendingSats} />} : <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'>
{item.user?.meMute && !includeParent && collapse === 'yep' {item.user?.meMute && !includeParent && collapse === 'yep'
@ -162,7 +161,6 @@ export default function Comment ({
</span>) </span>)
: <ItemInfo : <ItemInfo
item={item} item={item}
pendingSats={pendingSats}
commentsText='replies' commentsText='replies'
commentTextSingular='reply' commentTextSingular='reply'
className={`${itemStyles.other} ${styles.other}`} className={`${itemStyles.other} ${styles.other}`}

View File

@ -6,7 +6,6 @@ import AccordianItem from './accordian-item'
import Flag from '../svgs/flag-fill.svg' import Flag from '../svgs/flag-fill.svg'
import { useMemo } from 'react' import { useMemo } from 'react'
import getColor from '../lib/rainbow' import getColor from '../lib/rainbow'
import { useLightning } from './lightning'
export function DownZap ({ id, meDontLikeSats, ...props }) { export function DownZap ({ id, meDontLikeSats, ...props }) {
const style = useMemo(() => (meDontLikeSats const style = useMemo(() => (meDontLikeSats
@ -23,7 +22,6 @@ export function DownZap ({ id, meDontLikeSats, ...props }) {
function DownZapper ({ id, As, children }) { function DownZapper ({ id, As, children }) {
const toaster = useToast() const toaster = useToast()
const showModal = useShowModal() const showModal = useShowModal()
const strike = useLightning()
return ( return (
<As <As
@ -34,7 +32,7 @@ function DownZapper ({ id, As, children }) {
onClose={() => { onClose={() => {
onClose() onClose()
toaster.success('item downzapped') toaster.success('item downzapped')
}} itemId={id} strike={strike} down }} itemId={id} down
> >
<AccordianItem <AccordianItem
header='what is a downzap?' body={ header='what is a downzap?' body={

View File

@ -6,6 +6,9 @@ import { useMe } from './me'
import UpBolt from '../svgs/bolt.svg' import UpBolt from '../svgs/bolt.svg'
import { amountSchema } from '../lib/validate' import { amountSchema } from '../lib/validate'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import { payOrLoginError, useInvoiceModal } from './invoice'
import { useToast } from './toast'
import { useLightning } from './lightning'
const defaultTips = [100, 1000, 10000, 100000] const defaultTips = [100, 1000, 10000, 100000]
@ -37,10 +40,11 @@ const addCustomTip = (amount) => {
window.localStorage.setItem('custom-tips', JSON.stringify(customTips)) window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
} }
export default function ItemAct ({ onClose, itemId, down, strike, children }) { export default function ItemAct ({ onClose, itemId, down, children }) {
const inputRef = useRef(null) const inputRef = useRef(null)
const me = useMe() const me = useMe()
const [oValue, setOValue] = useState() const [oValue, setOValue] = useState()
const strike = useLightning()
useEffect(() => { useEffect(() => {
inputRef.current?.focus() inputRef.current?.focus()
@ -63,7 +67,7 @@ export default function ItemAct ({ onClose, itemId, down, strike, children }) {
hmac hmac
} }
}) })
strike && await strike() await strike()
addCustomTip(Number(amount)) addCustomTip(Number(amount))
onClose() onClose()
}, [act, down, itemId, strike]) }, [act, down, itemId, strike])
@ -166,3 +170,110 @@ export function useAct ({ onUpdate } = {}) {
}`, { update } }`, { update }
) )
} }
export function useZap () {
const update = useCallback((cache, args) => {
const { data: { idempotentAct: { id, sats, path } } } = args
// determine how much we increased existing sats by by checking the
// difference between result sats and meSats
// if it's negative, skip the cache as it's an out of order update
// if it's positive, add it to sats and commentSats
const item = cache.readFragment({
id: `Item:${id}`,
fragment: gql`
fragment ItemMeSats on Item {
meSats
}
`
})
const satsDelta = sats - item.meSats
if (satsDelta > 0) {
cache.modify({
id: `Item:${id}`,
fields: {
sats (existingSats = 0) {
return existingSats + satsDelta
},
meSats: () => {
return sats
}
}
})
// update all ancestors
path.split('.').forEach(aId => {
if (Number(aId) === Number(id)) return
cache.modify({
id: `Item:${aId}`,
fields: {
commentSats (existingCommentSats = 0) {
return existingCommentSats + satsDelta
}
}
})
})
}
}, [])
const [zap] = useMutation(
gql`
mutation idempotentAct($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
idempotentAct(id: $id, sats: $sats, hash: $hash, hmac: $hmac) {
id
sats
path
}
}`, { update }
)
const toaster = useToast()
const strike = useLightning()
const [act] = useAct()
const showInvoiceModal = useInvoiceModal(
async ({ hash, hmac }, { variables }) => {
await act({ variables: { ...variables, hash, hmac } })
strike()
}, [act, strike])
return useCallback(async ({ item, me }) => {
console.log(item)
const meSats = (item?.meSats || 0)
// what should our next tip be?
let sats = me?.privates?.tipDefault || 1
if (me?.privates?.turboTipping) {
while (meSats >= sats) {
sats *= 10
}
} else {
sats = meSats + sats
}
const variables = { id: item.id, sats, act: 'TIP' }
try {
await zap({
variables,
optimisticResponse: {
idempotentAct: {
path: item.path,
...variables
}
}
})
} catch (error) {
if (payOrLoginError(error)) {
// call non-idempotent version
const amount = sats - meSats
showInvoiceModal({ amount }, { variables: { ...variables, sats: amount } })
return
}
console.error(error)
toaster.danger(error?.message || error?.toString?.())
}
})
}

View File

@ -21,7 +21,7 @@ import MuteDropdownItem from './mute'
import { DropdownItemUpVote } from './upvote' import { DropdownItemUpVote } from './upvote'
export default function ItemInfo ({ export default function ItemInfo ({
item, pendingSats, full, commentsText = 'comments', item, full, commentsText = 'comments',
commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText, commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText,
onQuoteReply, nofollow, extraBadges onQuoteReply, nofollow, extraBadges
}) { }) {
@ -40,8 +40,8 @@ export default function ItemInfo ({
}, [item]) }, [item])
useEffect(() => { useEffect(() => {
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0) + (pendingSats || 0)) if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0))
}, [item?.meSats, item?.meAnonSats, pendingSats]) }, [item?.meSats, item?.meAnonSats])
return ( return (
<div className={className || `${styles.other}`}> <div className={className || `${styles.other}`}>
@ -57,7 +57,7 @@ export default function ItemInfo ({
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}` ? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
: ''} from me)`} `} : ''} from me)`} `}
> >
{numWithUnits(item.sats + pendingSats)} {numWithUnits(item.sats)}
</span> </span>
<span> \ </span> <span> \ </span>
</>} </>}

View File

@ -1,7 +1,7 @@
import Link from 'next/link' import Link from 'next/link'
import styles from './item.module.css' import styles from './item.module.css'
import UpVote from './upvote' import UpVote from './upvote'
import { useRef, useState } from 'react' import { useRef } from 'react'
import { AD_USER_ID, NOFOLLOW_LIMIT } from '../lib/constants' import { AD_USER_ID, NOFOLLOW_LIMIT } from '../lib/constants'
import Pin from '../svgs/pushpin-fill.svg' import Pin from '../svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace' import reactStringReplace from 'react-string-replace'
@ -27,7 +27,6 @@ export function SearchTitle ({ title }) {
export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, onQuoteReply }) { export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, onQuoteReply }) {
const titleRef = useRef() const titleRef = useRef()
const router = useRouter() const router = useRouter()
const [pendingSats, setPendingSats] = useState(0)
const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL) const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL)
const nofollow = item.sats + item.boost < NOFOLLOW_LIMIT && !item.position ? 'nofollow' : '' const nofollow = item.sats + item.boost < NOFOLLOW_LIMIT && !item.position ? 'nofollow' : ''
@ -47,7 +46,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} /> ? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
: Number(item.user?.id) === AD_USER_ID : Number(item.user?.id) === AD_USER_ID
? <AdIcon width={24} height={24} className={styles.ad} /> ? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} pendingSats={pendingSats} setPendingSats={setPendingSats} />} : <UpVote item={item} className={styles.upvote} />}
<div className={styles.hunk}> <div className={styles.hunk}>
<div className={`${styles.main} flex-wrap`}> <div className={`${styles.main} flex-wrap`}>
<Link <Link
@ -93,7 +92,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
</>} </>}
</div> </div>
<ItemInfo <ItemInfo
full={full} item={item} pendingSats={pendingSats} full={full} item={item}
onQuoteReply={onQuoteReply} onQuoteReply={onQuoteReply}
nofollow={nofollow} nofollow={nofollow}
extraBadges={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>} extraBadges={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>}

View File

@ -54,7 +54,6 @@ export default function Items ({ ssrData, variables = {}, query, destructureData
} }
export function ListItem ({ item, ...props }) { export function ListItem ({ item, ...props }) {
console.log(item)
return ( return (
item.parentId item.parentId
? <CommentFlat item={item} noReply includeParent clickToContext {...props} /> ? <CommentFlat item={item} noReply includeParent clickToContext {...props} />

View File

@ -2,7 +2,7 @@ import UpBolt from '../svgs/bolt.svg'
import styles from './upvote.module.css' import styles from './upvote.module.css'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
import ItemAct, { useAct } from './item-act' import ItemAct, { useAct, useZap } from './item-act'
import { useMe } from './me' import { useMe } from './me'
import getColor from '../lib/rainbow' import getColor from '../lib/rainbow'
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
@ -10,11 +10,8 @@ import LongPressable from 'react-longpressable'
import Overlay from 'react-bootstrap/Overlay' import Overlay from 'react-bootstrap/Overlay'
import Popover from 'react-bootstrap/Popover' import Popover from 'react-bootstrap/Popover'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { LightningConsumer, useLightning } from './lightning' import { useLightning } from './lightning'
import { numWithUnits } from '../lib/format' import { numWithUnits } from '../lib/format'
import { payOrLoginError, useInvoiceModal } from './invoice'
import useDebounceCallback from './use-debounce-callback'
import { useToast } from './toast'
import { Dropdown } from 'react-bootstrap' import { Dropdown } from 'react-bootstrap'
const UpvotePopover = ({ target, show, handleClose }) => { const UpvotePopover = ({ target, show, handleClose }) => {
@ -58,13 +55,12 @@ const TipPopover = ({ target, show, handleClose }) => (
export function DropdownItemUpVote ({ item }) { export function DropdownItemUpVote ({ item }) {
const showModal = useShowModal() const showModal = useShowModal()
const strike = useLightning()
return ( return (
<Dropdown.Item <Dropdown.Item
onClick={async () => { onClick={async () => {
showModal(onClose => showModal(onClose =>
<ItemAct onClose={onClose} itemId={item.id} strike={strike} />) <ItemAct onClose={onClose} itemId={item.id} />)
}} }}
> >
<span className='text-success'>zap</span> <span className='text-success'>zap</span>
@ -72,14 +68,12 @@ export function DropdownItemUpVote ({ item }) {
) )
} }
export default function UpVote ({ item, className, pendingSats, setPendingSats }) { export default function UpVote ({ item, className }) {
const showModal = useShowModal() const showModal = useShowModal()
const [voteShow, _setVoteShow] = useState(false) const [voteShow, _setVoteShow] = useState(false)
const [tipShow, _setTipShow] = useState(false) const [tipShow, _setTipShow] = useState(false)
const ref = useRef() const ref = useRef()
const me = useMe() const me = useMe()
const strike = useLightning()
const toaster = useToast()
const [setWalkthrough] = useMutation( const [setWalkthrough] = useMutation(
gql` gql`
mutation setWalkthrough($upvotePopover: Boolean, $tipPopover: Boolean) { mutation setWalkthrough($upvotePopover: Boolean, $tipPopover: Boolean) {
@ -116,61 +110,32 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}, [me, tipShow, setWalkthrough]) }, [me, tipShow, setWalkthrough])
const [act] = useAct() const [act] = useAct()
const zap = useZap()
const showInvoiceModal = useInvoiceModal( const strike = useLightning()
async ({ hash, hmac }, { variables }) => {
await act({ variables: { ...variables, hash, hmac } })
strike()
}, [act, strike])
const zap = useDebounceCallback(async (sats) => {
if (!sats) return
const variables = { id: item.id, sats, act: 'TIP' }
act({
variables,
optimisticResponse: {
act: {
id: item.id,
sats,
path: item.path,
act: 'TIP'
}
}
}).catch((error) => {
if (payOrLoginError(error)) {
showInvoiceModal({ amount: sats }, { variables })
return
}
console.error(error)
toaster.danger(error?.message || error?.toString?.())
})
setPendingSats(0)
}, 500, [act, toaster, item?.id, item?.path, showInvoiceModal, setPendingSats])
const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt, const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt,
[item?.mine, item?.meForward, item?.deletedAt]) [item?.mine, item?.meForward, item?.deletedAt])
const [meSats, sats, overlayText, color] = useMemo(() => { const [meSats, overlayText, color] = useMemo(() => {
const meSats = (item?.meSats || item?.meAnonSats || 0) + pendingSats const meSats = (item?.meSats || item?.meAnonSats || 0)
// what should our next tip be? // what should our next tip be?
let sats = me?.privates?.tipDefault || 1 let sats = me?.privates?.tipDefault || 1
let raiseSats = sats
if (me?.privates?.turboTipping) { if (me?.privates?.turboTipping) {
let raiseTip = sats while (meSats >= raiseSats) {
while (meSats >= raiseTip) { raiseSats *= 10
raiseTip *= 10
} }
sats = raiseTip - meSats sats = raiseSats - meSats
} else {
raiseSats = meSats + sats
} }
return [meSats, sats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it', getColor(meSats)] return [meSats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it', getColor(meSats)]
}, [item?.meSats, item?.meAnonSats, pendingSats, me?.privates?.tipDefault, me?.privates?.turboDefault]) }, [item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault])
return ( return (
<LightningConsumer>
{(strike) =>
<div ref={ref} className='upvoteParent'> <div ref={ref} className='upvoteParent'>
<LongPressable <LongPressable
onLongPress={ onLongPress={
@ -184,7 +149,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
setTipShow(false) setTipShow(false)
showModal(onClose => showModal(onClose =>
<ItemAct onClose={onClose} itemId={item.id} strike={strike} />) <ItemAct onClose={onClose} itemId={item.id} />)
} }
} }
onShortPress={ onShortPress={
@ -205,13 +170,9 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
strike() strike()
setPendingSats(pendingSats => { zap({ item, me })
const zapAmount = pendingSats + sats
zap(zapAmount)
return zapAmount
})
} }
: () => showModal(onClose => <ItemAct onClose={onClose} itemId={item.id} act={act} strike={strike} />) : () => showModal(onClose => <ItemAct onClose={onClose} itemId={item.id} act={act} />)
} }
> >
<ActionTooltip notForm disable={disabled} overlayText={overlayText}> <ActionTooltip notForm disable={disabled} overlayText={overlayText}>
@ -239,7 +200,6 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
</LongPressable> </LongPressable>
<TipPopover target={ref.current} show={tipShow} handleClose={() => setTipShow(false)} /> <TipPopover target={ref.current} show={tipShow} handleClose={() => setTipShow(false)} />
<UpvotePopover target={ref.current} show={voteShow} handleClose={() => setVoteShow(false)} /> <UpvotePopover target={ref.current} show={voteShow} handleClose={() => setVoteShow(false)} />
</div>} </div>
</LightningConsumer>
) )
} }

View File

@ -0,0 +1,88 @@
-- Update item_act to noop on negative or zero sats
CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
user_msats BIGINT;
act_msats BIGINT;
fee_msats BIGINT;
item_act_id INTEGER;
fwd_entry record; -- for loop iterator variable to iterate across forward recipients
fwd_msats BIGINT; -- for loop variable calculating how many msats to give each forward recipient
total_fwd_msats BIGINT := 0; -- accumulator to see how many msats have been forwarded for the act
BEGIN
PERFORM ASSERT_SERIALIZED();
IF act_sats <= 0 THEN
RETURN 0;
END IF;
act_msats := act_sats * 1000;
SELECT msats INTO user_msats FROM users WHERE id = user_id;
IF act_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
-- deduct msats from actor
UPDATE users SET msats = msats - act_msats WHERE id = user_id;
IF act = 'TIP' THEN
-- call to influence weightedVotes ... we need to do this before we record the acts because
-- the priors acts are taken into account
PERFORM weighted_votes_after_tip(item_id, user_id, act_sats);
-- call to denormalize sats and commentSats
PERFORM sats_after_tip(item_id, user_id, act_msats);
-- take 10% and insert as FEE
fee_msats := CEIL(act_msats * 0.1);
act_msats := act_msats - fee_msats;
-- save the fee act into item_act_id so we can record referral acts
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (fee_msats, item_id, user_id, 'FEE', now_utc(), now_utc())
RETURNING id INTO item_act_id;
-- leave the rest as a tip
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_msats, item_id, user_id, 'TIP', now_utc(), now_utc());
-- denormalize bounty paid (if applicable)
PERFORM bounty_paid_after_act(item_id, user_id);
-- add sats to actees' balance and stacked count
FOR fwd_entry IN SELECT "userId", "pct" FROM "ItemForward" WHERE "itemId" = item_id
LOOP
-- fwd_msats represents the sats for this forward recipient from this particular tip action
fwd_msats := act_msats * fwd_entry.pct / 100;
-- keep track of how many msats have been forwarded, so we can give any remaining to OP
total_fwd_msats := fwd_msats + total_fwd_msats;
UPDATE users
SET msats = msats + fwd_msats, "stackedMsats" = "stackedMsats" + fwd_msats
WHERE id = fwd_entry."userId";
END LOOP;
-- Give OP any remaining msats after forwards have been applied
IF act_msats - total_fwd_msats > 0 THEN
UPDATE users
SET msats = msats + act_msats - total_fwd_msats, "stackedMsats" = "stackedMsats" + act_msats - total_fwd_msats
WHERE id = (SELECT "userId" FROM "Item" WHERE id = item_id);
END IF;
ELSE -- BOOST, POLL, DONT_LIKE_THIS, STREAM
-- call to influence if DONT_LIKE_THIS weightedDownVotes
IF act = 'DONT_LIKE_THIS' THEN
PERFORM weighted_downvotes_after_act(item_id, user_id, act_sats);
END IF;
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_msats, item_id, user_id, act, now_utc(), now_utc())
RETURNING id INTO item_act_id;
END IF;
-- store referral effects
PERFORM referral_act(item_act_id);
RETURN 0;
END;
$$;