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 4606984276.

* undo yarn.lock changes

* Track comment bounties
This commit is contained in:
Ian Philips 2022-09-30 09:27:42 -06:00 committed by GitHub
parent 55f854115c
commit a90b765670
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 648 additions and 95 deletions

View File

@ -18,6 +18,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
userName: string userName: string
userUsername: string userUsername: string
userAvatarUrl?: string userAvatarUrl?: string
bountiesAwarded?: number
} & T } & T
export type OnContract = { export type OnContract = {

View File

@ -62,6 +62,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
featuredOnHomeRank?: number featuredOnHomeRank?: number
likedByUserIds?: string[] likedByUserIds?: string[]
likedByUserCount?: number likedByUserCount?: number
openCommentBounties?: number
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary

View File

@ -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_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 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 FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250

View File

@ -41,6 +41,7 @@ export type Economy = {
BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number FREE_MARKETS_PER_USER_MAX?: number
COMMENT_BOUNTY_AMOUNT?: number
} }
type FirebaseConfig = { type FirebaseConfig = {

View File

@ -8,6 +8,7 @@ type AnyTxnType =
| UniqueBettorBonus | UniqueBettorBonus
| BettingStreakBonus | BettingStreakBonus
| CancelUniqueBettorBonus | CancelUniqueBettorBonus
| CommentBountyRefund
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = { export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -31,6 +32,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
| 'UNIQUE_BETTOR_BONUS' | 'UNIQUE_BETTOR_BONUS'
| 'BETTING_STREAK_BONUS' | 'BETTING_STREAK_BONUS'
| 'CANCEL_UNIQUE_BETTOR_BONUS' | 'CANCEL_UNIQUE_BETTOR_BONUS'
| 'COMMENT_BOUNTY'
| 'REFUND_COMMENT_BOUNTY'
// Any extra data // Any extra data
data?: { [key: string]: any } 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 DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink export type ManalinkTxn = Txn & Manalink
@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral
export type BettingStreakBonusTxn = Txn & BettingStreakBonus export type BettingStreakBonusTxn = Txn & BettingStreakBonus
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus
export type CommentBountyDepositTxn = Txn & CommentBountyDeposit
export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal

View File

@ -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
}

View File

@ -52,6 +52,7 @@ export * from './unsubscribe'
export * from './stripe' export * from './stripe'
export * from './mana-bonus-email' export * from './mana-bonus-email'
export * from './close-market' export * from './close-market'
export * from './update-comment-bounty'
import { health } from './health' import { health } from './health'
import { transact } from './transact' import { transact } from './transact'
@ -65,6 +66,7 @@ import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market' import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity' import { addliquidity } from './add-liquidity'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
import { withdrawliquidity } from './withdraw-liquidity' import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group' import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
@ -91,6 +93,8 @@ const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink) const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket) const createMarketFunction = toCloudFunction(createmarket)
const addLiquidityFunction = toCloudFunction(addliquidity) const addLiquidityFunction = toCloudFunction(addliquidity)
const addCommentBounty = toCloudFunction(addcommentbounty)
const awardCommentBounty = toCloudFunction(awardcommentbounty)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity) const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup) const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket) const resolveMarketFunction = toCloudFunction(resolvemarket)
@ -127,4 +131,6 @@ export {
acceptChallenge as acceptchallenge, acceptChallenge as acceptchallenge,
createPostFunction as createpost, createPostFunction as createpost,
saveTwitchCredentials as savetwitchcredentials, saveTwitchCredentials as savetwitchcredentials,
addCommentBounty as addcommentbounty,
awardCommentBounty as awardcommentbounty,
} }

View File

