From a90b7656703a7e167c6e023044fb2ab387bca612 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 30 Sep 2022 09:27:42 -0600 Subject: [PATCH] Bounty comments (#944) * Adding, awarding, and sorting by bounties * Add notification for bounty award as tip * Fix merge * Wording * Allow adding in batches of m250 * import * imports * Style tabs * Refund unused bounties * Show curreantly available, reset open to 0 * Refactor * Rerun check prs * reset yarn.lock * Revert "reset yarn.lock" This reverts commit 4606984276821f403efd5ef7e1eca45b19ee4081. * undo yarn.lock changes * Track comment bounties --- common/comment.ts | 1 + common/contract.ts | 1 + common/economy.ts | 1 + common/envs/prod.ts | 1 + common/txn.ts | 33 ++++ functions/src/create-notification.ts | 44 +++++ functions/src/index.ts | 6 + functions/src/on-update-contract.ts | 134 +++++++++++---- functions/src/serve.ts | 3 + functions/src/update-comment-bounty.ts | 162 ++++++++++++++++++ web/components/award-bounty-button.tsx | 46 +++++ .../contract/add-comment-bounty.tsx | 74 ++++++++ .../contract/bountied-contract-badge.tsx | 9 + web/components/contract/contract-details.tsx | 3 + .../contract/contract-info-dialog.tsx | 6 +- web/components/contract/contract-tabs.tsx | 58 ++++++- .../liquidity-bounty-panel.tsx} | 63 ++++--- web/components/feed/feed-comments.tsx | 29 +++- web/components/layout/tabs.tsx | 22 ++- web/components/tipper.tsx | 4 +- web/components/user-page.tsx | 6 +- web/lib/firebase/api.ts | 8 + web/lib/firebase/comments.ts | 27 ++- web/package.json | 2 +- 24 files changed, 648 insertions(+), 95 deletions(-) create mode 100644 functions/src/update-comment-bounty.ts create mode 100644 web/components/award-bounty-button.tsx create mode 100644 web/components/contract/add-comment-bounty.tsx create mode 100644 web/components/contract/bountied-contract-badge.tsx rename web/components/{liquidity-panel.tsx => contract/liquidity-bounty-panel.tsx} (77%) 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 = { 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 = { 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 = { @@ -31,6 +32,8 @@ export type Txn = { | '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( + 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
+ return ( + + + Award {formatMoney(COMMENT_BOUNTY_AMOUNT)} + + + ) +} 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(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 ( + <> +
+ Add a {formatMoney(amount)} bounty for good comments that the creator + can award.{' '} + {totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`} +
+ + + + {error} + + + {isSuccess && amount && ( +
Success! Added {formatMoney(amount)} in bounties.
+ )} + + {isLoading &&
Processing...
} + + ) +} 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 ( + + Bounty + + ) +} 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: { ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( + ) : (contract.openCommentBounties ?? 0) > 0 ? ( + ) : volume > 0 || !isNew ? ( {formatMoney(volume)} bet ) : ( 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: { - {contract.mechanism === 'cpmm-1' && !contract.resolution && ( - - )} + {!contract.resolution && } 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 = (
@@ -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: , + inlineTabIcon: ({formatMoney(COMMENT_BOUNTY_AMOUNT)}), }, { 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 } @@ -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 ( <> + - {sortBy(topLevelComments, (c) => -c.createdTime).map((parent) => ( + {topLevelComments.map((parent) => ( - return ( , - }, - showWithdrawal && { - title: 'Withdraw', - content: ( - - ), - }, { - title: 'Pool', - content: , - } + title: 'Bounty Comments', + content: , + }, + (isCreator || isAdmin) && + isCPMM && { + title: (isAdmin ? '[Admin] ' : '') + 'Subsidize', + content: , + }, + showWithdrawal && + isCPMM && { + title: 'Withdraw', + content: ( + + ), + }, + + (isCreator || isAdmin) && + isCPMM && { + title: 'Pool', + content: , + } )} /> ) 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 && ( + + +{formatMoney(totalAwarded)} + + )}
{tips && } + {(contract.openCommentBounties ?? 0) > 0 && ( + + )} {onReplyClick && (