From 368da82f0ff1e77d75deb0ae342c7df9e3c87e83 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Mon, 18 Nov 2024 16:15:22 -0800 Subject: [PATCH 01/12] multi sweeps (wip) copy answers on create answer fixup --- backend/api/knowledge.md | 9 ++ backend/api/src/create-answer-cpmm.ts | 103 ++++++++-------- backend/api/src/create-cash-contract.ts | 7 +- backend/api/src/create-market.ts | 84 +------------ backend/scripts/create-cash-contract.ts | 4 +- backend/shared/src/create-cash-contract.ts | 113 ++++++++++++++---- backend/shared/src/create-contract-helpers.ts | 87 ++++++++++++++ 7 files changed, 253 insertions(+), 154 deletions(-) create mode 100644 backend/shared/src/create-contract-helpers.ts diff --git a/backend/api/knowledge.md b/backend/api/knowledge.md index 932ab5c3b6..5e13991287 100644 --- a/backend/api/knowledge.md +++ b/backend/api/knowledge.md @@ -9,6 +9,15 @@ This directory contains the implementation of various API endpoints for the Mani - We use Supabase for database operations. - Authentication is handled using the `APIHandler` type, which automatically manages user authentication based on the schema definition. +## Mana/Sweepstakes Market Relationships + +- Mana markets can have sweepstakes counterpart markets (siblingContractId) +- The mana market is the source of truth - changes to mana markets should propagate to their sweepstakes counterparts +- Changes that need to propagate include: + - Adding new answers to multiple choice markets + - Market metadata updates + - Market resolution + ## Adding a New API Endpoint To add a new API endpoint, follow these steps: diff --git a/backend/api/src/create-answer-cpmm.ts b/backend/api/src/create-answer-cpmm.ts index 16cd872179..d366d223a0 100644 --- a/backend/api/src/create-answer-cpmm.ts +++ b/backend/api/src/create-answer-cpmm.ts @@ -53,37 +53,49 @@ export const createAnswerCPMM: APIHandler<'market/:contractId/answer'> = async ( ) => { const { contractId, text } = props return await betsQueue.enqueueFn( - () => createAnswerCpmmMain(contractId, text, auth.uid), + () => createAnswerCpmmFull(contractId, text, auth.uid), [contractId, auth.uid] ) } - -export const createAnswerCpmmMain = async ( +const createAnswerCpmmFull = async ( contractId: string, text: string, - creatorId: string, - options: { - overrideAddAnswersMode?: add_answers_mode - specialLiquidityPerAnswer?: number - loverUserId?: string - } = {} + userId: string ) => { - const { overrideAddAnswersMode, specialLiquidityPerAnswer, loverUserId } = - options log('Received ' + contractId + ' ' + text) + const contract = await verifyContract(contractId, userId) + const response = await createAnswerCpmmMain(contract, text, userId) + + // copy answer if this is sweeps question + if (contract.siblingContractId) { + const cashContract = await getContractSupabase(contract.siblingContractId) + if (!cashContract) throw new APIError(500, 'Cash contract not found') + await createAnswerCpmmMain(cashContract as any, text, userId) + // ignore continuation of sweepstakes answer, since don't need to notify twice + } + + return response +} +const verifyContract = async (contractId: string, creatorId: string) => { const contract = await getContractSupabase(contractId) if (!contract) throw new APIError(404, 'Contract not found') + if (contract.token !== 'MANA') { + throw new APIError( + 403, + 'Must add answer to the mana version of the contract' + ) + } if (contract.mechanism !== 'cpmm-multi-1') throw new APIError(403, 'Requires a cpmm multiple choice contract') if (contract.outcomeType === 'NUMBER') throw new APIError(403, 'Cannot create new answers for numeric contracts') - const { closeTime, shouldAnswersSumToOne } = contract + const { closeTime } = contract if (closeTime && Date.now() > closeTime) throw new APIError(403, 'Trading is closed') - const addAnswersMode = overrideAddAnswersMode ?? contract.addAnswersMode + const addAnswersMode = contract.addAnswersMode if (!addAnswersMode || addAnswersMode === 'DISABLED') { throw new APIError(400, 'Adding answers is disabled') @@ -97,6 +109,16 @@ export const createAnswerCpmmMain = async ( throw new APIError(403, 'Only the creator or an admin can create an answer') } + return contract +} + +const createAnswerCpmmMain = async ( + contract: Awaited>, + text: string, + creatorId: string +) => { + const { shouldAnswersSumToOne } = contract + const answerCost = getTieredAnswerCost( getTierFromLiquidity(contract, contract.totalLiquidity) ) @@ -107,17 +129,15 @@ export const createAnswerCpmmMain = async ( if (!user) throw new APIError(401, 'Your account was not found') if (user.isBannedFromPosting) throw new APIError(403, 'You are banned') - if (user.balance < answerCost && !specialLiquidityPerAnswer) + if (user.balance < answerCost) throw new APIError(403, 'Insufficient balance, need M' + answerCost) - if (!specialLiquidityPerAnswer) { - await incrementBalance(pgTrans, user.id, { - balance: -answerCost, - totalDeposits: -answerCost, - }) - } + await incrementBalance(pgTrans, user.id, { + balance: -answerCost, + totalDeposits: -answerCost, + }) - const answers = await getAnswersForContract(pgTrans, contractId) + const answers = await getAnswersForContract(pgTrans, contract.id) const unresolvedAnswers = answers.filter((a) => !a.resolution) const maxAnswers = getMaximumAnswers(shouldAnswersSumToOne) if (unresolvedAnswers.length >= maxAnswers) { @@ -132,25 +152,13 @@ export const createAnswerCpmmMain = async ( let totalLiquidity = answerCost let prob = 0.5 - if (specialLiquidityPerAnswer) { - if (shouldAnswersSumToOne) - throw new APIError( - 500, - "Can't specify specialLiquidityPerAnswer and shouldAnswersSumToOne" - ) - prob = 0.02 - poolYes = specialLiquidityPerAnswer - poolNo = specialLiquidityPerAnswer / (1 / prob - 1) - totalLiquidity = specialLiquidityPerAnswer - } - const id = randomString() const n = answers.length const createdTime = Date.now() const newAnswer: Answer = removeUndefinedProps({ id, index: n, - contractId, + contractId: contract.id, createdTime, userId: user.id, text, @@ -161,7 +169,6 @@ export const createAnswerCpmmMain = async ( totalLiquidity, subsidyPool: 0, probChanges: { day: 0, week: 0, month: 0 }, - loverUserId, }) const updatedAnswers: Answer[] = [] @@ -180,21 +187,19 @@ export const createAnswerCpmmMain = async ( await insertAnswer(pgTrans, newAnswer) } - if (!specialLiquidityPerAnswer) { - await updateContract(pgTrans, contractId, { - totalLiquidity: FieldVal.increment(answerCost), - }) + await updateContract(pgTrans, contract.id, { + totalLiquidity: FieldVal.increment(answerCost), + }) - const lp = getCpmmInitialLiquidity( - user.id, - contract, - answerCost, - createdTime, - newAnswer.id - ) + const lp = getCpmmInitialLiquidity( + user.id, + contract, + answerCost, + createdTime, + newAnswer.id + ) - await insertLiquidity(pgTrans, lp) - } + await insertLiquidity(pgTrans, lp) return { newAnswer, updatedAnswers, user } } @@ -208,7 +213,7 @@ export const createAnswerCpmmMain = async ( contract ) const pg = createSupabaseDirectClient() - await followContractInternal(pg, contractId, true, creatorId) + await followContractInternal(pg, contract.id, true, creatorId) } return { result: { newAnswerId: newAnswer.id }, continue: continuation } } diff --git a/backend/api/src/create-cash-contract.ts b/backend/api/src/create-cash-contract.ts index 793c3f3d5c..e7a1ee684d 100644 --- a/backend/api/src/create-cash-contract.ts +++ b/backend/api/src/create-cash-contract.ts @@ -15,6 +15,11 @@ export const createCashContract: APIHandler<'create-cash-contract'> = async ( 'Only Manifold team members can create cash contracts' ) - const contract = await createCashContractMain(manaContractId, subsidyAmount) + const contract = await createCashContractMain( + manaContractId, + subsidyAmount, + auth.uid + ) + return toLiteMarket(contract) } diff --git a/backend/api/src/create-market.ts b/backend/api/src/create-market.ts index 3ea007a741..9e2c8665bb 100644 --- a/backend/api/src/create-market.ts +++ b/backend/api/src/create-market.ts @@ -1,6 +1,4 @@ import { onCreateMarket } from 'api/helpers/on-create-market' -import { getNewLiquidityProvision } from 'common/add-liquidity' -import { getCpmmInitialLiquidity } from 'common/antes' import { createBinarySchema, createBountySchema, @@ -12,9 +10,6 @@ import { } from 'common/api/market-types' import { ValidatedAPIParams } from 'common/api/schema' import { - BinaryContract, - CPMMMultiContract, - Contract, MULTI_NUMERIC_CREATION_ENABLED, NO_CLOSE_TIME_TYPES, OutcomeType, @@ -34,7 +29,6 @@ import { getCloseDate } from 'shared/helpers/openai-utils' import { generateContractEmbeddings, getContractsDirect, - updateContract, } from 'shared/supabase/contracts' import { SupabaseDirectClient, @@ -42,7 +36,6 @@ import { createSupabaseDirectClient, pgp, } from 'shared/supabase/init' -import { insertLiquidity } from 'shared/supabase/liquidity' import { anythingToRichText } from 'shared/tiptap' import { runTxnOutsideBetQueue } from 'shared/txn/run-txn' import { @@ -56,10 +49,11 @@ import { } from 'shared/websockets/helpers' import { APIError, AuthedUser, type APIHandler } from './helpers/endpoint' import { Row } from 'common/supabase/utils' -import { bulkInsertQuery, FieldVal } from 'shared/supabase/utils' +import { bulkInsertQuery } from 'shared/supabase/utils' import { z } from 'zod' import { answerToRow } from 'shared/supabase/answers' import { convertAnswer } from 'common/supabase/contracts' +import { generateAntes } from 'shared/create-contract-helpers' type Body = ValidatedAPIParams<'market'> @@ -490,77 +484,3 @@ async function getGroupCheckPermissions( return group } - -export async function generateAntes( - pg: SupabaseDirectClient, - providerId: string, - contract: Contract, - outcomeType: OutcomeType, - ante: number, - totalMarketCost: number -) { - if ( - contract.outcomeType === 'MULTIPLE_CHOICE' && - contract.mechanism === 'cpmm-multi-1' && - !contract.shouldAnswersSumToOne - ) { - const { answers } = contract - for (const answer of answers) { - const ante = Math.sqrt(answer.poolYes * answer.poolNo) - - const lp = getCpmmInitialLiquidity( - providerId, - contract, - ante, - contract.createdTime, - answer.id - ) - - await insertLiquidity(pg, lp) - } - } else if ( - outcomeType === 'BINARY' || - outcomeType === 'PSEUDO_NUMERIC' || - outcomeType === 'STONK' || - outcomeType === 'MULTIPLE_CHOICE' || - outcomeType === 'NUMBER' - ) { - const lp = getCpmmInitialLiquidity( - providerId, - contract as BinaryContract | CPMMMultiContract, - ante, - contract.createdTime - ) - - await insertLiquidity(pg, lp) - } - const drizzledAmount = totalMarketCost - ante - if ( - drizzledAmount > 0 && - (contract.mechanism === 'cpmm-1' || contract.mechanism === 'cpmm-multi-1') - ) { - return await pg.txIf(async (tx) => { - await runTxnOutsideBetQueue(tx, { - fromId: providerId, - amount: drizzledAmount, - toId: contract.id, - toType: 'CONTRACT', - category: 'ADD_SUBSIDY', - token: 'M$', - fromType: 'USER', - }) - const newLiquidityProvision = getNewLiquidityProvision( - providerId, - drizzledAmount, - contract - ) - - await insertLiquidity(tx, newLiquidityProvision) - - await updateContract(tx, contract.id, { - subsidyPool: FieldVal.increment(drizzledAmount), - totalLiquidity: FieldVal.increment(drizzledAmount), - }) - }) - } -} diff --git a/backend/scripts/create-cash-contract.ts b/backend/scripts/create-cash-contract.ts index db43d3f119..3d99940a3b 100644 --- a/backend/scripts/create-cash-contract.ts +++ b/backend/scripts/create-cash-contract.ts @@ -1,5 +1,6 @@ import { runScript } from './run-script' import { createCashContractMain } from '../shared/src/create-cash-contract' +import { HOUSE_LIQUIDITY_PROVIDER_ID } from 'common/antes' runScript(async () => { const manaContractId = process.argv[2] @@ -15,7 +16,8 @@ runScript(async () => { try { const cashContract = await createCashContractMain( manaContractId, - subsidyAmount + subsidyAmount, + HOUSE_LIQUIDITY_PROVIDER_ID ) console.log('Success ' + cashContract.id) } catch (error) { diff --git a/backend/shared/src/create-cash-contract.ts b/backend/shared/src/create-cash-contract.ts index 4012372242..824f98aae3 100644 --- a/backend/shared/src/create-cash-contract.ts +++ b/backend/shared/src/create-cash-contract.ts @@ -5,20 +5,26 @@ import { log, revalidateContractStaticProps, } from './utils' -import { runTxnFromBank } from './txn/run-txn' +import { runTxnOutsideBetQueue } from './txn/run-txn' import { APIError } from 'common/api/utils' import { updateContract } from './supabase/contracts' import { randomString } from 'common/util/random' import { getNewContract } from 'common/new-contract' -import { convertContract } from 'common/supabase/contracts' +import { convertAnswer, convertContract } from 'common/supabase/contracts' import { clamp } from 'lodash' import { runTransactionWithRetries } from './transact-with-retries' +import { answerToRow, getAnswersForContract } from './supabase/answers' +import { Answer } from 'common/answer' +import { bulkInsertQuery } from './supabase/utils' +import { pgp } from './supabase/init' +import { generateAntes } from 'shared/create-contract-helpers' // cribbed from backend/api/src/create-market.ts export async function createCashContractMain( manaContractId: string, - subsidyAmount: number + subsidyAmount: number, + myId: string ) { const { cashContract, manaContract } = await runTransactionWithRetries( async (tx) => { @@ -40,29 +46,52 @@ export async function createCashContractMain( ) } - if (manaContract.outcomeType !== 'BINARY') { + if (manaContract.siblingContractId) { throw new APIError( 400, - `Contract ${manaContractId} is not a binary contract` + `Contract ${manaContractId} already has a sweepstakes sibling contract ${manaContract.siblingContractId}` ) - - // TODO: Add support for multi } - if (manaContract.siblingContractId) { + if ( + manaContract.outcomeType !== 'BINARY' && + manaContract.outcomeType !== 'MULTIPLE_CHOICE' && + manaContract.outcomeType !== 'PSEUDO_NUMERIC' && + manaContract.outcomeType !== 'NUMBER' + ) { throw new APIError( 400, - `Contract ${manaContractId} already has a sweepstakes sibling contract ${manaContract.siblingContractId}` + `Cannot make sweepstakes question for ${manaContract.outcomeType} contract ${manaContractId}` ) } + let answers: Answer[] = [] + if (manaContract.outcomeType === 'MULTIPLE_CHOICE') { + if (manaContract.addAnswersMode === 'ANYONE') + throw new APIError( + 400, + `Cannot make sweepstakes question for free response contract` + ) + + answers = await getAnswersForContract(tx, manaContractId) + } + + const initialProb = + manaContract.mechanism === 'cpmm-1' + ? clamp(Math.round(manaContract.prob * 100), 1, 99) + : 50 + + const min = 'min' in manaContract ? manaContract.min : 0 + const max = 'max' in manaContract ? manaContract.max : 0 + const isLogScale = + 'isLogScale' in manaContract ? manaContract.isLogScale : false + const contract = getNewContract({ id: randomString(), ante: subsidyAmount, token: 'CASH', description: htmlToRichText('