@ -1,33 +1,48 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getUser } from './utils' import { getUser, getValues, log } from './utils'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Contract } from '../../common/contract' 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 export const onUpdateContract = functions.firestore
.document('contracts/{contractId}') .document('contracts/{contractId}')
.onUpdate(async (change, context) => { .onUpdate(async (change, context) => {
const contract = change.after.data() as Contract const contract = change.after.data() as Contract
const previousContract = change.before.data() as Contract
const { eventId } = context const { eventId } = context
const { openCommentBounties, closeTime, question } = contract
if (
!previousContract.isResolved &&
contract.isResolved &&
(openCommentBounties ?? 0) > 0
) {
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) const contractUpdater = await getUser(contract.creatorId)
if (!contractUpdater) throw new Error('Could not find contract updater') 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
if (
previousValue.closeTime !== contract.closeTime ||
previousValue.question !== contract.question
) {
let sourceText = '' let sourceText = ''
if ( if (previousContract.closeTime !== contract.closeTime && contract.closeTime) {
previousValue.closeTime !== contract.closeTime &&
contract.closeTime
) {
sourceText = contract.closeTime.toString() sourceText = contract.closeTime.toString()
} else if (previousValue.question !== contract.question) { } else if (previousContract.question !== contract.question) {
sourceText = contract.question sourceText = contract.question
} }
@ -40,5 +55,64 @@ export const onUpdateContract = functions.firestore
sourceText, sourceText,
contract 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()

View File

@ -29,6 +29,7 @@ import { getcurrentuser } from './get-current-user'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials' import { savetwitchcredentials } from './save-twitch-credentials'
import { testscheduledfunction } from './test-scheduled-function' import { testscheduledfunction } from './test-scheduled-function'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express() const app = express()
@ -61,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares)
addJsonEndpointRoute('/claimmanalink', claimmanalink) addJsonEndpointRoute('/claimmanalink', claimmanalink)
addJsonEndpointRoute('/createmarket', createmarket) addJsonEndpointRoute('/createmarket', createmarket)
addJsonEndpointRoute('/addliquidity', addliquidity) addJsonEndpointRoute('/addliquidity', addliquidity)
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity) addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/resolvemarket', resolvemarket)

View File

@ -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()

View File

@ -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>
)
}

View File

@ -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>}
</>
)
}

View File

@ -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>
)
}

View File

