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
|
userName: string
|
||||||
userUsername: string
|
userUsername: string
|
||||||
userAvatarUrl?: string
|
userAvatarUrl?: string
|
||||||
|
bountiesAwarded?: number
|
||||||
} & T
|
} & T
|
||||||
|
|
||||||
export type OnContract = {
|
export type OnContract = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 './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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
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 { 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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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} />,
|
||||||
}
|
}
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in New Issue
Block a user