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:
parent
55f854115c
commit
a90b765670
|
@ -18,6 +18,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
|
|||
userName: string
|
||||
userUsername: string
|
||||
userAvatarUrl?: string
|
||||
bountiesAwarded?: number
|
||||
} & T
|
||||
|
||||
export type OnContract = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -1,33 +1,48 @@
|
|||
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 { 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)
|
||||
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 = ''
|
||||
if (
|
||||
previousValue.closeTime !== contract.closeTime &&
|
||||
contract.closeTime
|
||||
) {
|
||||
if (previousContract.closeTime !== contract.closeTime && contract.closeTime) {
|
||||
sourceText = contract.closeTime.toString()
|
||||
} else if (previousValue.question !== contract.question) {
|
||||
} else if (previousContract.question !== contract.question) {
|
||||
sourceText = contract.question
|
||||
}
|
||||
|
||||
|
@ -41,4 +56,63 @@ export const onUpdateContract = functions.firestore
|
|||
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()
|
||||
|
|
|
@ -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)
|
||||
|
|
162
functions/src/update-comment-bounty.ts
Normal file
162
functions/src/update-comment-bounty.ts
Normal 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()
|
46
web/components/award-bounty-button.tsx
Normal file
46
web/components/award-bounty-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
74
web/components/contract/add-comment-bounty.tsx
Normal file
74
web/components/contract/add-comment-bounty.tsx
Normal 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>}
|
||||
</>
|
||||
)
|
||||
}
|
9
web/components/contract/bountied-contract-badge.tsx
Normal file
9
web/components/contract/bountied-contract-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,16 +36,20 @@ 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: 'Bounty Comments',
|
||||
content: <AddCommentBountyPanel contract={contract} />,
|
||||
},
|
||||
(isCreator || isAdmin) &&
|
||||
isCPMM && {
|
||||
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
|
||||
content: <AddLiquidityPanel contract={contract} />,
|
||||
},
|
||||
showWithdrawal && {
|
||||
showWithdrawal &&
|
||||
isCPMM && {
|
||||
title: 'Withdraw',
|
||||
content: (
|
||||
<WithdrawLiquidityPanel
|
||||
|
@ -51,7 +58,9 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
|
|||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
(isCreator || isAdmin) &&
|
||||
isCPMM && {
|
||||
title: 'Pool',
|
||||
content: <ViewLiquidityPanel contract={contract} />,
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>}
|
||||
<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>
|
||||
))}
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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`, {
|
||||
track(
|
||||
`${extraFields.commentType} message`,
|
||||
removeUndefinedProps({
|
||||
user,
|
||||
commentId: ref.id,
|
||||
surfaceId,
|
||||
replyToCommentId: replyToCommentId,
|
||||
onContractWithBounty:
|
||||
extraFields.commentType === 'contract'
|
||||
? onContractWithBounty
|
||||
: undefined,
|
||||
})
|
||||
)
|
||||
return await setDoc(ref, comment)
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue
Block a user