'), - initialProb: clamp(Math.round(manaContract.prob * 100), 1, 99), - + initialProb, creator, slug: manaContract.slug + '--cash', question: manaContract.question, @@ -71,17 +100,49 @@ export async function createCashContractMain( visibility: manaContract.visibility, isTwitchContract: manaContract.isTwitchContract, - min: 0, - max: 0, - isLogScale: false, - answers: [], + min, + max, + isLogScale, + + answers: answers.filter((a) => !a.isOther).map((a) => a.text), // Other gets recreated + + ...(manaContract.outcomeType === 'MULTIPLE_CHOICE' + ? { + addAnswersMode: manaContract.addAnswersMode, + shouldAnswersSumToOne: manaContract.shouldAnswersSumToOne, + } + : {}), }) - const newRow = await tx.one( + // copy answer colors and set userId to subsidizer + const answersToInsert = + 'answers' in contract && + contract.answers?.map((a: Answer) => ({ + ...a, + userId: myId, + color: answers.find((b) => b.index === a.index)?.color, + })) + + const insertAnswersQuery = answersToInsert + ? bulkInsertQuery('answers', answersToInsert.map(answerToRow)) + : `select 1 where false` + + // TODO: initialize marke tier? + + const contractQuery = pgp.as.format( `insert into contracts (id, data, token) values ($1, $2, $3) returning *`, [contract.id, JSON.stringify(contract), contract.token] ) - const cashContract = convertContract(newRow) + + const [newContracts, newAnswers] = await tx.multi( + `${contractQuery}; + ${insertAnswersQuery};` + ) + + const cashContract = convertContract(newContracts[0]) + if (newAnswers.length > 0 && cashContract.mechanism === 'cpmm-multi-1') { + cashContract.answers = newAnswers.map(convertAnswer) + } // Set sibling contract IDs await updateContract(tx, manaContractId, { @@ -92,19 +153,29 @@ export async function createCashContractMain( }) // Add initial liquidity - await runTxnFromBank(tx, { - amount: subsidyAmount, - category: 'CREATE_CONTRACT_ANTE', + await runTxnOutsideBetQueue(tx, { + fromId: myId, + fromType: 'USER', toId: cashContract.id, toType: 'CONTRACT', - fromType: 'BANK', + amount: subsidyAmount, token: 'CASH', + category: 'CREATE_CONTRACT_ANTE', }) log( `Created cash contract ${cashContract.id} for mana contract ${manaContractId}` ) + await generateAntes( + tx, + myId, + cashContract, + contract.outcomeType, + subsidyAmount, + subsidyAmount + ) + return { cashContract, manaContract } } ) diff --git a/backend/shared/src/create-contract-helpers.ts b/backend/shared/src/create-contract-helpers.ts new file mode 100644 index 0000000000..7023be3002 --- /dev/null +++ b/backend/shared/src/create-contract-helpers.ts @@ -0,0 +1,87 @@ +import { getNewLiquidityProvision } from 'common/add-liquidity' +import { getCpmmInitialLiquidity } from 'common/antes' +import { + BinaryContract, + Contract, + CPMMMultiContract, + OutcomeType, +} from 'common/contract' +import { updateContract } from './supabase/contracts' +import { SupabaseDirectClient } from './supabase/init' +import { insertLiquidity } from './supabase/liquidity' +import { FieldVal } from './supabase/utils' +import { runTxnOutsideBetQueue } from './txn/run-txn' + +export async function generateAntes( + pg: SupabaseDirectClient, + providerId: string, + contract: Contract, + outcomeType: OutcomeType, + ante: number, + totalMarketCost: number +) { + if ( + contract.outcomeType === 'MULTIPLE_CHOICE' && + contract.mechanism === 'cpmm-multi-1' && + !contract.shouldAnswersSumToOne + ) { + const { answers } = contract + for (const answer of answers) { + const ante = Math.sqrt(answer.poolYes * answer.poolNo) + + const lp = getCpmmInitialLiquidity( + providerId, + contract, + ante, + contract.createdTime, + answer.id + ) + + await insertLiquidity(pg, lp) + } + } else if ( + outcomeType === 'BINARY' || + outcomeType === 'PSEUDO_NUMERIC' || + outcomeType === 'STONK' || + outcomeType === 'MULTIPLE_CHOICE' || + outcomeType === 'NUMBER' + ) { + const lp = getCpmmInitialLiquidity( + providerId, + contract as BinaryContract | CPMMMultiContract, + ante, + contract.createdTime + ) + + await insertLiquidity(pg, lp) + } + const drizzledAmount = totalMarketCost - ante + if ( + drizzledAmount > 0 && + (contract.mechanism === 'cpmm-1' || contract.mechanism === 'cpmm-multi-1') + ) { + return await pg.txIf(async (tx) => { + await runTxnOutsideBetQueue(tx, { + fromId: providerId, + amount: drizzledAmount, + toId: contract.id, + toType: 'CONTRACT', + category: 'ADD_SUBSIDY', + token: 'M$', + fromType: 'USER', + }) + const newLiquidityProvision = getNewLiquidityProvision( + providerId, + drizzledAmount, + contract + ) + + await insertLiquidity(tx, newLiquidityProvision) + + await updateContract(tx, contract.id, { + subsidyPool: FieldVal.increment(drizzledAmount), + totalLiquidity: FieldVal.increment(drizzledAmount), + }) + }) + } +} From 531e4ec429393e81be0743486ba15c6de23a09b4 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Thu, 21 Nov 2024 16:59:04 -0800 Subject: [PATCH 02/12] wip in progress --- backend/api/src/on-create-bet.ts | 22 +++++- .../api/src/on-create-comment-on-contract.ts | 9 +-- backend/shared/src/supabase/bets.ts | 17 +++-- web/components/comments/comment-actions.tsx | 2 +- web/components/comments/comment-header.tsx | 71 +++++++++-------- web/components/comments/comment-input.tsx | 8 +- web/components/comments/comment-thread.tsx | 1 - web/components/comments/comment.tsx | 8 +- web/components/comments/repost-modal.tsx | 8 +- .../contract/contract-info-dialog.tsx | 2 +- web/components/contract/contract-page.tsx | 7 ++ web/components/contract/contract-tabs.tsx | 7 +- web/components/feed/feed-bets.tsx | 15 +++- .../feed/scored-feed-repost-item.tsx | 76 ++++++++----------- web/components/outcome-label.tsx | 65 +++++++--------- web/hooks/use-answers.ts | 31 ++++++++ web/knowledge.md | 19 +++++ 17 files changed, 225 insertions(+), 143 deletions(-) create mode 100644 web/hooks/use-answers.ts diff --git a/backend/api/src/on-create-bet.ts b/backend/api/src/on-create-bet.ts index 208cbb3f97..e9ad2b1d0d 100644 --- a/backend/api/src/on-create-bet.ts +++ b/backend/api/src/on-create-bet.ts @@ -1,4 +1,9 @@ -import { log, getUsers, revalidateContractStaticProps } from 'shared/utils' +import { + log, + getUsers, + revalidateContractStaticProps, + getContract, +} from 'shared/utils' import { Bet, LimitBet } from 'common/bet' import { Contract } from 'common/contract' import { humanish, User } from 'common/user' @@ -257,7 +262,20 @@ const handleBetReplyToComment = async ( if (!comment) return - const allBetReplies = await getBetsRepliedToComment(pg, comment, contract.id) + const manaContract = + contract.token === 'CASH' + ? await getContract(pg, contract.siblingContractId!) + : contract + + if (!manaContract) return + + const allBetReplies = await getBetsRepliedToComment( + pg, + comment, + contract.id, + contract.siblingContractId + ) + const bets = filterDefined(allBetReplies) // This could potentially miss some bets if they're not replicated in time if (!bets.some((b) => b.id === bet.id)) bets.push(bet) diff --git a/backend/api/src/on-create-comment-on-contract.ts b/backend/api/src/on-create-comment-on-contract.ts index 7d86869ea4..68e0c5065b 100644 --- a/backend/api/src/on-create-comment-on-contract.ts +++ b/backend/api/src/on-create-comment-on-contract.ts @@ -16,6 +16,7 @@ import { import { insertModReport } from 'shared/create-mod-report' import { updateContract } from 'shared/supabase/contracts' import { followContractInternal } from 'api/follow-contract' +import { getAnswer } from 'shared/supabase/answers' export const onCreateCommentOnContract = async (props: { contract: Contract @@ -51,12 +52,8 @@ const getReplyInfo = async ( comment: ContractComment, contract: Contract ) => { - if ( - comment.answerOutcome && - contract.outcomeType === 'MULTIPLE_CHOICE' && - contract.answers - ) { - const answer = contract.answers.find((a) => a.id === comment.answerOutcome) + if (comment.answerOutcome && contract.outcomeType === 'MULTIPLE_CHOICE') { + const answer = await getAnswer(pg, comment.answerOutcome) const comments = await pg.manyOrNone( `select comment_id, user_id from contract_comments diff --git a/backend/shared/src/supabase/bets.ts b/backend/shared/src/supabase/bets.ts index b81deb77dc..cd7646248f 100644 --- a/backend/shared/src/supabase/bets.ts +++ b/backend/shared/src/supabase/bets.ts @@ -119,15 +119,20 @@ export const getBetsWithFilter = async ( export const getBetsRepliedToComment = async ( pg: SupabaseDirectClient, comment: ContractComment, - contractId: string + contractId: string, + siblingContractId?: string ) => { return await pg.map( `select * from contract_bets - where data->>'replyToCommentId' = $1 - and contract_id = $2 - and created_time>=$3 - `, - [comment.id, contractId, new Date(comment.createdTime).toISOString()], + where data->>'replyToCommentId' = $1 + and created_time>=$2 + and (contract_id = $3 or contract_id = $4)`, + [ + comment.id, + millisToTs(comment.createdTime), + contractId, + siblingContractId, + ], convertBet ) } diff --git a/web/components/comments/comment-actions.tsx b/web/components/comments/comment-actions.tsx index 1560f3bb9c..d5eaf0735e 100644 --- a/web/components/comments/comment-actions.tsx +++ b/web/components/comments/comment-actions.tsx @@ -63,7 +63,7 @@ export function CommentActions(props: { buttonClassName={'mr-1 min-w-[60px]'} /> )} - {user && liveContract.outcomeType === 'BINARY' && !isCashContract && ( + {user && liveContract.outcomeType === 'BINARY' && ( { track('bet intent', { diff --git a/web/components/comments/comment-header.tsx b/web/components/comments/comment-header.tsx index 6f1b4af9b2..ebab8a994b 100644 --- a/web/components/comments/comment-header.tsx +++ b/web/components/comments/comment-header.tsx @@ -47,6 +47,7 @@ import { CommentEditHistoryButton } from './comment-edit-history-button' import DropdownMenu from './dropdown-menu' import { EditCommentModal } from './edit-comment-modal' import { RepostModal } from './repost-modal' +import { type Answer } from 'common/answer' export function FeedCommentHeader(props: { comment: ContractComment @@ -118,9 +119,9 @@ export function FeedCommentHeader(props: { /> {' '} {' '} at {formatPercent(betLimitProb)} order @@ -129,9 +130,9 @@ export function FeedCommentHeader(props: { {bought} {money}{' '} @@ -147,16 +148,20 @@ export function FeedCommentHeader(props: { {/* Hide my status if replying to a bet, it's too much clutter*/} {!isReplyToBet && !inTimeline && ( - + {bought} {money} {shouldDisplayOutcome && ( <> {' '} of{' '} @@ -223,9 +228,9 @@ const getBoughtMoney = ( export function CommentReplyHeaderWithBet(props: { comment: ContractComment bet: Bet - liveContract: Contract + answers: Answer[] }) { - const { comment, bet, liveContract } = props + const { comment, bet, answers } = props const { outcome, answerId, amount, orderAmount, limitProb } = bet return ( ) } export function CommentReplyHeader(props: { comment: ContractComment - liveContract: Contract + answers: Answer[] hideBetHeader?: boolean }) { - const { comment, liveContract, hideBetHeader } = props + const { comment, answers, hideBetHeader } = props const { bettorName, bettorId, @@ -276,12 +281,11 @@ export function CommentReplyHeader(props: { betAmount={betAmount} betOrderAmount={betOrderAmount} betLimitProb={betLimitProb} - liveContract={liveContract} /> ) } - if (answerOutcome && 'answers' in liveContract) { - const answer = liveContract.answers.find((a) => a.id === answerOutcome) + if (answerOutcome) { + const answer = answers.find((a) => a.id === answerOutcome) if (answer) return } @@ -289,7 +293,7 @@ export function CommentReplyHeader(props: { } export function ReplyToBetRow(props: { - liveContract: Contract + contract: Pick commenterIsBettor: boolean betOutcome: string betAmount: number @@ -298,7 +302,7 @@ export function ReplyToBetRow(props: { bettorUsername?: string betOrderAmount?: number betLimitProb?: number - betAnswerId?: string + betAnswer?: Answer clearReply?: () => void }) { const { @@ -308,8 +312,8 @@ export function ReplyToBetRow(props: { bettorUsername, bettorName, bettorId, - betAnswerId, - liveContract: contract, + betAnswer, + contract, clearReply, betLimitProb, betOrderAmount, @@ -337,14 +341,14 @@ export function ReplyToBetRow(props: { {!commenterIsBettor && bettorId && ( )} {!commenterIsBettor && !bettorId && bettorName && bettorUsername && ( {' '} @@ -381,8 +385,8 @@ export function ReplyToBetRow(props: { {bought} {money} @@ -404,11 +408,11 @@ export function ReplyToBetRow(props: { } function CommentStatus(props: { - contract: Contract + contract: Pick + answer?: Answer comment: ContractComment }) { const { contract, comment } = props - const { resolution } = contract const { commenterPositionProb, commenterPositionOutcome, @@ -416,6 +420,9 @@ function CommentStatus(props: { commenterPositionShares, } = comment + // TODO: what to do here? get the answer? pass another answer in? + // casche on the comment? + if ( comment.betId == null && commenterPositionProb != null && @@ -425,10 +432,10 @@ function CommentStatus(props: { ) return ( <> - {resolution ? 'predicted ' : `predicts `} + predicted diff --git a/web/components/comments/comment-input.tsx b/web/components/comments/comment-input.tsx index e5ffb393e6..97644c23db 100644 --- a/web/components/comments/comment-input.tsx +++ b/web/components/comments/comment-input.tsx @@ -247,7 +247,7 @@ export function CommentInputTextArea(props: { export function ContractCommentInput(props: { playContract: Contract - liveContract: Contract + answers?: Answer[] autoFocus: boolean className?: string replyTo?: Answer | Bet @@ -261,7 +261,7 @@ export function ContractCommentInput(props: { }) { const { playContract, - liveContract, + answers, autoFocus, replyTo, parentCommentId, @@ -326,8 +326,8 @@ export function ContractCommentInput(props: { bettorId={replyTo.userId} betOrderAmount={replyTo.orderAmount} betLimitProb={replyTo.limitProb} - betAnswerId={replyTo.answerId} - liveContract={liveContract} + betAnswer={answers?.find((a) => a.id === replyTo.answerId)} + contract={playContract} clearReply={clearReply} /> ) : replyTo ? ( diff --git a/web/components/comments/comment-thread.tsx b/web/components/comments/comment-thread.tsx index 00569649ab..bc73074585 100644 --- a/web/components/comments/comment-thread.tsx +++ b/web/components/comments/comment-thread.tsx @@ -131,7 +131,6 @@ export function FeedCommentThread(props: { {replyToUserInfo && ( diff --git a/web/components/comments/repost-modal.tsx b/web/components/comments/repost-modal.tsx index abcd081cec..9829bfba4d 100644 --- a/web/components/comments/repost-modal.tsx +++ b/web/components/comments/repost-modal.tsx @@ -101,14 +101,11 @@ export const RepostModal = (props: { (bet ? ( ) : ( - + ))} @@ -160,7 +157,6 @@ export const RepostModal = (props: { autoFocus replyTo={bet} playContract={playContract} - liveContract={liveContract} trackingLocation={'contract page'} commentTypes={['repost']} onClearInput={() => setOpen(false)} diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index bed8627415..626ea76ac3 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -535,7 +535,7 @@ export function ContractInfoDialog(props: { {isAdmin || isTrusted ? ( ) : null} - {isAdmin && !playContract.siblingContractId && ( + {!playContract.siblingContractId && ( { if ( @@ -516,6 +522,7 @@ export function ContractPageContent(props: ContractParams) { a.id === answerId) + : undefined + } contract={contract} truncate="short" />{' '} @@ -232,6 +236,11 @@ export function BetStatusText(props: { ? getFormattedMappedValue(contract, probAfter) : getFormattedMappedValue(contract, limitProb ?? probAfter) + const answer = + contract.mechanism === 'cpmm-multi-1' + ? contract.answers?.find((a) => a.id === answerId) + : undefined + return (
{!inTimeline ? ( @@ -256,7 +265,7 @@ export function BetStatusText(props: { )}{' '} {' '} @@ -267,7 +276,7 @@ export function BetStatusText(props: { {bought} {money}{' '} {' '} diff --git a/web/components/feed/scored-feed-repost-item.tsx b/web/components/feed/scored-feed-repost-item.tsx index 187c24f15e..8f9cda5b45 100644 --- a/web/components/feed/scored-feed-repost-item.tsx +++ b/web/components/feed/scored-feed-repost-item.tsx @@ -170,54 +170,44 @@ function RepostLabel(props: { commenterIsBettor, repost, } = props - if (showTopLevelRow && creatorRepostedTheirComment) + if (!showTopLevelRow) return <> + + const dropdown = ( + + ) + + const header = bet && ( + + ) + + if (creatorRepostedTheirComment) { return ( - {bet && ( - - )} - + {header}i'' + {dropdown} ) - - if (showTopLevelRow && !creatorRepostedTheirComment) { - return ( - - - - - - {!commenterIsBettor && bet && ( - - )} - - ) } - return <> + + return ( + + + + {dropdown} + + {!commenterIsBettor && header} + + ) } export const BottomActionRow = (props: { diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index fb07a29b79..8c9ff38198 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -3,8 +3,8 @@ import { getProbability } from 'common/calculate' import { BinaryContract, Contract, - getMainBinaryMCAnswer, - MultiContract, + // getMainBinaryMCAnswer, + // MultiContract, OutcomeType, resolution, } from 'common/contract' @@ -12,12 +12,13 @@ import { formatLargeNumber, formatPercent } from 'common/util/format' import { Bet } from 'common/bet' import { STONK_NO, STONK_YES } from 'common/stonk' import { AnswerLabel } from './answers/answer-components' +import { Answer } from 'common/answer' export function OutcomeLabel(props: { - contract: Contract + contract: Pick outcome: resolution | string truncate: 'short' | 'long' | 'none' - answerId?: string + answer?: Answer pseudonym?: { YES: { pseudonymName: string @@ -29,9 +30,9 @@ export function OutcomeLabel(props: { } } }) { - const { outcome, contract, truncate, answerId, pseudonym } = props + const { outcome, contract, truncate, answer, pseudonym } = props const { outcomeType, mechanism } = contract - const mainBinaryMCAnswer = getMainBinaryMCAnswer(contract) + // const mainBinaryMCAnswer = getMainBinaryMCAnswer(contract) const { pseudonymName, pseudonymColor } = pseudonym?.[outcome as 'YES' | 'NO'] ?? {} @@ -51,18 +52,18 @@ export function OutcomeLabel(props: { ) } - if (mainBinaryMCAnswer && mechanism === 'cpmm-multi-1') { - return ( - - ) - } + // TODO: fix + // if (mainBinaryMCAnswer && mechanism === 'cpmm-multi-1') { + // return ( + // + // ) + // } if (outcomeType === 'PSEUDO_NUMERIC') return @@ -77,10 +78,10 @@ export function OutcomeLabel(props: { if (outcomeType === 'NUMBER') { return ( - {answerId && ( + {answer && ( @@ -93,10 +94,10 @@ export function OutcomeLabel(props: { if (outcomeType === 'MULTIPLE_CHOICE' && mechanism === 'cpmm-multi-1') { return ( - {answerId && ( + {answer && ( @@ -114,14 +115,7 @@ export function OutcomeLabel(props: { return <> } - return ( - - ) + return <>??? } export function BinaryOutcomeLabel(props: { outcome: resolution }) { @@ -165,21 +159,20 @@ export function BinaryContractOutcomeLabel(props: { } export function MultiOutcomeLabel(props: { - contract: MultiContract + answer: Answer resolution: string | 'CANCEL' | 'MKT' truncate: 'short' | 'long' | 'none' answerClassName?: string }) { - const { contract, resolution, truncate, answerClassName } = props + const { answer, resolution, truncate, answerClassName } = props if (resolution === 'CANCEL') return if (resolution === 'MKT' || resolution === 'CHOOSE_MULTIPLE') return - const chosen = contract.answers?.find((answer) => answer.id === resolution) return ( diff --git a/web/hooks/use-answers.ts b/web/hooks/use-answers.ts new file mode 100644 index 0000000000..e784737e99 --- /dev/null +++ b/web/hooks/use-answers.ts @@ -0,0 +1,31 @@ +import { useState } from 'react' +import { type Answer } from 'common/answer' +import { useApiSubscription } from './use-api-subscription' + +export function useLiveAnswers(contractId: string | undefined) { + const [answers, setAnswers] = useState([]) + + useApiSubscription({ + enabled: contractId != undefined, + topics: [ + `contract/${contractId}/new-answer`, + `contract/${contractId}/updated-answers`, + ], + onBroadcast: ({ data, topic }) => { + if (topic.endsWith('new-answer')) { + setAnswers((a) => [...a, data.answer as Answer]) + } else if (topic.endsWith('updated-answers')) { + const updates = data.answers as (Partial & { id: string })[] + setAnswers((a) => + a.map((a) => { + const u = updates.find((u) => u.id === a.id) + if (!u) return a + return { ...a, ...u } + }) + ) + } + }, + }) + + return answers +} diff --git a/web/knowledge.md b/web/knowledge.md index dadae0f072..8a6224d559 100644 --- a/web/knowledge.md +++ b/web/knowledge.md @@ -1,5 +1,24 @@ ## Design Principles +### Mana/Sweepstakes Market Pairs + +Markets can exist in both mana and sweepstakes versions, displayed together on the same page. When building UI components: +- Prefer passing specific data (like answer lists) rather than entire contract objects to reduce prop threading complexity +- Remember components may need to handle data from both market versions +- Consider which contract (mana or sweepstakes) owns the source of truth for shared data + +Component design patterns: +- Break components into small, focused pieces that handle one type of data +- Pass minimal props - e.g. pass answer objects instead of whole contracts +- For shared UI elements like answer displays, prefer passing the specific data needed (answer text, probability, etc.) rather than passing the entire contract +- Keep market-type-specific logic (mana vs sweepstakes) in container components, not shared display components + +Refactoring strategy for dual market support: +- Use grep to find all usages of a component before modifying it +- Start with leaf components that have fewer dependencies +- When threading props becomes complex, consider creating intermediate container components +- Extract market-specific logic into hooks or container components + ### Dark Mode and Component Consistency - Always consider dark mode when adding new UI components. Use color classes that respect the current theme (e.g., `text-ink-700 dark:text-ink-300` instead of fixed color classes). From f73627c0bfe03dfa557f2f11670bbecaf17dc97e Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Mon, 25 Nov 2024 16:19:57 -0800 Subject: [PATCH 03/12] more wip in progress --- backend/shared/src/websockets/helpers.ts | 8 ++-- web/components/comments/comment-header.tsx | 21 +++++---- web/components/comments/repost-modal.tsx | 6 +-- .../feed/scored-feed-repost-item.tsx | 2 +- web/hooks/use-answers.ts | 43 ++++++++++++++++++- 5 files changed, 62 insertions(+), 18 deletions(-) diff --git a/backend/shared/src/websockets/helpers.ts b/backend/shared/src/websockets/helpers.ts index 5a70d55dce..88f121aa9c 100644 --- a/backend/shared/src/websockets/helpers.ts +++ b/backend/shared/src/websockets/helpers.ts @@ -109,10 +109,10 @@ export function broadcastUpdatedAnswers( ) { if (answers.length === 0) return - const payload = { answers } - const topics = [`contract/${contractId}/updated-answers`] - // TODO: broadcast to global - broadcastMulti(topics, payload) + broadcast(`contract/${contractId}/updated-answers`, { answers }) + for (const a of answers) { + broadcast(`answer/${a.id}/update`, { answer: a }) + } } export function broadcastTVScheduleUpdate() { diff --git a/web/components/comments/comment-header.tsx b/web/components/comments/comment-header.tsx index ebab8a994b..5251a2a063 100644 --- a/web/components/comments/comment-header.tsx +++ b/web/components/comments/comment-header.tsx @@ -48,11 +48,12 @@ import DropdownMenu from './dropdown-menu' import { EditCommentModal } from './edit-comment-modal' import { RepostModal } from './repost-modal' import { type Answer } from 'common/answer' +import { useLiveAnswers } from 'web/hooks/use-answers' export function FeedCommentHeader(props: { comment: ContractComment playContract: Contract - liveContract: Contract + liveContractId: string updateComment?: (comment: Partial) => void inTimeline?: boolean isParent?: boolean @@ -63,7 +64,7 @@ export function FeedCommentHeader(props: { comment, updateComment, playContract, - liveContract, + liveContractId, inTimeline, isPinned, className, @@ -90,6 +91,8 @@ export function FeedCommentHeader(props: { const marketCreator = playContract.creatorId === userId const { bought, money } = getBoughtMoney(betAmount, betOnCashContract) const shouldDisplayOutcome = betOutcome && !answerOutcome + const answer = useLiveAnswer(betAnswerId) + const isReplyToBet = betAmount !== undefined const commenterIsBettor = commenterAndBettorMatch(comment) const isLimitBet = betOrderAmount !== undefined && betLimitProb !== undefined @@ -131,7 +134,7 @@ export function FeedCommentHeader(props: { {bought} {money}{' '} @@ -188,7 +191,7 @@ export function FeedCommentHeader(props: { updateComment={updateComment} comment={comment} playContract={playContract} - liveContract={liveContract} + liveContractId={liveContractId} /> )} @@ -198,7 +201,7 @@ export function FeedCommentHeader(props: { + )} @@ -445,13 +448,13 @@ function CommentStatus(props: { return } -export function DotMenu(props: { +function DotMenu(props: { comment: ContractComment updateComment: (update: Partial) => void playContract: Contract - liveContract: Contract + liveContractId: string }) { - const { comment, updateComment, playContract, liveContract } = props + const { comment, updateComment, playContract, liveContractId } = props const [isModalOpen, setIsModalOpen] = useState(false) const user = useUser() const privateUser = usePrivateUser() @@ -570,7 +573,7 @@ export function DotMenu(props: { diff --git a/web/components/comments/repost-modal.tsx b/web/components/comments/repost-modal.tsx index 9829bfba4d..4ee12f9be5 100644 --- a/web/components/comments/repost-modal.tsx +++ b/web/components/comments/repost-modal.tsx @@ -64,13 +64,13 @@ export const RepostButton = (props: { export const RepostModal = (props: { playContract: Contract - liveContract: Contract + liveContractId: string bet?: Bet comment?: ContractComment open: boolean setOpen: (open: boolean) => void }) => { - const { playContract, liveContract, comment, bet, open, setOpen } = props + const { playContract, liveContractId, comment, bet, open, setOpen } = props const [loading, setLoading] = useState(false) const repost = async () => api('post', { @@ -125,7 +125,7 @@ export const RepostModal = (props: { diff --git a/web/components/feed/scored-feed-repost-item.tsx b/web/components/feed/scored-feed-repost-item.tsx index 8f9cda5b45..f58f3d5922 100644 --- a/web/components/feed/scored-feed-repost-item.tsx +++ b/web/components/feed/scored-feed-repost-item.tsx @@ -99,7 +99,7 @@ export const ScoredFeedRepost = memo(function (props: { comment={comment} // TODO: fix playContract={contract} - liveContract={contract} + liveContractId={contract.id} inTimeline={true} className="truncate" /> diff --git a/web/hooks/use-answers.ts b/web/hooks/use-answers.ts index e784737e99..e1a20e9602 100644 --- a/web/hooks/use-answers.ts +++ b/web/hooks/use-answers.ts @@ -1,10 +1,51 @@ -import { useState } from 'react' +// @ts-nocheck +import { useEffect, useState } from 'react' import { type Answer } from 'common/answer' import { useApiSubscription } from './use-api-subscription' +import { api } from 'web/lib/api/api' + +// TODOS +// export function useAnswer(answerId: string) { + +export function useLiveAnswer(answerId: string | undefined) { + const [answer, setAnswer] = useState(null) + + useEffect(() => { + if (answerId) { + // TODO: create api + api('answer/:answerId', { + answerId, + }).then(setAnswer) + } + }, [answerId]) + + useApiSubscription({ + enabled: answerId != undefined, + topics: [`answer/${answerId}/update`], + onBroadcast: ({ data }) => { + setAnswer((a) => + a ? { ...a, ...(data.answer as Answer) } : (data.answer as Answer) + ) + }, + }) + + return answer +} + +// export function useAnswers(contractId: string | undefined) { export function useLiveAnswers(contractId: string | undefined) { const [answers, setAnswers] = useState([]) + useEffect(() => { + if (contractId) { + // TODO: create api + api('market/:contractId/answers', { + contractId, + }).then(setAnswers) + } + }, [contractId]) + useApiSubscription({ enabled: contractId != undefined, topics: [ From 5820dd223afa3ee2e410050c6d9b641d439483f5 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Tue, 26 Nov 2024 17:59:12 -0800 Subject: [PATCH 04/12] rip in progress --- web/components/bet/sell-row.tsx | 4 +- web/components/comments/comment-header.tsx | 64 ++++++++----------- web/components/comments/comment-input.tsx | 9 ++- web/components/comments/comment.tsx | 11 ++-- web/components/comments/repost-modal.tsx | 13 ++-- web/components/contract/contract-tabs.tsx | 4 -- web/components/contract/header-actions.tsx | 1 - web/components/feed/feed-bets.tsx | 1 - web/components/feed/good-comment.tsx | 2 - .../feed/scored-feed-repost-item.tsx | 8 ++- web/hooks/use-answers.ts | 14 ++-- 11 files changed, 59 insertions(+), 72 deletions(-) diff --git a/web/components/bet/sell-row.tsx b/web/components/bet/sell-row.tsx index 97bb39c858..79ad4093bf 100644 --- a/web/components/bet/sell-row.tsx +++ b/web/components/bet/sell-row.tsx @@ -19,6 +19,7 @@ import { MoneyDisplay } from './money-display' import { SellPanel } from './sell-panel' import { ContractMetric } from 'common/contract-metric' import { useSavedContractMetrics } from 'web/hooks/use-saved-contract-metrics' +import { useAnswer } from 'web/hooks/use-answers' export function SellRow(props: { contract: CPMMContract @@ -144,6 +145,7 @@ export function SellSharesModal(props: { } = props const isStonk = contract.outcomeType === 'STONK' const isCashContract = contract.token === 'CASH' + const { answer } = useAnswer(answerId) return ( @@ -164,7 +166,7 @@ export function SellSharesModal(props: { outcome={sharesOutcome} contract={contract} truncate={'short'} - answerId={answerId} + answer={answer} pseudonym={binaryPseudonym} /> . diff --git a/web/components/comments/comment-header.tsx b/web/components/comments/comment-header.tsx index 5251a2a063..8c5fb567d3 100644 --- a/web/components/comments/comment-header.tsx +++ b/web/components/comments/comment-header.tsx @@ -48,27 +48,23 @@ import DropdownMenu from './dropdown-menu' import { EditCommentModal } from './edit-comment-modal' import { RepostModal } from './repost-modal' import { type Answer } from 'common/answer' -import { useLiveAnswers } from 'web/hooks/use-answers' +import { useAnswer, useLiveAnswer } from 'web/hooks/use-answers' export function FeedCommentHeader(props: { comment: ContractComment playContract: Contract - liveContractId: string - updateComment?: (comment: Partial) => void + menuProps?: { + liveContractId: string + updateComment: (comment: Partial) => void + } inTimeline?: boolean isParent?: boolean isPinned?: boolean className?: string }) { - const { - comment, - updateComment, - playContract, - liveContractId, - inTimeline, - isPinned, - className, - } = props + const { comment, playContract, menuProps, inTimeline, isPinned, className } = + props + const { userUsername, userName, @@ -151,11 +147,7 @@ export function FeedCommentHeader(props: { {/* Hide my status if replying to a bet, it's too much clutter*/} {!isReplyToBet && !inTimeline && ( - + {bought} {money} {shouldDisplayOutcome && ( <> @@ -186,12 +178,12 @@ export function FeedCommentHeader(props: { {!inTimeline && isApi && ( 🤖 )} - {!inTimeline && updateComment && ( + {!inTimeline && menuProps && ( )} @@ -199,10 +191,7 @@ export function FeedCommentHeader(props: { {bountyAwarded && bountyAwarded > 0 && ( + - + )} {isPinned && } @@ -230,10 +219,10 @@ const getBoughtMoney = ( export function CommentReplyHeaderWithBet(props: { comment: ContractComment + contract: Pick bet: Bet - answers: Answer[] }) { - const { comment, bet, answers } = props + const { comment, contract, bet } = props const { outcome, answerId, amount, orderAmount, limitProb } = bet return ( ) } export function CommentReplyHeader(props: { comment: ContractComment - answers: Answer[] + contract: Pick hideBetHeader?: boolean }) { - const { comment, answers, hideBetHeader } = props + const { comment, contract, hideBetHeader } = props const { bettorName, bettorId, @@ -267,6 +256,10 @@ export function CommentReplyHeader(props: { betOrderAmount, betLimitProb, } = comment + + const { answer: betAnswer } = useAnswer(betAnswerId) + const { answer: answerToReply } = useAnswer(answerOutcome) + if ( (bettorId || (bettorUsername && bettorName)) && betOutcome && @@ -276,20 +269,20 @@ export function CommentReplyHeader(props: { return ( ) } - if (answerOutcome) { - const answer = answers.find((a) => a.id === answerOutcome) - if (answer) return + if (answerToReply) { + return } return null @@ -412,7 +405,6 @@ export function ReplyToBetRow(props: { function CommentStatus(props: { contract: Pick - answer?: Answer comment: ContractComment }) { const { contract, comment } = props @@ -423,8 +415,7 @@ function CommentStatus(props: { commenterPositionShares, } = comment - // TODO: what to do here? get the answer? pass another answer in? - // casche on the comment? + const { answer } = useAnswer(commenterPositionAnswerId) if ( comment.betId == null && @@ -581,7 +572,6 @@ function DotMenu(props: { {user && reposting && ( {isReplyToBet ? ( @@ -326,7 +329,7 @@ export function ContractCommentInput(props: { bettorId={replyTo.userId} betOrderAmount={replyTo.orderAmount} betLimitProb={replyTo.limitProb} - betAnswer={answers?.find((a) => a.id === replyTo.answerId)} + betAnswer={betAnswer} contract={playContract} clearReply={clearReply} /> diff --git a/web/components/comments/comment.tsx b/web/components/comments/comment.tsx index 69286263df..f3adb11443 100644 --- a/web/components/comments/comment.tsx +++ b/web/components/comments/comment.tsx @@ -123,10 +123,7 @@ export const FeedComment = memo(function FeedComment(props: { @@ -183,9 +180,11 @@ export const FeedComment = memo(function FeedComment(props: { > { - const { playContract, liveContract, bet, size, className, iconClassName } = - props + const { playContract, bet, size, className, iconClassName } = props const [open, setOpen] = useState(false) return ( <> @@ -53,7 +51,6 @@ export const RepostButton = (props: { @@ -64,13 +61,12 @@ export const RepostButton = (props: { export const RepostModal = (props: { playContract: Contract - liveContractId: string bet?: Bet comment?: ContractComment open: boolean setOpen: (open: boolean) => void }) => { - const { playContract, liveContractId, comment, bet, open, setOpen } = props + const { playContract, comment, bet, open, setOpen } = props const [loading, setLoading] = useState(false) const repost = async () => api('post', { @@ -100,12 +96,12 @@ export const RepostModal = (props: { (comment.bettorUsername && !commenterIsBettor)) && (bet ? ( ) : ( - + ))} @@ -125,7 +121,6 @@ export const RepostModal = (props: { diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index a82d16f5b2..1e843daf05 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -121,7 +121,6 @@ export function ContractTabs(props: { diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index 5c982f1b6a..bf7e047819 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -305,7 +305,6 @@ function BetActions(props: { bet={bet} size={'2xs'} className={'!p-1'} - liveContract={contract} playContract={contract} /> {onReply && ( diff --git a/web/components/feed/good-comment.tsx b/web/components/feed/good-comment.tsx index b793093c55..db2a2df217 100644 --- a/web/components/feed/good-comment.tsx +++ b/web/components/feed/good-comment.tsx @@ -80,9 +80,7 @@ export const GoodComment = memo(function (props: { diff --git a/web/components/feed/scored-feed-repost-item.tsx b/web/components/feed/scored-feed-repost-item.tsx index f58f3d5922..a83d101764 100644 --- a/web/components/feed/scored-feed-repost-item.tsx +++ b/web/components/feed/scored-feed-repost-item.tsx @@ -97,9 +97,7 @@ export const ScoredFeedRepost = memo(function (props: {
@@ -183,7 +181,11 @@ function RepostLabel(props: { ) const header = bet && ( - + ) if (creatorRepostedTheirComment) { diff --git a/web/hooks/use-answers.ts b/web/hooks/use-answers.ts index e1a20e9602..1aa38e1ae4 100644 --- a/web/hooks/use-answers.ts +++ b/web/hooks/use-answers.ts @@ -4,12 +4,10 @@ import { type Answer } from 'common/answer' import { useApiSubscription } from './use-api-subscription' import { api } from 'web/lib/api/api' -// TODOS -// export function useAnswer(answerId: string) { - -export function useLiveAnswer(answerId: string | undefined) { - const [answer, setAnswer] = useState(null) +// TODO: use API getter +export function useAnswer(answerId: string | undefined) { + const [answer, setAnswer] = useState() useEffect(() => { if (answerId) { // TODO: create api @@ -19,6 +17,12 @@ export function useLiveAnswer(answerId: string | undefined) { } }, [answerId]) + return { answer, setAnswer } +} + +export function useLiveAnswer(answerId: string | undefined) { + const { answer, setAnswer } = useAnswer(answerId) + useApiSubscription({ enabled: answerId != undefined, topics: [`answer/${answerId}/update`], From 57de730cb707fd7b6460ba0b3e017f81e4ec9844 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Wed, 27 Nov 2024 13:26:49 -0800 Subject: [PATCH 05/12] add apis --- backend/api/src/get-answer.ts | 12 ++++++++++++ backend/api/src/get-contract-answers.ts | 8 ++++++++ backend/api/src/routes.ts | 4 ++++ common/src/api/schema.ts | 17 ++++++++++++++++- web/hooks/use-answers.ts | 2 -- 5 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 backend/api/src/get-answer.ts create mode 100644 backend/api/src/get-contract-answers.ts diff --git a/backend/api/src/get-answer.ts b/backend/api/src/get-answer.ts new file mode 100644 index 0000000000..f9711f1ee7 --- /dev/null +++ b/backend/api/src/get-answer.ts @@ -0,0 +1,12 @@ +import { APIHandler } from './helpers/endpoint' +import { getAnswer } from 'shared/supabase/answers' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export const getSingleAnswer: APIHandler<'answer/:answerId'> = async (props) => { + const pg = createSupabaseDirectClient() + const answer = await getAnswer(pg, props.answerId) + if (!answer) { + throw new Error('Answer not found') + } + return answer +} diff --git a/backend/api/src/get-contract-answers.ts b/backend/api/src/get-contract-answers.ts new file mode 100644 index 0000000000..0086659a6d --- /dev/null +++ b/backend/api/src/get-contract-answers.ts @@ -0,0 +1,8 @@ +import { APIHandler } from './helpers/endpoint' +import { getAnswersForContract } from 'shared/supabase/answers' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export const getContractAnswers: APIHandler<'market/:contractId/answers'> = async (props) => { + const pg = createSupabaseDirectClient() + return await getAnswersForContract(pg, props.contractId) +} diff --git a/backend/api/src/routes.ts b/backend/api/src/routes.ts index 096ca1b913..cafbbc2174 100644 --- a/backend/api/src/routes.ts +++ b/backend/api/src/routes.ts @@ -35,6 +35,8 @@ import { getGroup } from './get-group' import { getPositions } from './get-positions' import { getLeagues } from './get-leagues' import { getContract } from './get-contract' +import { getSingleAnswer } from './get-answer' +import { getContractAnswers } from './get-contract-answers' import { addOrRemoveTopicFromContract } from './add-topic-to-market' import { addOrRemoveTopicFromTopic } from './add-topic-to-topic' import { searchUsers } from './search-users' @@ -173,6 +175,8 @@ export const handlers: { [k in APIPath]: APIHandler } = { groups: getGroups, 'market/:id': getMarket, 'market/:id/lite': ({ id }) => getMarket({ id, lite: true }), + 'answer/:answerId': getSingleAnswer, + 'market/:contractId/answers': getContractAnswers, 'markets-by-ids': getMarketsByIds, 'slug/:slug': getMarket, 'market/:contractId/update': updateMarket, diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 455a539cd5..5256c3a570 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -15,6 +15,7 @@ import { FullMarket, updateMarketProps, } from './market-types' +import { type Answer } from 'common/answer' import { MAX_COMMENT_LENGTH, type ContractComment } from 'common/comment' import { CandidateBet } from 'common/new-bet' import type { Bet, LimitBet } from 'common/bet' @@ -59,12 +60,12 @@ import { PendingCashoutStatusData, cashoutParams, } from 'common/gidx/gidx' - import { notification_preference } from 'common/user-notification-preferences' import { PrivateMessageChannel } from 'common/supabase/private-messages' import { Notification } from 'common/notification' import { NON_POINTS_BETS_LIMIT } from 'common/supabase/bets' import { ContractMetric } from 'common/contract-metric' + import { JSONContent } from '@tiptap/core' // mqp: very unscientific, just balancing our willingness to accept load // with user willingness to put up with stale data @@ -158,6 +159,20 @@ export const API = (_apiTypeCheck = { }) .strict(), }, + 'answer/:answerId': { + method: 'GET', + visibility: 'public', + authed: false, + returns: {} as Answer, + props: z.object({ answerId: z.string() }).strict(), + }, + 'market/:contractId/answers': { + method: 'GET', + visibility: 'public', + authed: false, + returns: [] as Answer[], + props: z.object({ contractId: z.string() }).strict(), + }, 'hide-comment': { method: 'POST', visibility: 'public', diff --git a/web/hooks/use-answers.ts b/web/hooks/use-answers.ts index 1aa38e1ae4..bc60d92198 100644 --- a/web/hooks/use-answers.ts +++ b/web/hooks/use-answers.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { useEffect, useState } from 'react' import { type Answer } from 'common/answer' import { useApiSubscription } from './use-api-subscription' @@ -10,7 +9,6 @@ export function useAnswer(answerId: string | undefined) { const [answer, setAnswer] = useState() useEffect(() => { if (answerId) { - // TODO: create api api('answer/:answerId', { answerId, }).then(setAnswer) From d8fa3044c30d87bf65f8e99ee26ed3aff5400450 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Wed, 27 Nov 2024 14:06:50 -0800 Subject: [PATCH 06/12] refactor: simplify --- web/hooks/use-user-supabase.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/web/hooks/use-user-supabase.ts b/web/hooks/use-user-supabase.ts index 38fa686f06..147a299dcb 100644 --- a/web/hooks/use-user-supabase.ts +++ b/web/hooks/use-user-supabase.ts @@ -82,12 +82,5 @@ export function useUsersInStore( export function useDisplayUserByIdOrAnswer(answer: Answer) { const userId = answer.userId - const user = useDisplayUserById(userId) - if (!user) return user - return { - id: userId, - name: user.name, - username: user.username, - avatarUrl: user.avatarUrl, - } + return useDisplayUserById(userId) } From 87dc9235b23f6590e4d080c2dab993d4568c9398 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Wed, 27 Nov 2024 14:21:55 -0800 Subject: [PATCH 07/12] Disallow adding answers to sweepstakes question --- backend/api/src/create-answer-cpmm.ts | 5 +---- backend/shared/src/create-cash-contract.ts | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/backend/api/src/create-answer-cpmm.ts b/backend/api/src/create-answer-cpmm.ts index d366d223a0..d8f1b7271c 100644 --- a/backend/api/src/create-answer-cpmm.ts +++ b/backend/api/src/create-answer-cpmm.ts @@ -81,10 +81,7 @@ const verifyContract = async (contractId: string, creatorId: string) => { const contract = await getContractSupabase(contractId) if (!contract) throw new APIError(404, 'Contract not found') if (contract.token !== 'MANA') { - throw new APIError( - 403, - 'Must add answer to the mana version of the contract' - ) + throw new APIError(403, 'Cannot add answers to sweepstakes question') } if (contract.mechanism !== 'cpmm-multi-1') throw new APIError(403, 'Requires a cpmm multiple choice contract') diff --git a/backend/shared/src/create-cash-contract.ts b/backend/shared/src/create-cash-contract.ts index 824f98aae3..7bb3c6bd53 100644 --- a/backend/shared/src/create-cash-contract.ts +++ b/backend/shared/src/create-cash-contract.ts @@ -67,10 +67,10 @@ export async function createCashContractMain( let answers: Answer[] = [] if (manaContract.outcomeType === 'MULTIPLE_CHOICE') { - if (manaContract.addAnswersMode === 'ANYONE') + if (manaContract.addAnswersMode !== 'DISABLED') throw new APIError( 400, - `Cannot make sweepstakes question for free response contract` + `Cannot add answers to multi sweepstakes question` ) answers = await getAnswersForContract(tx, manaContractId) From b84bfa33b3b27a69f94d850c7688a05057862935 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Wed, 27 Nov 2024 14:29:18 -0800 Subject: [PATCH 08/12] fix lint --- backend/api/src/create-answer-cpmm.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/api/src/create-answer-cpmm.ts b/backend/api/src/create-answer-cpmm.ts index d8f1b7271c..1775635ead 100644 --- a/backend/api/src/create-answer-cpmm.ts +++ b/backend/api/src/create-answer-cpmm.ts @@ -144,10 +144,10 @@ const createAnswerCpmmMain = async ( ) } - let poolYes = answerCost - let poolNo = answerCost - let totalLiquidity = answerCost - let prob = 0.5 + const poolYes = answerCost + const poolNo = answerCost + const totalLiquidity = answerCost + const prob = 0.5 const id = randomString() const n = answers.length From 4ef144b1dbe940a3f239cb7a518609e3e5eee0c7 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Wed, 27 Nov 2024 14:32:58 -0800 Subject: [PATCH 09/12] fix lint --- backend/api/src/create-answer-cpmm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/src/create-answer-cpmm.ts b/backend/api/src/create-answer-cpmm.ts index 1775635ead..728b801346 100644 --- a/backend/api/src/create-answer-cpmm.ts +++ b/backend/api/src/create-answer-cpmm.ts @@ -1,5 +1,5 @@ import { groupBy, partition, sumBy } from 'lodash' -import { CPMMMultiContract, add_answers_mode } from 'common/contract' +import { CPMMMultiContract } from 'common/contract' import { User } from 'common/user' import { getBetDownToOneMultiBetInfo } from 'common/new-bet' import { Answer, getMaximumAnswers } from 'common/answer' From 5e3bd62ed7323b2c6819e2b710ebc277b27504b8 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Wed, 27 Nov 2024 15:21:04 -0800 Subject: [PATCH 10/12] code review feedback add back admin flag for sweepify lol remove no longer necessary answers data passthrough --- .../contract/contract-info-dialog.tsx | 2 +- web/components/contract/contract-page.tsx | 7 ---- web/components/contract/contract-tabs.tsx | 2 - .../feed/scored-feed-repost-item.tsx | 2 +- web/hooks/use-answers.ts | 39 ------------------- 5 files changed, 2 insertions(+), 50 deletions(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 626ea76ac3..bed8627415 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -535,7 +535,7 @@ export function ContractInfoDialog(props: { {isAdmin || isTrusted ? ( ) : null} - {!playContract.siblingContractId && ( + {isAdmin && !playContract.siblingContractId && ( { if ( @@ -522,7 +516,6 @@ export function ContractPageContent(props: ContractParams) { - {header}i'' + {header} {dropdown} ) diff --git a/web/hooks/use-answers.ts b/web/hooks/use-answers.ts index bc60d92198..29a8586337 100644 --- a/web/hooks/use-answers.ts +++ b/web/hooks/use-answers.ts @@ -33,42 +33,3 @@ export function useLiveAnswer(answerId: string | undefined) { return answer } - -// export function useAnswers(contractId: string | undefined) { - -export function useLiveAnswers(contractId: string | undefined) { - const [answers, setAnswers] = useState([]) - - useEffect(() => { - if (contractId) { - // TODO: create api - api('market/:contractId/answers', { - contractId, - }).then(setAnswers) - } - }, [contractId]) - - useApiSubscription({ - enabled: contractId != undefined, - topics: [ - `contract/${contractId}/new-answer`, - `contract/${contractId}/updated-answers`, - ], - onBroadcast: ({ data, topic }) => { - if (topic.endsWith('new-answer')) { - setAnswers((a) => [...a, data.answer as Answer]) - } else if (topic.endsWith('updated-answers')) { - const updates = data.answers as (Partial & { id: string })[] - setAnswers((a) => - a.map((a) => { - const u = updates.find((u) => u.id === a.id) - if (!u) return a - return { ...a, ...u } - }) - ) - } - }, - }) - - return answers -} From bb01407d357d367a843c7015b3ad259bbc031967 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Wed, 27 Nov 2024 16:06:20 -0800 Subject: [PATCH 11/12] Remove answer duplication logic --- backend/api/src/create-answer-cpmm.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/backend/api/src/create-answer-cpmm.ts b/backend/api/src/create-answer-cpmm.ts index 728b801346..92c64c9cc4 100644 --- a/backend/api/src/create-answer-cpmm.ts +++ b/backend/api/src/create-answer-cpmm.ts @@ -64,17 +64,7 @@ const createAnswerCpmmFull = async ( ) => { log('Received ' + contractId + ' ' + text) const contract = await verifyContract(contractId, userId) - const response = await createAnswerCpmmMain(contract, text, userId) - - // copy answer if this is sweeps question - if (contract.siblingContractId) { - const cashContract = await getContractSupabase(contract.siblingContractId) - if (!cashContract) throw new APIError(500, 'Cash contract not found') - await createAnswerCpmmMain(cashContract as any, text, userId) - // ignore continuation of sweepstakes answer, since don't need to notify twice - } - - return response + return await createAnswerCpmmMain(contract, text, userId) } const verifyContract = async (contractId: string, creatorId: string) => { From 051c82a7b58a4e5bb97adb00c3c653b11f65e20e Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Wed, 27 Nov 2024 19:36:52 -0800 Subject: [PATCH 12/12] pre-cache answers --- web/components/contract/contract-page.tsx | 10 ++++++++++ web/hooks/use-answers.ts | 23 +++++++++++------------ web/hooks/use-api-getter.ts | 12 +++++++++++- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/web/components/contract/contract-page.tsx b/web/components/contract/contract-page.tsx index 0e86ad1d51..7f6f2d8fbd 100644 --- a/web/components/contract/contract-page.tsx +++ b/web/components/contract/contract-page.tsx @@ -74,6 +74,7 @@ import { SpiceCoin } from 'web/public/custom-components/spiceCoin' import { YourTrades } from 'web/pages/[username]/[contractSlug]' import { useSweepstakes } from '../sweepstakes-provider' import { useRouter } from 'next/router' +import { precacheAnswers } from 'web/hooks/use-answers' export function ContractPageContent(props: ContractParams) { const { @@ -190,6 +191,15 @@ export function ContractPageContent(props: ContractParams) { ) useSaveContractVisitsLocally(user === null, props.contract.id) + useEffect(() => { + if ('answers' in props.contract) { + precacheAnswers(props.contract.answers) + } + if (props.cash?.contract && 'answers' in props.cash.contract) { + precacheAnswers(props.cash.contract.answers) + } + }, []) + const playBetData = useBetData({ contractId: props.contract.id, outcomeType: props.contract.outcomeType, diff --git a/web/hooks/use-answers.ts b/web/hooks/use-answers.ts index 29a8586337..ecf67b0918 100644 --- a/web/hooks/use-answers.ts +++ b/web/hooks/use-answers.ts @@ -1,19 +1,12 @@ -import { useEffect, useState } from 'react' import { type Answer } from 'common/answer' +import { prepopulateCache, useAPIGetter } from './use-api-getter' import { useApiSubscription } from './use-api-subscription' -import { api } from 'web/lib/api/api' - -// TODO: use API getter export function useAnswer(answerId: string | undefined) { - const [answer, setAnswer] = useState() - useEffect(() => { - if (answerId) { - api('answer/:answerId', { - answerId, - }).then(setAnswer) - } - }, [answerId]) + const { data: answer, setData: setAnswer } = useAPIGetter( + 'answer/:answerId', + answerId ? { answerId } : undefined + ) return { answer, setAnswer } } @@ -33,3 +26,9 @@ export function useLiveAnswer(answerId: string | undefined) { return answer } + +export function precacheAnswers(answers: Answer[]) { + for (const answer of answers) { + prepopulateCache('answer/:answerId', { answerId: answer.id }, answer) + } +} diff --git a/web/hooks/use-api-getter.ts b/web/hooks/use-api-getter.ts index 4b088cbbc1..0d25db7808 100644 --- a/web/hooks/use-api-getter.ts +++ b/web/hooks/use-api-getter.ts @@ -6,6 +6,16 @@ import { useEvent } from './use-event' const promiseCache: Record | undefined> = {} +// Prepopulate cache with data, e.g. from static props +export function prepopulateCache

( + path: P, + props: APIParams

, + data: APIResponse

+) { + const key = `${path}-${JSON.stringify(props)}` + promiseCache[key] = Promise.resolve(data) +} + // react query at home export const useAPIGetter =

( path: P, @@ -24,7 +34,7 @@ export const useAPIGetter =

( APIResponse

| undefined >(undefined, `${overrideKey ?? path}`) - const key = `${path}-${propsString}-error` + const key = `${path}-${propsString}` const [error, setError] = usePersistentInMemoryState( undefined, key