diff --git a/common/comment.ts b/common/comment.ts index cdb62fd3..71c04af4 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -18,6 +18,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = { userName: string userUsername: string userAvatarUrl?: string + bountiesAwarded?: number } & T export type OnContract = { diff --git a/common/contract.ts b/common/contract.ts index 248c9745..2e9d94c4 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -62,6 +62,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = { featuredOnHomeRank?: number likedByUserIds?: string[] likedByUserCount?: number + openCommentBounties?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/economy.ts b/common/economy.ts index 7ec52b30..d25a0c71 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -15,3 +15,4 @@ export const BETTING_STREAK_BONUS_AMOUNT = export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 +export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250 diff --git a/common/envs/prod.ts b/common/envs/prod.ts index d0469d84..38dd4feb 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -41,6 +41,7 @@ export type Economy = { BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_RESET_HOUR?: number FREE_MARKETS_PER_USER_MAX?: number + COMMENT_BOUNTY_AMOUNT?: number } type FirebaseConfig = { diff --git a/common/txn.ts b/common/txn.ts index 2b7a32e8..c404059d 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -8,6 +8,7 @@ type AnyTxnType = | UniqueBettorBonus | BettingStreakBonus | CancelUniqueBettorBonus + | CommentBountyRefund type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn<T extends AnyTxnType = AnyTxnType> = { @@ -31,6 +32,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = { | 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' | 'CANCEL_UNIQUE_BETTOR_BONUS' + | 'COMMENT_BOUNTY' + | 'REFUND_COMMENT_BOUNTY' // Any extra data data?: { [key: string]: any } @@ -98,6 +101,34 @@ type CancelUniqueBettorBonus = { } } +type CommentBountyDeposit = { + fromType: 'USER' + toType: 'BANK' + category: 'COMMENT_BOUNTY' + data: { + contractId: string + } +} + +type CommentBountyWithdrawal = { + fromType: 'BANK' + toType: 'USER' + category: 'COMMENT_BOUNTY' + data: { + contractId: string + commentId: string + } +} + +type CommentBountyRefund = { + fromType: 'BANK' + toType: 'USER' + category: 'REFUND_COMMENT_BOUNTY' + data: { + contractId: string + } +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink @@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral export type BettingStreakBonusTxn = Txn & BettingStreakBonus export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus +export type CommentBountyDepositTxn = Txn & CommentBountyDeposit +export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 038e0142..9bd73d05 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1046,3 +1046,47 @@ export const createContractResolvedNotifications = async ( ) ) } + +export const createBountyNotification = async ( + fromUser: User, + toUserId: string, + amount: number, + idempotencyKey: string, + contract: Contract, + commentId?: string +) => { + const privateUser = await getPrivateUser(toUserId) + if (!privateUser) return + const { sendToBrowser } = getNotificationDestinationsForUser( + privateUser, + 'tip_received' + ) + if (!sendToBrowser) return + + const slug = commentId + const notificationRef = firestore + .collection(`/users/${toUserId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUserId, + reason: 'tip_received', + createdTime: Date.now(), + isSeen: false, + sourceId: commentId ? commentId : contract.id, + sourceType: 'tip', + sourceUpdateType: 'created', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: amount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceSlug: slug, + sourceTitle: contract.question, + } + return await notificationRef.set(removeUndefinedProps(notification)) + + // maybe TODO: send email notification to comment creator +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 9a8ec232..f5c45004 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -52,6 +52,7 @@ export * from './unsubscribe' export * from './stripe' export * from './mana-bonus-email' export * from './close-market' +export * from './update-comment-bounty' import { health } from './health' import { transact } from './transact' @@ -65,6 +66,7 @@ import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' import { addliquidity } from './add-liquidity' +import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' @@ -91,6 +93,8 @@ const sellSharesFunction = toCloudFunction(sellshares) const claimManalinkFunction = toCloudFunction(claimmanalink) const createMarketFunction = toCloudFunction(createmarket) const addLiquidityFunction = toCloudFunction(addliquidity) +const addCommentBounty = toCloudFunction(addcommentbounty) +const awardCommentBounty = toCloudFunction(awardcommentbounty) const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity) const createGroupFunction = toCloudFunction(creategroup) const resolveMarketFunction = toCloudFunction(resolvemarket) @@ -127,4 +131,6 @@ export { acceptChallenge as acceptchallenge, createPostFunction as createpost, saveTwitchCredentials as savetwitchcredentials, + addCommentBounty as addcommentbounty, + awardCommentBounty as awardcommentbounty, } diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 5e2a94c0..d667f0d2 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -1,44 +1,118 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' +import { getUser, getValues, log } from './utils' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { Contract } from '../../common/contract' +import { Txn } from '../../common/txn' +import { partition, sortBy } from 'lodash' +import { runTxn, TxnData } from './transact' +import * as admin from 'firebase-admin' export const onUpdateContract = functions.firestore .document('contracts/{contractId}') .onUpdate(async (change, context) => { const contract = change.after.data() as Contract + const previousContract = change.before.data() as Contract const { eventId } = context - - const contractUpdater = await getUser(contract.creatorId) - if (!contractUpdater) throw new Error('Could not find contract updater') - - const previousValue = change.before.data() as Contract - - // Resolution is handled in resolve-market.ts - if (!previousValue.isResolved && contract.isResolved) return + const { openCommentBounties, closeTime, question } = contract if ( - previousValue.closeTime !== contract.closeTime || - previousValue.question !== contract.question + !previousContract.isResolved && + contract.isResolved && + (openCommentBounties ?? 0) > 0 ) { - let sourceText = '' - if ( - previousValue.closeTime !== contract.closeTime && - contract.closeTime - ) { - sourceText = contract.closeTime.toString() - } else if (previousValue.question !== contract.question) { - sourceText = contract.question - } - - await createCommentOrAnswerOrUpdatedContractNotification( - contract.id, - 'contract', - 'updated', - contractUpdater, - eventId, - sourceText, - contract - ) + await handleUnusedCommentBountyRefunds(contract) + // No need to notify users of resolution, that's handled in resolve-market + return + } + if ( + previousContract.closeTime !== closeTime || + previousContract.question !== question + ) { + await handleUpdatedCloseTime(previousContract, contract, eventId) } }) + +async function handleUpdatedCloseTime( + previousContract: Contract, + contract: Contract, + eventId: string +) { + const contractUpdater = await getUser(contract.creatorId) + if (!contractUpdater) throw new Error('Could not find contract updater') + let sourceText = '' + if (previousContract.closeTime !== contract.closeTime && contract.closeTime) { + sourceText = contract.closeTime.toString() + } else if (previousContract.question !== contract.question) { + sourceText = contract.question + } + + await createCommentOrAnswerOrUpdatedContractNotification( + contract.id, + 'contract', + 'updated', + contractUpdater, + eventId, + sourceText, + contract + ) +} + +async function handleUnusedCommentBountyRefunds(contract: Contract) { + const outstandingCommentBounties = await getValues<Txn>( + firestore.collection('txns').where('category', '==', 'COMMENT_BOUNTY') + ) + + const commentBountiesOnThisContract = sortBy( + outstandingCommentBounties.filter( + (bounty) => bounty.data?.contractId === contract.id + ), + (bounty) => bounty.createdTime + ) + + const [toBank, fromBank] = partition( + commentBountiesOnThisContract, + (bounty) => bounty.toType === 'BANK' + ) + if (toBank.length <= fromBank.length) return + + await firestore + .collection('contracts') + .doc(contract.id) + .update({ openCommentBounties: 0 }) + + const refunds = toBank.slice(fromBank.length) + await Promise.all( + refunds.map(async (extraBountyTxn) => { + const result = await firestore.runTransaction(async (trans) => { + const bonusTxn: TxnData = { + fromId: extraBountyTxn.toId, + fromType: 'BANK', + toId: extraBountyTxn.fromId, + toType: 'USER', + amount: extraBountyTxn.amount, + token: 'M$', + category: 'REFUND_COMMENT_BOUNTY', + data: { + contractId: contract.id, + }, + } + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + log( + `Couldn't refund bonus for user: ${extraBountyTxn.fromId} - status:`, + result.status + ) + log('message:', result.message) + } else { + log( + `Refund bonus txn for user: ${extraBountyTxn.fromId} completed:`, + result.txn?.id + ) + } + }) + ) +} + +const firestore = admin.firestore() diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 99ac6281..d861dcbc 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -29,6 +29,7 @@ import { getcurrentuser } from './get-current-user' import { createpost } from './create-post' import { savetwitchcredentials } from './save-twitch-credentials' import { testscheduledfunction } from './test-scheduled-function' +import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -61,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares) addJsonEndpointRoute('/claimmanalink', claimmanalink) addJsonEndpointRoute('/createmarket', createmarket) addJsonEndpointRoute('/addliquidity', addliquidity) +addJsonEndpointRoute('/addCommentBounty', addcommentbounty) +addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty) addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity) addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/resolvemarket', resolvemarket) diff --git a/functions/src/update-comment-bounty.ts b/functions/src/update-comment-bounty.ts new file mode 100644 index 00000000..af1d6c0a --- /dev/null +++ b/functions/src/update-comment-bounty.ts @@ -0,0 +1,162 @@ +import * as admin from 'firebase-admin' +import { z } from 'zod' + +import { Contract } from '../../common/contract' +import { User } from '../../common/user' +import { removeUndefinedProps } from '../../common/util/object' +import { APIError, newEndpoint, validate } from './api' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' +import { isProd } from './utils' +import { + CommentBountyDepositTxn, + CommentBountyWithdrawalTxn, +} from '../../common/txn' +import { runTxn } from './transact' +import { Comment } from '../../common/comment' +import { createBountyNotification } from './create-notification' + +const bodySchema = z.object({ + contractId: z.string(), + amount: z.number().gt(0), +}) +const awardBodySchema = z.object({ + contractId: z.string(), + commentId: z.string(), + amount: z.number().gt(0), +}) + +export const addcommentbounty = newEndpoint({}, async (req, auth) => { + const { amount, contractId } = validate(bodySchema, req.body) + + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') + + // run as transaction to prevent race conditions + return await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract + + if (user.balance < amount) + throw new APIError(400, 'Insufficient user balance') + + const newCommentBountyTxn = { + fromId: user.id, + fromType: 'USER', + toId: isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + toType: 'BANK', + amount, + token: 'M$', + category: 'COMMENT_BOUNTY', + data: { + contractId, + }, + description: `Deposit M$${amount} from ${user.id} for comment bounty for contract ${contractId}`, + } as CommentBountyDepositTxn + + const result = await runTxn(transaction, newCommentBountyTxn) + + transaction.update( + contractDoc, + removeUndefinedProps({ + openCommentBounties: (contract.openCommentBounties ?? 0) + amount, + }) + ) + + return result + }) +}) +export const awardcommentbounty = newEndpoint({}, async (req, auth) => { + const { amount, commentId, contractId } = validate(awardBodySchema, req.body) + + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') + + // run as transaction to prevent race conditions + const res = await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract + + if (user.id !== contract.creatorId) + throw new APIError( + 400, + 'Only contract creator can award comment bounties' + ) + + const commentDoc = firestore.doc( + `contracts/${contractId}/comments/${commentId}` + ) + const commentSnap = await transaction.get(commentDoc) + if (!commentSnap.exists) throw new APIError(400, 'Invalid comment') + + const comment = commentSnap.data() as Comment + const amountAvailable = contract.openCommentBounties ?? 0 + if (amountAvailable < amount) + throw new APIError(400, 'Insufficient open bounty balance') + + const newCommentBountyTxn = { + fromId: isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + fromType: 'BANK', + toId: comment.userId, + toType: 'USER', + amount, + token: 'M$', + category: 'COMMENT_BOUNTY', + data: { + contractId, + commentId, + }, + description: `Withdrawal M$${amount} from BANK for comment ${comment.id} bounty for contract ${contractId}`, + } as CommentBountyWithdrawalTxn + + const result = await runTxn(transaction, newCommentBountyTxn) + + await transaction.update( + contractDoc, + removeUndefinedProps({ + openCommentBounties: amountAvailable - amount, + }) + ) + await transaction.update( + commentDoc, + removeUndefinedProps({ + bountiesAwarded: (comment.bountiesAwarded ?? 0) + amount, + }) + ) + + return { ...result, comment, contract, user } + }) + if (res.txn?.id) { + const { comment, contract, user } = res + await createBountyNotification( + user, + comment.userId, + amount, + res.txn.id, + contract, + comment.id + ) + } + + return res +}) + +const firestore = admin.firestore() diff --git a/web/components/award-bounty-button.tsx b/web/components/award-bounty-button.tsx new file mode 100644 index 00000000..7a69cf15 --- /dev/null +++ b/web/components/award-bounty-button.tsx @@ -0,0 +1,46 @@ +import clsx from 'clsx' +import { ContractComment } from 'common/comment' +import { useUser } from 'web/hooks/use-user' +import { awardCommentBounty } from 'web/lib/firebase/api' +import { track } from 'web/lib/service/analytics' +import { Row } from './layout/row' +import { Contract } from 'common/contract' +import { TextButton } from 'web/components/text-button' +import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' +import { formatMoney } from 'common/util/format' + +export function AwardBountyButton(prop: { + comment: ContractComment + contract: Contract +}) { + const { comment, contract } = prop + + const me = useUser() + + const submit = () => { + const data = { + amount: COMMENT_BOUNTY_AMOUNT, + commentId: comment.id, + contractId: contract.id, + } + + awardCommentBounty(data) + .then((_) => { + console.log('success') + track('award comment bounty', data) + }) + .catch((reason) => console.log('Server error:', reason)) + + track('award comment bounty', data) + } + + const canUp = me && me.id !== comment.userId && contract.creatorId === me.id + if (!canUp) return <div /> + return ( + <Row className={clsx('-ml-2 items-center gap-0.5', !canUp ? '-ml-6' : '')}> + <TextButton className={'font-bold'} onClick={submit}> + Award {formatMoney(COMMENT_BOUNTY_AMOUNT)} + </TextButton> + </Row> + ) +} diff --git a/web/components/contract/add-comment-bounty.tsx b/web/components/contract/add-comment-bounty.tsx new file mode 100644 index 00000000..8b716e71 --- /dev/null +++ b/web/components/contract/add-comment-bounty.tsx @@ -0,0 +1,74 @@ +import { Contract } from 'common/contract' +import { useUser } from 'web/hooks/use-user' +import { useState } from 'react' +import { addCommentBounty } from 'web/lib/firebase/api' +import { track } from 'web/lib/service/analytics' +import { Row } from 'web/components/layout/row' +import clsx from 'clsx' +import { formatMoney } from 'common/util/format' +import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' +import { Button } from 'web/components/button' + +export function AddCommentBountyPanel(props: { contract: Contract }) { + const { contract } = props + const { id: contractId, slug } = contract + + const user = useUser() + const amount = COMMENT_BOUNTY_AMOUNT + const totalAdded = contract.openCommentBounties ?? 0 + const [error, setError] = useState<string | undefined>(undefined) + const [isSuccess, setIsSuccess] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + const submit = () => { + if ((user?.balance ?? 0) < amount) { + setError('Insufficient balance') + return + } + + setIsLoading(true) + setIsSuccess(false) + + addCommentBounty({ amount, contractId }) + .then((_) => { + track('offer comment bounty', { + amount, + contractId, + }) + setIsSuccess(true) + setError(undefined) + setIsLoading(false) + }) + .catch((_) => setError('Server error')) + + track('add comment bounty', { amount, contractId, slug }) + } + + return ( + <> + <div className="mb-4 text-gray-500"> + Add a {formatMoney(amount)} bounty for good comments that the creator + can award.{' '} + {totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`} + </div> + + <Row className={'items-center gap-2'}> + <Button + className={clsx('ml-2', isLoading && 'btn-disabled')} + onClick={submit} + disabled={isLoading} + color={'blue'} + > + Add {formatMoney(amount)} bounty + </Button> + <span className={'text-error'}>{error}</span> + </Row> + + {isSuccess && amount && ( + <div>Success! Added {formatMoney(amount)} in bounties.</div> + )} + + {isLoading && <div>Processing...</div>} + </> + ) +} diff --git a/web/components/contract/bountied-contract-badge.tsx b/web/components/contract/bountied-contract-badge.tsx new file mode 100644 index 00000000..8e3e8c5b --- /dev/null +++ b/web/components/contract/bountied-contract-badge.tsx @@ -0,0 +1,9 @@ +import { CurrencyDollarIcon } from '@heroicons/react/outline' + +export function BountiedContractBadge() { + return ( + <span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-0.5 text-sm font-medium text-blue-800"> + <CurrencyDollarIcon className={'h4 w-4'} /> Bounty + </span> + ) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index fc4bcfcf..22167a9c 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -32,6 +32,7 @@ import { PlusCircleIcon } from '@heroicons/react/solid' import { GroupLink } from 'common/group' import { Subtitle } from '../subtitle' import { useIsMobile } from 'web/hooks/use-is-mobile' +import { BountiedContractBadge } from 'web/components/contract/bountied-contract-badge' export type ShowTime = 'resolve-date' | 'close-date' @@ -63,6 +64,8 @@ export function MiscDetails(props: { </Row> ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( <FeaturedContractBadge /> + ) : (contract.openCommentBounties ?? 0) > 0 ? ( + <BountiedContractBadge /> ) : volume > 0 || !isNew ? ( <Row className={'shrink-0'}>{formatMoney(volume)} bet</Row> ) : ( diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 5187030d..df6695ed 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -7,7 +7,7 @@ import { capitalize } from 'lodash' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' import { contractPool, updateContract } from 'web/lib/firebase/contracts' -import { LiquidityPanel } from '../liquidity-panel' +import { LiquidityBountyPanel } from 'web/components/contract/liquidity-bounty-panel' import { Col } from '../layout/col' import { Modal } from '../layout/modal' import { Title } from '../title' @@ -196,9 +196,7 @@ export function ContractInfoDialog(props: { <Row className="flex-wrap"> <DuplicateContractButton contract={contract} /> </Row> - {contract.mechanism === 'cpmm-1' && !contract.resolution && ( - <LiquidityPanel contract={contract} /> - )} + {!contract.resolution && <LiquidityBountyPanel contract={contract} />} </Col> </Modal> </> diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 33a3c05a..e53881d3 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -5,7 +5,7 @@ import { FeedBet } from '../feed/feed-bets' import { FeedLiquidity } from '../feed/feed-liquidity' import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group' import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments' -import { groupBy, sortBy } from 'lodash' +import { groupBy, sortBy, sum } from 'lodash' import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { PAST_BETS } from 'common/user' @@ -25,6 +25,13 @@ import { import { buildArray } from 'common/util/array' import { ContractComment } from 'common/comment' +import { formatMoney } from 'common/util/format' +import { Button } from 'web/components/button' +import { MINUTE_MS } from 'common/util/time' +import { useUser } from 'web/hooks/use-user' +import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' +import { Tooltip } from 'web/components/tooltip' + export function ContractTabs(props: { contract: Contract bets: Bet[] @@ -32,6 +39,7 @@ export function ContractTabs(props: { comments: ContractComment[] }) { const { contract, bets, userBets, comments } = props + const { openCommentBounties } = contract const yourTrades = ( <div> @@ -43,8 +51,16 @@ export function ContractTabs(props: { const tabs = buildArray( { - title: 'Comments', + title: `Comments`, + tooltip: openCommentBounties + ? `The creator of this market may award ${formatMoney( + COMMENT_BOUNTY_AMOUNT + )} for good comments. ${formatMoney( + openCommentBounties + )} currently available.` + : undefined, content: <CommentsTabContent contract={contract} comments={comments} />, + inlineTabIcon: <span>({formatMoney(COMMENT_BOUNTY_AMOUNT)})</span>, }, { title: capitalize(PAST_BETS), @@ -68,6 +84,8 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { const { contract } = props const tips = useTipTxns({ contractId: contract.id }) const comments = useComments(contract.id) ?? props.comments + const [sort, setSort] = useState<'Newest' | 'Best'>('Best') + const me = useUser() if (comments == null) { return <LoadingIndicator /> } @@ -119,12 +137,44 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { </> ) } else { - const commentsByParent = groupBy(comments, (c) => c.replyToCommentId ?? '_') + const tipsOrBountiesAwarded = + Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded) + + const commentsByParent = groupBy( + sortBy(comments, (c) => + sort === 'Newest' + ? -c.createdTime + : // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score + tipsOrBountiesAwarded && + c.createdTime > Date.now() - 10 * MINUTE_MS && + c.userId === me?.id + ? -Infinity + : -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? []))) + ), + (c) => c.replyToCommentId ?? '_' + ) + const topLevelComments = commentsByParent['_'] ?? [] return ( <> + <Button + size={'xs'} + color={'gray-white'} + className="mb-4" + onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')} + > + <Tooltip + text={ + sort === 'Best' + ? 'Comments with tips or bounties will be shown first. Your comments made within the last 10 minutes will temporarily appear (to you) first.' + : '' + } + > + Sorted by: {sort} + </Tooltip> + </Button> <ContractCommentInput className="mb-5" contract={contract} /> - {sortBy(topLevelComments, (c) => -c.createdTime).map((parent) => ( + {topLevelComments.map((parent) => ( <FeedCommentThread key={parent.id} contract={contract} diff --git a/web/components/liquidity-panel.tsx b/web/components/contract/liquidity-bounty-panel.tsx similarity index 77% rename from web/components/liquidity-panel.tsx rename to web/components/contract/liquidity-bounty-panel.tsx index 7e216be5..4cc7fd70 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/contract/liquidity-bounty-panel.tsx @@ -1,27 +1,30 @@ import clsx from 'clsx' import { useEffect, useState } from 'react' -import { CPMMContract } from 'common/contract' +import { Contract, CPMMContract } from 'common/contract' import { formatMoney } from 'common/util/format' import { useUser } from 'web/hooks/use-user' import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api' -import { AmountInput } from './amount-input' -import { Row } from './layout/row' +import { AmountInput } from 'web/components/amount-input' +import { Row } from 'web/components/layout/row' import { useUserLiquidity } from 'web/hooks/use-liquidity' -import { Tabs } from './layout/tabs' -import { NoLabel, YesLabel } from './outcome-label' -import { Col } from './layout/col' +import { Tabs } from 'web/components/layout/tabs' +import { NoLabel, YesLabel } from 'web/components/outcome-label' +import { Col } from 'web/components/layout/col' import { track } from 'web/lib/service/analytics' -import { InfoTooltip } from './info-tooltip' +import { InfoTooltip } from 'web/components/info-tooltip' import { BETTORS, PRESENT_BET } from 'common/user' import { buildArray } from 'common/util/array' import { useAdmin } from 'web/hooks/use-admin' +import { AddCommentBountyPanel } from 'web/components/contract/add-comment-bounty' -export function LiquidityPanel(props: { contract: CPMMContract }) { +export function LiquidityBountyPanel(props: { contract: Contract }) { const { contract } = props + const isCPMM = contract.mechanism === 'cpmm-1' const user = useUser() - const lpShares = useUserLiquidity(contract, user?.id ?? '') + // eslint-disable-next-line react-hooks/rules-of-hooks + const lpShares = isCPMM && useUserLiquidity(contract, user?.id ?? '') const [showWithdrawal, setShowWithdrawal] = useState(false) @@ -33,28 +36,34 @@ export function LiquidityPanel(props: { contract: CPMMContract }) { const isCreator = user?.id === contract.creatorId const isAdmin = useAdmin() - if (!showWithdrawal && !isAdmin && !isCreator) return <></> - return ( <Tabs tabs={buildArray( - (isCreator || isAdmin) && { - title: (isAdmin ? '[Admin] ' : '') + 'Subsidize', - content: <AddLiquidityPanel contract={contract} />, - }, - showWithdrawal && { - title: 'Withdraw', - content: ( - <WithdrawLiquidityPanel - contract={contract} - lpShares={lpShares as { YES: number; NO: number }} - /> - ), - }, { - title: 'Pool', - content: <ViewLiquidityPanel contract={contract} />, - } + title: 'Bounty Comments', + content: <AddCommentBountyPanel contract={contract} />, + }, + (isCreator || isAdmin) && + isCPMM && { + title: (isAdmin ? '[Admin] ' : '') + 'Subsidize', + content: <AddLiquidityPanel contract={contract} />, + }, + showWithdrawal && + isCPMM && { + title: 'Withdraw', + content: ( + <WithdrawLiquidityPanel + contract={contract} + lpShares={lpShares as { YES: number; NO: number }} + /> + ), + }, + + (isCreator || isAdmin) && + isCPMM && { + title: 'Pool', + content: <ViewLiquidityPanel contract={contract} />, + } )} /> ) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 1b62690b..20d124f8 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -19,6 +19,7 @@ import { Content } from '../editor' import { Editor } from '@tiptap/react' import { UserLink } from 'web/components/user-link' import { CommentInput } from '../comment-input' +import { AwardBountyButton } from 'web/components/award-bounty-button' export type ReplyTo = { id: string; username: string } @@ -85,6 +86,7 @@ export function FeedComment(props: { commenterPositionShares, commenterPositionOutcome, createdTime, + bountiesAwarded, } = comment const betOutcome = comment.betOutcome let bought: string | undefined @@ -93,6 +95,7 @@ export function FeedComment(props: { bought = comment.betAmount >= 0 ? 'bought' : 'sold' money = formatMoney(Math.abs(comment.betAmount)) } + const totalAwarded = bountiesAwarded ?? 0 const router = useRouter() const highlighted = router.asPath.endsWith(`#${comment.id}`) @@ -162,6 +165,11 @@ export function FeedComment(props: { createdTime={createdTime} elementId={comment.id} /> + {totalAwarded > 0 && ( + <span className=" text-primary ml-2 text-sm"> + +{formatMoney(totalAwarded)} + </span> + )} </div> <Content className="mt-2 text-[15px] text-gray-700" @@ -170,6 +178,9 @@ export function FeedComment(props: { /> <Row className="mt-2 items-center gap-6 text-xs text-gray-500"> {tips && <Tipper comment={comment} tips={tips} />} + {(contract.openCommentBounties ?? 0) > 0 && ( + <AwardBountyButton comment={comment} contract={contract} /> + )} {onReplyClick && ( <button className="font-bold hover:underline" @@ -208,28 +219,32 @@ export function ContractCommentInput(props: { onSubmitComment?: () => void }) { const user = useUser() + const { contract, parentAnswerOutcome, parentCommentId, replyTo, className } = + props + const { openCommentBounties } = contract async function onSubmitComment(editor: Editor) { if (!user) { track('sign in to comment') return await firebaseLogin() } await createCommentOnContract( - props.contract.id, + contract.id, editor.getJSON(), user, - props.parentAnswerOutcome, - props.parentCommentId + !!openCommentBounties, + parentAnswerOutcome, + parentCommentId ) props.onSubmitComment?.() } return ( <CommentInput - replyTo={props.replyTo} - parentAnswerOutcome={props.parentAnswerOutcome} - parentCommentId={props.parentCommentId} + replyTo={replyTo} + parentAnswerOutcome={parentAnswerOutcome} + parentCommentId={parentCommentId} onSubmitComment={onSubmitComment} - className={props.className} + className={className} /> ) } diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index b82131ec..deff2203 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -3,13 +3,15 @@ import { useRouter, NextRouter } from 'next/router' import { ReactNode, useState } from 'react' import { track } from '@amplitude/analytics-browser' import { Col } from './col' +import { Tooltip } from 'web/components/tooltip' +import { Row } from 'web/components/layout/row' type Tab = { title: string - tabIcon?: ReactNode content: ReactNode - // If set, show a badge with this content - badge?: string + stackedTabIcon?: ReactNode + inlineTabIcon?: ReactNode + tooltip?: string } type TabProps = { @@ -56,12 +58,16 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) { )} aria-current={activeIndex === i ? 'page' : undefined} > - {tab.badge ? ( - <span className="px-0.5 font-bold">{tab.badge}</span> - ) : null} <Col> - {tab.tabIcon && <div className="mx-auto">{tab.tabIcon}</div>} - {tab.title} + <Tooltip text={tab.tooltip}> + {tab.stackedTabIcon && ( + <Row className="justify-center">{tab.stackedTabIcon}</Row> + )} + <Row className={'gap-1 '}> + {tab.title} + {tab.inlineTabIcon} + </Row> + </Tooltip> </Col> </a> ))} diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index ccb8361f..1dcb0f05 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -116,7 +116,7 @@ function DownTip(props: { onClick?: () => void }) { noTap > <button - className="hover:text-red-600 disabled:text-gray-300" + className="hover:text-red-600 disabled:text-gray-100" disabled={!onClick} onClick={onClick} > @@ -137,7 +137,7 @@ function UpTip(props: { onClick?: () => void; value: number }) { noTap > <button - className="hover:text-primary disabled:text-gray-300" + className="hover:text-primary disabled:text-gray-100" disabled={!onClick} onClick={onClick} > diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 623b4d35..f9f77cf6 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -192,7 +192,7 @@ export function UserPage(props: { user: User }) { tabs={[ { title: 'Markets', - tabIcon: <ScaleIcon className="h-5" />, + stackedTabIcon: <ScaleIcon className="h-5" />, content: ( <> <Spacer h={4} /> @@ -202,7 +202,7 @@ export function UserPage(props: { user: User }) { }, { title: 'Portfolio', - tabIcon: <FolderIcon className="h-5" />, + stackedTabIcon: <FolderIcon className="h-5" />, content: ( <> <Spacer h={4} /> @@ -214,7 +214,7 @@ export function UserPage(props: { user: User }) { }, { title: 'Comments', - tabIcon: <ChatIcon className="h-5" />, + stackedTabIcon: <ChatIcon className="h-5" />, content: ( <> <Spacer h={4} /> diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index 8aa7a067..3e803bc6 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -46,6 +46,14 @@ export function addLiquidity(params: any) { return call(getFunctionUrl('addliquidity'), 'POST', params) } +export function addCommentBounty(params: any) { + return call(getFunctionUrl('addcommentbounty'), 'POST', params) +} + +export function awardCommentBounty(params: any) { + return call(getFunctionUrl('awardcommentbounty'), 'POST', params) +} + export function withdrawLiquidity(params: any) { return call(getFunctionUrl('withdrawliquidity'), 'POST', params) } diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index 733a1e06..e1b4ccef 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -35,6 +35,7 @@ export async function createCommentOnContract( contractId: string, content: JSONContent, user: User, + onContractWithBounty: boolean, answerOutcome?: string, replyToCommentId?: string ) { @@ -50,7 +51,8 @@ export async function createCommentOnContract( content, user, ref, - replyToCommentId + replyToCommentId, + onContractWithBounty ) } export async function createCommentOnGroup( @@ -95,7 +97,8 @@ async function createComment( content: JSONContent, user: User, ref: DocumentReference<DocumentData>, - replyToCommentId?: string + replyToCommentId?: string, + onContractWithBounty?: boolean ) { const comment = removeUndefinedProps({ id: ref.id, @@ -108,13 +111,19 @@ async function createComment( replyToCommentId: replyToCommentId, ...extraFields, }) - - track(`${extraFields.commentType} message`, { - user, - commentId: ref.id, - surfaceId, - replyToCommentId: replyToCommentId, - }) + track( + `${extraFields.commentType} message`, + removeUndefinedProps({ + user, + commentId: ref.id, + surfaceId, + replyToCommentId: replyToCommentId, + onContractWithBounty: + extraFields.commentType === 'contract' + ? onContractWithBounty + : undefined, + }) + ) return await setDoc(ref, comment) } diff --git a/web/package.json b/web/package.json index a3ec9aaa..a5fa8ced 100644 --- a/web/package.json +++ b/web/package.json @@ -22,7 +22,7 @@ "@amplitude/analytics-browser": "0.4.1", "@floating-ui/react-dom-interactions": "0.9.2", "@headlessui/react": "1.6.1", - "@heroicons/react": "1.0.5", + "@heroicons/react": "1.0.6", "@nivo/core": "0.80.0", "@nivo/line": "0.80.0", "@nivo/tooltip": "0.80.0",