@ -32,6 +32,7 @@ import { PlusCircleIcon } from '@heroicons/react/solid'
import { GroupLink } from 'common/group' import { GroupLink } from 'common/group'
import { Subtitle } from '../subtitle' import { Subtitle } from '../subtitle'
import { useIsMobile } from 'web/hooks/use-is-mobile' import { useIsMobile } from 'web/hooks/use-is-mobile'
import { BountiedContractBadge } from 'web/components/contract/bountied-contract-badge'
export type ShowTime = 'resolve-date' | 'close-date' export type ShowTime = 'resolve-date' | 'close-date'
@ -63,6 +64,8 @@ export function MiscDetails(props: {
</Row> </Row>
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
<FeaturedContractBadge /> <FeaturedContractBadge />
) : (contract.openCommentBounties ?? 0) > 0 ? (
<BountiedContractBadge />
) : volume > 0 || !isNew ? ( ) : volume > 0 || !isNew ? (
<Row className={'shrink-0'}>{formatMoney(volume)} bet</Row> <Row className={'shrink-0'}>{formatMoney(volume)} bet</Row>
) : ( ) : (

View File

@ -7,7 +7,7 @@ import { capitalize } from 'lodash'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { contractPool, updateContract } from 'web/lib/firebase/contracts' 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 { Col } from '../layout/col'
import { Modal } from '../layout/modal' import { Modal } from '../layout/modal'
import { Title } from '../title' import { Title } from '../title'
@ -196,9 +196,7 @@ export function ContractInfoDialog(props: {
<Row className="flex-wrap"> <Row className="flex-wrap">
<DuplicateContractButton contract={contract} /> <DuplicateContractButton contract={contract} />
</Row> </Row>
{contract.mechanism === 'cpmm-1' && !contract.resolution && ( {!contract.resolution && <LiquidityBountyPanel contract={contract} />}
<LiquidityPanel contract={contract} />
)}
</Col> </Col>
</Modal> </Modal>
</> </>

View File

@ -5,7 +5,7 @@ import { FeedBet } from '../feed/feed-bets'
import { FeedLiquidity } from '../feed/feed-liquidity' import { FeedLiquidity } from '../feed/feed-liquidity'
import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group' import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments' import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { groupBy, sortBy } from 'lodash' import { groupBy, sortBy, sum } from 'lodash'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { PAST_BETS } from 'common/user' import { PAST_BETS } from 'common/user'
@ -25,6 +25,13 @@ import {
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import { ContractComment } from 'common/comment' 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: { export function ContractTabs(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
@ -32,6 +39,7 @@ export function ContractTabs(props: {
comments: ContractComment[] comments: ContractComment[]
}) { }) {
const { contract, bets, userBets, comments } = props const { contract, bets, userBets, comments } = props
const { openCommentBounties } = contract
const yourTrades = ( const yourTrades = (
<div> <div>
@ -43,8 +51,16 @@ export function ContractTabs(props: {
const tabs = buildArray( 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} />, content: <CommentsTabContent contract={contract} comments={comments} />,
inlineTabIcon: <span>({formatMoney(COMMENT_BOUNTY_AMOUNT)})</span>,
}, },
{ {
title: capitalize(PAST_BETS), title: capitalize(PAST_BETS),
@ -68,6 +84,8 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
const { contract } = props const { contract } = props
const tips = useTipTxns({ contractId: contract.id }) const tips = useTipTxns({ contractId: contract.id })
const comments = useComments(contract.id) ?? props.comments const comments = useComments(contract.id) ?? props.comments
const [sort, setSort] = useState<'Newest' | 'Best'>('Best')
const me = useUser()
if (comments == null) { if (comments == null) {
return <LoadingIndicator /> return <LoadingIndicator />
} }
@ -119,12 +137,44 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
</> </>
) )
} else { } 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['_'] ?? [] const topLevelComments = commentsByParent['_'] ?? []
return ( 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} /> <ContractCommentInput className="mb-5" contract={contract} />
{sortBy(topLevelComments, (c) => -c.createdTime).map((parent) => ( {topLevelComments.map((parent) => (
<FeedCommentThread <FeedCommentThread
key={parent.id} key={parent.id}
contract={contract} contract={contract}

View File

@ -1,27 +1,30 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { CPMMContract } from 'common/contract' import { Contract, CPMMContract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api' import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api'
import { AmountInput } from './amount-input' import { AmountInput } from 'web/components/amount-input'
import { Row } from './layout/row' import { Row } from 'web/components/layout/row'
import { useUserLiquidity } from 'web/hooks/use-liquidity' import { useUserLiquidity } from 'web/hooks/use-liquidity'
import { Tabs } from './layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { NoLabel, YesLabel } from './outcome-label' import { NoLabel, YesLabel } from 'web/components/outcome-label'
import { Col } from './layout/col' import { Col } from 'web/components/layout/col'
import { track } from 'web/lib/service/analytics' 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 { BETTORS, PRESENT_BET } from 'common/user'
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import { useAdmin } from 'web/hooks/use-admin' 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 { contract } = props
const isCPMM = contract.mechanism === 'cpmm-1'
const user = useUser() 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) const [showWithdrawal, setShowWithdrawal] = useState(false)
@ -33,16 +36,20 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
const isCreator = user?.id === contract.creatorId const isCreator = user?.id === contract.creatorId
const isAdmin = useAdmin() const isAdmin = useAdmin()
if (!showWithdrawal && !isAdmin && !isCreator) return <></>
return ( return (
<Tabs <Tabs
tabs={buildArray( tabs={buildArray(
(isCreator || isAdmin) && { {
title: 'Bounty Comments',
content: <AddCommentBountyPanel contract={contract} />,
},
(isCreator || isAdmin) &&
isCPMM && {
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize', title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
content: <AddLiquidityPanel contract={contract} />, content: <AddLiquidityPanel contract={contract} />,
}, },
showWithdrawal && { showWithdrawal &&
isCPMM && {
title: 'Withdraw', title: 'Withdraw',
content: ( content: (
<WithdrawLiquidityPanel <WithdrawLiquidityPanel
@ -51,7 +58,9 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
/> />
), ),
}, },
{
(isCreator || isAdmin) &&
isCPMM && {
title: 'Pool', title: 'Pool',
content: <ViewLiquidityPanel contract={contract} />, content: <ViewLiquidityPanel contract={contract} />,
} }

View File

@ -19,6 +19,7 @@ import { Content } from '../editor'
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input' import { CommentInput } from '../comment-input'
import { AwardBountyButton } from 'web/components/award-bounty-button'
export type ReplyTo = { id: string; username: string } export type ReplyTo = { id: string; username: string }
@ -85,6 +86,7 @@ export function FeedComment(props: {
commenterPositionShares, commenterPositionShares,
commenterPositionOutcome, commenterPositionOutcome,
createdTime, createdTime,
bountiesAwarded,
} = comment } = comment
const betOutcome = comment.betOutcome const betOutcome = comment.betOutcome
let bought: string | undefined let bought: string | undefined
@ -93,6 +95,7 @@ export function FeedComment(props: {
bought = comment.betAmount >= 0 ? 'bought' : 'sold' bought = comment.betAmount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(comment.betAmount)) money = formatMoney(Math.abs(comment.betAmount))
} }
const totalAwarded = bountiesAwarded ?? 0
const router = useRouter() const router = useRouter()
const highlighted = router.asPath.endsWith(`#${comment.id}`) const highlighted = router.asPath.endsWith(`#${comment.id}`)
@ -162,6 +165,11 @@ export function FeedComment(props: {
createdTime={createdTime} createdTime={createdTime}
elementId={comment.id} elementId={comment.id}
/> />
{totalAwarded > 0 && (
<span className=" text-primary ml-2 text-sm">
+{formatMoney(totalAwarded)}
</span>
)}
</div> </div>
<Content <Content
className="mt-2 text-[15px] text-gray-700" 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"> <Row className="mt-2 items-center gap-6 text-xs text-gray-500">
{tips && <Tipper comment={comment} tips={tips} />} {tips && <Tipper comment={comment} tips={tips} />}
{(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} />
)}
{onReplyClick && ( {onReplyClick && (
<button <button
className="font-bold hover:underline" className="font-bold hover:underline"
@ -208,28 +219,32 @@ export function ContractCommentInput(props: {
onSubmitComment?: () => void onSubmitComment?: () => void
}) { }) {
const user = useUser() const user = useUser()
const { contract, parentAnswerOutcome, parentCommentId, replyTo, className } =
props
const { openCommentBounties } = contract
async function onSubmitComment(editor: Editor) { async function onSubmitComment(editor: Editor) {
if (!user) { if (!user) {
track('sign in to comment') track('sign in to comment')
return await firebaseLogin() return await firebaseLogin()
} }
await createCommentOnContract( await createCommentOnContract(
props.contract.id, contract.id,
editor.getJSON(), editor.getJSON(),
user, user,
props.parentAnswerOutcome, !!openCommentBounties,
props.parentCommentId parentAnswerOutcome,
parentCommentId
) )
props.onSubmitComment?.() props.onSubmitComment?.()
} }
return ( return (
<CommentInput <CommentInput
replyTo={props.replyTo} replyTo={replyTo}
parentAnswerOutcome={props.parentAnswerOutcome} parentAnswerOutcome={parentAnswerOutcome}
parentCommentId={props.parentCommentId} parentCommentId={parentCommentId}
onSubmitComment={onSubmitComment} onSubmitComment={onSubmitComment}
className={props.className} className={className}
/> />
) )
} }

View File

@ -3,13 +3,15 @@ import { useRouter, NextRouter } from 'next/router'
import { ReactNode, useState } from 'react' import { ReactNode, useState } from 'react'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { Col } from './col' import { Col } from './col'
import { Tooltip } from 'web/components/tooltip'
import { Row } from 'web/components/layout/row'
type Tab = { type Tab = {
title: string title: string
tabIcon?: ReactNode
content: ReactNode content: ReactNode
// If set, show a badge with this content stackedTabIcon?: ReactNode
badge?: string inlineTabIcon?: ReactNode
tooltip?: string
} }
type TabProps = { type TabProps = {
@ -56,12 +58,16 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
)} )}
aria-current={activeIndex === i ? 'page' : undefined} aria-current={activeIndex === i ? 'page' : undefined}
> >
{tab.badge ? (
<span className="px-0.5 font-bold">{tab.badge}</span>
) : null}
<Col> <Col>
{tab.tabIcon && <div className="mx-auto">{tab.tabIcon}</div>} <Tooltip text={tab.tooltip}>
{tab.stackedTabIcon && (
<Row className="justify-center">{tab.stackedTabIcon}</Row>
)}
<Row className={'gap-1 '}>
{tab.title} {tab.title}
{tab.inlineTabIcon}
</Row>
</Tooltip>
</Col> </Col>
</a> </a>
))} ))}

View File

@ -116,7 +116,7 @@ function DownTip(props: { onClick?: () => void }) {
noTap noTap
> >
<button <button
className="hover:text-red-600 disabled:text-gray-300" className="hover:text-red-600 disabled:text-gray-100"
disabled={!onClick} disabled={!onClick}
onClick={onClick} onClick={onClick}
> >
@ -137,7 +137,7 @@ function UpTip(props: { onClick?: () => void; value: number }) {
noTap noTap
> >
<button <button
className="hover:text-primary disabled:text-gray-300" className="hover:text-primary disabled:text-gray-100"
disabled={!onClick} disabled={!onClick}
onClick={onClick} onClick={onClick}
> >

View File

@ -192,7 +192,7 @@ export function UserPage(props: { user: User }) {
tabs={[ tabs={[
{ {
title: 'Markets', title: 'Markets',
tabIcon: <ScaleIcon className="h-5" />, stackedTabIcon: <ScaleIcon className="h-5" />,
content: ( content: (
<> <>
<Spacer h={4} /> <Spacer h={4} />
@ -202,7 +202,7 @@ export function UserPage(props: { user: User }) {
}, },
{ {
title: 'Portfolio', title: 'Portfolio',
tabIcon: <FolderIcon className="h-5" />, stackedTabIcon: <FolderIcon className="h-5" />,
content: ( content: (
<> <>
<Spacer h={4} /> <Spacer h={4} />
@ -214,7 +214,7 @@ export function UserPage(props: { user: User }) {
}, },
{ {
title: 'Comments', title: 'Comments',
tabIcon: <ChatIcon className="h-5" />, stackedTabIcon: <ChatIcon className="h-5" />,
content: ( content: (
<> <>
<Spacer h={4} /> <Spacer h={4} />

View File

@ -46,6 +46,14 @@ export function addLiquidity(params: any) {
return call(getFunctionUrl('addliquidity'), 'POST', params) 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) { export function withdrawLiquidity(params: any) {
return call(getFunctionUrl('withdrawliquidity'), 'POST', params) return call(getFunctionUrl('withdrawliquidity'), 'POST', params)
} }

View File

@ -35,6 +35,7 @@ export async function createCommentOnContract(
contractId: string, contractId: string,
content: JSONContent, content: JSONContent,
user: User, user: User,
onContractWithBounty: boolean,
answerOutcome?: string, answerOutcome?: string,
replyToCommentId?: string replyToCommentId?: string
) { ) {
@ -50,7 +51,8 @@ export async function createCommentOnContract(
content, content,
user, user,
ref, ref,
replyToCommentId replyToCommentId,
onContractWithBounty
) )
} }
export async function createCommentOnGroup( export async function createCommentOnGroup(
@ -95,7 +97,8 @@ async function createComment(
content: JSONContent, content: JSONContent,
user: User, user: User,
ref: DocumentReference<DocumentData>, ref: DocumentReference<DocumentData>,
replyToCommentId?: string replyToCommentId?: string,
onContractWithBounty?: boolean
) { ) {
const comment = removeUndefinedProps({ const comment = removeUndefinedProps({
id: ref.id, id: ref.id,
@ -108,13 +111,19 @@ async function createComment(
replyToCommentId: replyToCommentId, replyToCommentId: replyToCommentId,
...extraFields, ...extraFields,
}) })
track(
track(`${extraFields.commentType} message`, { `${extraFields.commentType} message`,
removeUndefinedProps({
user, user,
commentId: ref.id, commentId: ref.id,
surfaceId, surfaceId,
replyToCommentId: replyToCommentId, replyToCommentId: replyToCommentId,
onContractWithBounty:
extraFields.commentType === 'contract'
? onContractWithBounty
: undefined,
}) })
)
return await setDoc(ref, comment) return await setDoc(ref, comment)
} }

View File

@ -22,7 +22,7 @@
"@amplitude/analytics-browser": "0.4.1", "@amplitude/analytics-browser": "0.4.1",
"@floating-ui/react-dom-interactions": "0.9.2", "@floating-ui/react-dom-interactions": "0.9.2",
"@headlessui/react": "1.6.1", "@headlessui/react": "1.6.1",
"@heroicons/react": "1.0.5", "@heroicons/react": "1.0.6",
"@nivo/core": "0.80.0", "@nivo/core": "0.80.0",
"@nivo/line": "0.80.0", "@nivo/line": "0.80.0",
"@nivo/tooltip": "0.80.0", "@nivo/tooltip": "0.80.0",