From b8d65acc3f75cc29af12bb56176a64bcb68acbdc Mon Sep 17 00:00:00 2001 From: mantikoros Date: Thu, 6 Oct 2022 10:54:42 -0500 Subject: [PATCH 01/44] Revert "create market: use transaction" This reverts commit e1f24f24a96fa8d811ebcaa3b10b19d9b67cb282. --- functions/src/create-market.ts | 457 ++++++++++++++++----------------- 1 file changed, 224 insertions(+), 233 deletions(-) diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index 38852184..d1483ca4 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -15,7 +15,7 @@ import { import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' -import { isProd } from './utils' +import { chargeUser, getContract, isProd } from './utils' import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' @@ -36,7 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric' import { JSONContent } from '@tiptap/core' import { uniq, zip } from 'lodash' import { Bet } from '../../common/bet' -import { FieldValue, Transaction } from 'firebase-admin/firestore' +import { FieldValue } from 'firebase-admin/firestore' const descScehma: z.ZodType = z.lazy(() => z.intersection( @@ -107,242 +107,229 @@ export async function createMarketHelper(body: any, auth: AuthedUser) { visibility = 'public', } = validate(bodySchema, body) - return await firestore.runTransaction(async (trans) => { - let min, max, initialProb, isLogScale, answers + let min, max, initialProb, isLogScale, answers - if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { - let initialValue - ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body)) - if (max - min <= 0.01 || initialValue <= min || initialValue >= max) - throw new APIError(400, 'Invalid range.') + if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { + let initialValue + ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body)) + if (max - min <= 0.01 || initialValue <= min || initialValue >= max) + throw new APIError(400, 'Invalid range.') - initialProb = - getPseudoProbability(initialValue, min, max, isLogScale) * 100 + initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100 - if (initialProb < 1 || initialProb > 99) - if (outcomeType === 'PSEUDO_NUMERIC') - throw new APIError( - 400, - `Initial value is too ${initialProb < 1 ? 'low' : 'high'}` - ) - else throw new APIError(400, 'Invalid initial probability.') - } - - if (outcomeType === 'BINARY') { - ;({ initialProb } = validate(binarySchema, body)) - } - - if (outcomeType === 'MULTIPLE_CHOICE') { - ;({ answers } = validate(multipleChoiceSchema, body)) - } - - const userDoc = await trans.get(firestore.collection('users').doc(auth.uid)) - if (!userDoc.exists) { - throw new APIError(400, 'No user exists with the authenticated user ID.') - } - const user = userDoc.data() as User - - const ante = FIXED_ANTE - const deservesFreeMarket = - (user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX - // TODO: this is broken because it's not in a transaction - if (ante > user.balance && !deservesFreeMarket) - throw new APIError(400, `Balance must be at least ${ante}.`) - - let group: Group | null = null - if (groupId) { - const groupDocRef = firestore.collection('groups').doc(groupId) - const groupDoc = await trans.get(groupDocRef) - if (!groupDoc.exists) { - throw new APIError(400, 'No group exists with the given group ID.') - } - - group = groupDoc.data() as Group - const groupMembersSnap = await trans.get( - firestore.collection(`groups/${groupId}/groupMembers`) - ) - const groupMemberDocs = groupMembersSnap.docs.map( - (doc) => doc.data() as { userId: string; createdTime: number } - ) - if ( - !groupMemberDocs.map((m) => m.userId).includes(user.id) && - !group.anyoneCanJoin && - group.creatorId !== user.id - ) { + if (initialProb < 1 || initialProb > 99) + if (outcomeType === 'PSEUDO_NUMERIC') throw new APIError( 400, - 'User must be a member/creator of the group or group must be open to add markets to it.' + `Initial value is too ${initialProb < 1 ? 'low' : 'high'}` ) - } - } - const slug = await getSlug(trans, question) - const contractRef = firestore.collection('contracts').doc() + else throw new APIError(400, 'Invalid initial probability.') + } - console.log( - 'creating contract for', - user.username, - 'on', - question, - 'ante:', - ante || 0 - ) + if (outcomeType === 'BINARY') { + ;({ initialProb } = validate(binarySchema, body)) + } - // convert string descriptions into JSONContent - const newDescription = - !description || typeof description === 'string' - ? { - type: 'doc', - content: [ - { - type: 'paragraph', - content: [{ type: 'text', text: description || ' ' }], - }, - ], - } - : description + if (outcomeType === 'MULTIPLE_CHOICE') { + ;({ answers } = validate(multipleChoiceSchema, body)) + } - const contract = getNewContract( - contractRef.id, - slug, - user, - question, - outcomeType, - newDescription, - initialProb ?? 0, - ante, - closeTime.getTime(), - tags ?? [], - NUMERIC_BUCKET_COUNT, - min ?? 0, - max ?? 0, - isLogScale ?? false, - answers ?? [], - visibility - ) + const userDoc = await firestore.collection('users').doc(auth.uid).get() + if (!userDoc.exists) { + throw new APIError(400, 'No user exists with the authenticated user ID.') + } + const user = userDoc.data() as User - const providerId = deservesFreeMarket - ? isProd() - ? HOUSE_LIQUIDITY_PROVIDER_ID - : DEV_HOUSE_LIQUIDITY_PROVIDER_ID - : user.id + const ante = FIXED_ANTE + const deservesFreeMarket = + (user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX + // TODO: this is broken because it's not in a transaction + if (ante > user.balance && !deservesFreeMarket) + throw new APIError(400, `Balance must be at least ${ante}.`) - if (ante) { - const delta = FieldValue.increment(-ante) - const providerDoc = firestore.collection('users').doc(providerId) - await trans.update(providerDoc, { balance: delta, totalDeposits: delta }) + let group: Group | null = null + if (groupId) { + const groupDocRef = firestore.collection('groups').doc(groupId) + const groupDoc = await groupDocRef.get() + if (!groupDoc.exists) { + throw new APIError(400, 'No group exists with the given group ID.') } - if (deservesFreeMarket) { - await trans.update(firestore.collection('users').doc(user.id), { - freeMarketsCreated: FieldValue.increment(1), + group = groupDoc.data() as Group + const groupMembersSnap = await firestore + .collection(`groups/${groupId}/groupMembers`) + .get() + const groupMemberDocs = groupMembersSnap.docs.map( + (doc) => doc.data() as { userId: string; createdTime: number } + ) + if ( + !groupMemberDocs.map((m) => m.userId).includes(user.id) && + !group.anyoneCanJoin && + group.creatorId !== user.id + ) { + throw new APIError( + 400, + 'User must be a member/creator of the group or group must be open to add markets to it.' + ) + } + } + const slug = await getSlug(question) + const contractRef = firestore.collection('contracts').doc() + + console.log( + 'creating contract for', + user.username, + 'on', + question, + 'ante:', + ante || 0 + ) + + // convert string descriptions into JSONContent + const newDescription = + !description || typeof description === 'string' + ? { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: description || ' ' }], + }, + ], + } + : description + + const contract = getNewContract( + contractRef.id, + slug, + user, + question, + outcomeType, + newDescription, + initialProb ?? 0, + ante, + closeTime.getTime(), + tags ?? [], + NUMERIC_BUCKET_COUNT, + min ?? 0, + max ?? 0, + isLogScale ?? false, + answers ?? [], + visibility + ) + + const providerId = deservesFreeMarket + ? isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + : user.id + + if (ante) await chargeUser(providerId, ante, true) + if (deservesFreeMarket) + await firestore + .collection('users') + .doc(user.id) + .update({ freeMarketsCreated: FieldValue.increment(1) }) + + await contractRef.create(contract) + + if (group != null) { + const groupContractsSnap = await firestore + .collection(`groups/${groupId}/groupContracts`) + .get() + const groupContracts = groupContractsSnap.docs.map( + (doc) => doc.data() as { contractId: string; createdTime: number } + ) + if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) { + await createGroupLinks(group, [contractRef.id], auth.uid) + const groupContractRef = firestore + .collection(`groups/${groupId}/groupContracts`) + .doc(contract.id) + await groupContractRef.set({ + contractId: contract.id, + createdTime: Date.now(), }) } + } - await contractRef.create(contract) + if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { + const liquidityDoc = firestore + .collection(`contracts/${contract.id}/liquidity`) + .doc() - if (group != null) { - const groupContractsSnap = await trans.get( - firestore.collection(`groups/${groupId}/groupContracts`) - ) - const groupContracts = groupContractsSnap.docs.map( - (doc) => doc.data() as { contractId: string; createdTime: number } + const lp = getCpmmInitialLiquidity( + providerId, + contract as CPMMBinaryContract, + liquidityDoc.id, + ante + ) + + await liquidityDoc.set(lp) + } else if (outcomeType === 'MULTIPLE_CHOICE') { + const betCol = firestore.collection(`contracts/${contract.id}/bets`) + const betDocs = (answers ?? []).map(() => betCol.doc()) + + const answerCol = firestore.collection(`contracts/${contract.id}/answers`) + const answerDocs = (answers ?? []).map((_, i) => + answerCol.doc(i.toString()) + ) + + const { bets, answerObjects } = getMultipleChoiceAntes( + user, + contract as MultipleChoiceContract, + answers ?? [], + betDocs.map((bd) => bd.id) + ) + + await Promise.all( + zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet)) + ) + await Promise.all( + zip(answerObjects, answerDocs).map(([answer, doc]) => + doc?.create(answer as Answer) ) + ) + await contractRef.update({ answers: answerObjects }) + } else if (outcomeType === 'FREE_RESPONSE') { + const noneAnswerDoc = firestore + .collection(`contracts/${contract.id}/answers`) + .doc('0') - if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) { - await createGroupLinks(trans, group, [contractRef.id], auth.uid) + const noneAnswer = getNoneAnswer(contract.id, user) + await noneAnswerDoc.set(noneAnswer) - const groupContractRef = firestore - .collection(`groups/${groupId}/groupContracts`) - .doc(contract.id) + const anteBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() - await trans.set(groupContractRef, { - contractId: contract.id, - createdTime: Date.now(), - }) - } - } + const anteBet = getFreeAnswerAnte( + providerId, + contract as FreeResponseContract, + anteBetDoc.id + ) + await anteBetDoc.set(anteBet) + } else if (outcomeType === 'NUMERIC') { + const anteBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() - if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { - const liquidityDoc = firestore - .collection(`contracts/${contract.id}/liquidity`) - .doc() + const anteBet = getNumericAnte( + providerId, + contract as NumericContract, + ante, + anteBetDoc.id + ) - const lp = getCpmmInitialLiquidity( - providerId, - contract as CPMMBinaryContract, - liquidityDoc.id, - ante - ) + await anteBetDoc.set(anteBet) + } - await trans.set(liquidityDoc, lp) - } else if (outcomeType === 'MULTIPLE_CHOICE') { - const betCol = firestore.collection(`contracts/${contract.id}/bets`) - const betDocs = (answers ?? []).map(() => betCol.doc()) - - const answerCol = firestore.collection(`contracts/${contract.id}/answers`) - const answerDocs = (answers ?? []).map((_, i) => - answerCol.doc(i.toString()) - ) - - const { bets, answerObjects } = getMultipleChoiceAntes( - user, - contract as MultipleChoiceContract, - answers ?? [], - betDocs.map((bd) => bd.id) - ) - - await Promise.all( - zip(bets, betDocs).map(([bet, doc]) => - doc ? trans.create(doc, bet as Bet) : undefined - ) - ) - await Promise.all( - zip(answerObjects, answerDocs).map(([answer, doc]) => - doc ? trans.create(doc, answer as Answer) : undefined - ) - ) - await trans.update(contractRef, { answers: answerObjects }) - } else if (outcomeType === 'FREE_RESPONSE') { - const noneAnswerDoc = firestore - .collection(`contracts/${contract.id}/answers`) - .doc('0') - - const noneAnswer = getNoneAnswer(contract.id, user) - await trans.set(noneAnswerDoc, noneAnswer) - - const anteBetDoc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() - - const anteBet = getFreeAnswerAnte( - providerId, - contract as FreeResponseContract, - anteBetDoc.id - ) - await trans.set(anteBetDoc, anteBet) - } else if (outcomeType === 'NUMERIC') { - const anteBetDoc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() - - const anteBet = getNumericAnte( - providerId, - contract as NumericContract, - ante, - anteBetDoc.id - ) - - await trans.set(anteBetDoc, anteBet) - } - - return contract - }) + return contract } -const getSlug = async (trans: Transaction, question: string) => { +const getSlug = async (question: string) => { const proposedSlug = slugify(question) - const preexistingContract = await getContractFromSlug(trans, proposedSlug) + const preexistingContract = await getContractFromSlug(proposedSlug) return preexistingContract ? proposedSlug + '-' + randomString() @@ -351,42 +338,46 @@ const getSlug = async (trans: Transaction, question: string) => { const firestore = admin.firestore() -async function getContractFromSlug(trans: Transaction, slug: string) { - const snap = await trans.get( - firestore.collection('contracts').where('slug', '==', slug) - ) +export async function getContractFromSlug(slug: string) { + const snap = await firestore + .collection('contracts') + .where('slug', '==', slug) + .get() return snap.empty ? undefined : (snap.docs[0].data() as Contract) } async function createGroupLinks( - trans: Transaction, group: Group, contractIds: string[], userId: string ) { for (const contractId of contractIds) { - const contractRef = firestore.collection('contracts').doc(contractId) - const contract = (await trans.get(contractRef)).data() as Contract - + const contract = await getContract(contractId) if (!contract?.groupSlugs?.includes(group.slug)) { - await trans.update(contractRef, { - groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]), - }) + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]), + }) } if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) { - await trans.update(contractRef, { - groupLinks: [ - { - groupId: group.id, - name: group.name, - slug: group.slug, - userId, - createdTime: Date.now(), - } as GroupLink, - ...(contract?.groupLinks ?? []), - ], - }) + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupLinks: [ + { + groupId: group.id, + name: group.name, + slug: group.slug, + userId, + createdTime: Date.now(), + } as GroupLink, + ...(contract?.groupLinks ?? []), + ], + }) } } } From 59de9799498a5710fc8d79ae18f8edd224d67af4 Mon Sep 17 00:00:00 2001 From: Pico2x Date: Thu, 6 Oct 2022 17:04:00 +0100 Subject: [PATCH 02/44] Refactor Pinned Items into a reusable component --- web/components/groups/group-overview.tsx | 203 +++++++++++++---------- web/components/pinned-select-modal.tsx | 8 +- 2 files changed, 118 insertions(+), 93 deletions(-) diff --git a/web/components/groups/group-overview.tsx b/web/components/groups/group-overview.tsx index 080453ca..d5cdaafa 100644 --- a/web/components/groups/group-overview.tsx +++ b/web/components/groups/group-overview.tsx @@ -145,8 +145,6 @@ function GroupOverviewPinned(props: { }) { const { group, posts, isEditable } = props const [pinned, setPinned] = useState([]) - const [open, setOpen] = useState(false) - const [editMode, setEditMode] = useState(false) useEffect(() => { async function getPinned() { @@ -185,100 +183,127 @@ function GroupOverviewPinned(props: { ...(selectedItems as { itemId: string; type: 'contract' | 'post' }[]), ], }) - setOpen(false) + } + + function onDeleteClicked(index: number) { + const newPinned = group.pinnedItems.filter((item) => { + return item.itemId !== group.pinnedItems[index].itemId + }) + updateGroup(group, { pinnedItems: newPinned }) } return isEditable || (group.pinnedItems && group.pinnedItems.length > 0) ? ( - pinned.length > 0 || isEditable ? ( -
- - - {isEditable && ( - - )} - -
- - {pinned.length == 0 && !editMode && ( -
-

- No pinned items yet. Click the edit button to add some! -

-
- )} - {pinned.map((element, index) => ( -
- {element} + + ) : ( + + ) +} - {editMode && ( - { - const newPinned = group.pinnedItems.filter((item) => { - return item.itemId !== group.pinnedItems[index].itemId - }) - updateGroup(group, { pinnedItems: newPinned }) - }} - /> - )} -
- ))} - {editMode && group.pinnedItems && pinned.length < 6 && ( -
- - - -
+export function PinnedItems(props: { + posts: Post[] + isEditable: boolean + pinned: JSX.Element[] + onDeleteClicked: (index: number) => void + onSubmit: (selectedItems: { itemId: string; type: string }[]) => void + group?: Group + modalMessage: string +}) { + const { + isEditable, + pinned, + onDeleteClicked, + onSubmit, + posts, + group, + modalMessage, + } = props + const [editMode, setEditMode] = useState(false) + const [open, setOpen] = useState(false) + + return pinned.length > 0 || isEditable ? ( +
+ + + {isEditable && ( +
- - Pin posts or markets to the overview of this group. + + )} + +
+ + {pinned.length == 0 && !editMode && ( +
+

+ No pinned items yet. Click the edit button to add some! +

- } - onSubmit={onSubmit} - /> + )} + {pinned.map((element, index) => ( +
+ {element} + + {editMode && onDeleteClicked(index)} />} +
+ ))} + {editMode && pinned.length < 6 && ( +
+ + + +
+ )} +
- ) : ( - - ) + {modalMessage}
+ } + onSubmit={onSubmit} + /> +
) : ( <> ) diff --git a/web/components/pinned-select-modal.tsx b/web/components/pinned-select-modal.tsx index e72deee2..c43c7534 100644 --- a/web/components/pinned-select-modal.tsx +++ b/web/components/pinned-select-modal.tsx @@ -20,8 +20,8 @@ export function PinnedSelectModal(props: { selectedItems: { itemId: string; type: string }[] ) => void | Promise contractSearchOptions?: Partial[0]> - group: Group posts: Post[] + group?: Group }) { const { title, @@ -134,8 +134,8 @@ export function PinnedSelectModal(props: { highlightClassName: '!bg-indigo-100 outline outline-2 outline-indigo-300', }} - additionalFilter={{ groupSlug: group.slug }} - persistPrefix={`group-${group.slug}`} + additionalFilter={group ? { groupSlug: group.slug } : undefined} + persistPrefix={group ? `group-${group.slug}` : undefined} headerClassName="bg-white sticky" {...contractSearchOptions} /> @@ -152,7 +152,7 @@ export function PinnedSelectModal(props: { '!bg-indigo-100 outline outline-2 outline-indigo-300', }} /> - {posts.length === 0 && ( + {posts.length == 0 && (
No posts yet
)} From 853e3e48967ac83173269edf698f45ba91e72fdd Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Thu, 6 Oct 2022 14:20:35 -0400 Subject: [PATCH 03/44] Mark @v with a (Bot) label --- web/components/user-link.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx index 4b05ccd0..c3a273fc 100644 --- a/web/components/user-link.tsx +++ b/web/components/user-link.tsx @@ -34,6 +34,17 @@ export function UserLink(props: { )} > {shortName} + {BOT_USERNAMES.includes(username) && } ) } + +const BOT_USERNAMES = ['v'] + +function BotBadge() { + return ( + + Bot + + ) +} From 2f2c586d5de08b87b2857b04acfc34bc78fcabdb Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Thu, 6 Oct 2022 12:01:00 -0700 Subject: [PATCH 04/44] fix padding on daily movers --- web/components/contract/prob-change-table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index 70eaf18c..c5eb9e55 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -37,12 +37,12 @@ export function ProbChangeTable(props: { return ( - + {filteredPositiveChanges.map((contract) => ( ))} - + {filteredNegativeChanges.map((contract) => ( ))} From 91da39370f08d27ec957285f5afea6fe8b697386 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Thu, 6 Oct 2022 14:54:22 -0500 Subject: [PATCH 05/44] fix type errors --- functions/src/scripts/denormalize.ts | 3 +-- functions/src/utils.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/functions/src/scripts/denormalize.ts b/functions/src/scripts/denormalize.ts index d4feb425..3362e940 100644 --- a/functions/src/scripts/denormalize.ts +++ b/functions/src/scripts/denormalize.ts @@ -3,7 +3,6 @@ import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' import { isEqual, zip } from 'lodash' -import { UpdateSpec } from '../utils' export type DocumentValue = { doc: DocumentSnapshot @@ -54,7 +53,7 @@ export function getDiffUpdate(diff: DocumentDiff) { return { doc: diff.dest.doc.ref, fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)), - } as UpdateSpec + } } export function applyDiff(transaction: Transaction, diff: DocumentDiff) { diff --git a/functions/src/utils.ts b/functions/src/utils.ts index efc22e53..91f4b293 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -47,7 +47,7 @@ export const writeAsync = async ( const batch = db.batch() for (const { doc, fields } of chunks[i]) { if (operationType === 'update') { - batch.update(doc, fields) + batch.update(doc, fields as any) } else { batch.set(doc, fields) } From 4162cca3ff4bceff8f93a9eca3e35b9679864d97 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 6 Oct 2022 15:23:51 -0500 Subject: [PATCH 06/44] Wrap sprig init in check for window --- web/lib/service/sprig.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/lib/service/sprig.ts b/web/lib/service/sprig.ts index f89a9678..7a478d77 100644 --- a/web/lib/service/sprig.ts +++ b/web/lib/service/sprig.ts @@ -5,7 +5,7 @@ import { ENV_CONFIG } from 'common/envs/constants' import { PROD_CONFIG } from 'common/envs/prod' -if (ENV_CONFIG.domain === PROD_CONFIG.domain) { +if (ENV_CONFIG.domain === PROD_CONFIG.domain && typeof window !== 'undefined') { try { ;(function (l, e, a, p) { if (window.Sprig) return @@ -20,7 +20,8 @@ if (ENV_CONFIG.domain === PROD_CONFIG.domain) { a.async = 1 a.src = e + '?id=' + S.appId p = l.getElementsByTagName('script')[0] - p.parentNode.insertBefore(a, p) + ENV_CONFIG.domain === PROD_CONFIG.domain && + p.parentNode.insertBefore(a, p) })(document, 'https://cdn.sprig.com/shim.js', ENV_CONFIG.sprigEnvironmentId) } catch (error) { console.log('Error initializing Sprig, please complain to Barak', error) From bc5af50b0ccd92dea103a74144b087d9006dc01b Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Thu, 6 Oct 2022 13:49:39 -0700 Subject: [PATCH 07/44] unindex date-docs from search engines --- web/components/NoSEO.tsx | 10 ++++++++++ web/pages/date-docs/[username].tsx | 2 ++ web/pages/date-docs/create.tsx | 2 ++ web/pages/date-docs/index.tsx | 2 ++ 4 files changed, 16 insertions(+) create mode 100644 web/components/NoSEO.tsx diff --git a/web/components/NoSEO.tsx b/web/components/NoSEO.tsx new file mode 100644 index 00000000..f72907c8 --- /dev/null +++ b/web/components/NoSEO.tsx @@ -0,0 +1,10 @@ +import Head from "next/head"; + +/** Exclude page from search results */ +export function NoSEO() { + return ( + + + + ) +} \ No newline at end of file diff --git a/web/pages/date-docs/[username].tsx b/web/pages/date-docs/[username].tsx index 350e79b7..dd7d5d73 100644 --- a/web/pages/date-docs/[username].tsx +++ b/web/pages/date-docs/[username].tsx @@ -22,6 +22,7 @@ import { PostCommentsActivity, RichEditPost } from '../post/[...slugs]' import { usePost } from 'web/hooks/use-post' import { useTipTxns } from 'web/hooks/use-tip-txns' import { useCommentsOnPost } from 'web/hooks/use-comments' +import { NoSEO } from 'web/components/NoSEO' export async function getStaticProps(props: { params: { username: string } }) { const { username } = props.params @@ -62,6 +63,7 @@ function DateDocPage(props: { creator: User; post: DateDoc }) { return ( + diff --git a/web/pages/date-docs/create.tsx b/web/pages/date-docs/create.tsx index 08442cc1..ed1df677 100644 --- a/web/pages/date-docs/create.tsx +++ b/web/pages/date-docs/create.tsx @@ -14,6 +14,7 @@ import dayjs from 'dayjs' import { MINUTE_MS } from 'common/util/time' import { Col } from 'web/components/layout/col' import { MAX_QUESTION_LENGTH } from 'common/contract' +import { NoSEO } from 'web/components/NoSEO' export default function CreateDateDocPage() { const user = useUser() @@ -64,6 +65,7 @@ export default function CreateDateDocPage() { return ( +
diff --git a/web/pages/date-docs/index.tsx b/web/pages/date-docs/index.tsx index 9ddeb57f..48e0bb13 100644 --- a/web/pages/date-docs/index.tsx +++ b/web/pages/date-docs/index.tsx @@ -12,6 +12,7 @@ import { Button } from 'web/components/button' import { SiteLink } from 'web/components/site-link' import { getUser, User } from 'web/lib/firebase/users' import { DateDocPost } from './[username]' +import { NoSEO } from 'web/components/NoSEO' export async function getStaticProps() { const dateDocs = await getDateDocs() @@ -40,6 +41,7 @@ export default function DatePage(props: { return ( +
From ac37f94cf78f7985d71c0ef1790d8bbc77e36bd7 Mon Sep 17 00:00:00 2001 From: sipec <sipec@users.noreply.github.com> Date: Thu, 6 Oct 2022 20:50:29 +0000 Subject: [PATCH 08/44] Auto-prettification --- web/components/NoSEO.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/NoSEO.tsx b/web/components/NoSEO.tsx index f72907c8..53437b10 100644 --- a/web/components/NoSEO.tsx +++ b/web/components/NoSEO.tsx @@ -1,10 +1,10 @@ -import Head from "next/head"; +import Head from 'next/head' /** Exclude page from search results */ export function NoSEO() { return ( <Head> - <meta name="robots" content="noindex,follow"/> + <meta name="robots" content="noindex,follow" /> </Head> ) -} \ No newline at end of file +} From 7ca0fb72fcb70389884d93ea543f45a7ea2c7546 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 6 Oct 2022 16:36:16 -0500 Subject: [PATCH 09/44] compute elasticity --- common/calculate-metrics.ts | 59 +++++++++++++++++++++++++++++++-- common/contract.ts | 1 + common/new-bet.ts | 5 ++- common/new-contract.ts | 1 + functions/src/update-metrics.ts | 2 ++ 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 7c2153c1..524dfadd 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,9 +1,15 @@ import { last, sortBy, sum, sumBy } from 'lodash' import { calculatePayout } from './calculate' -import { Bet } from './bet' -import { Contract } from './contract' +import { Bet, LimitBet } from './bet' +import { + Contract, + CPMMContract, + DPMContract, +} from './contract' import { PortfolioMetrics, User } from './user' import { DAY_MS } from './util/time' +import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet' +import { getCpmmProbability } from './calculate-cpmm' const computeInvestmentValue = ( bets: Bet[], @@ -40,6 +46,55 @@ export const computeInvestmentValueCustomProb = ( }) } +export const computeElasticity = ( + bets: Bet[], + contract: Contract, + betAmount = 50 +) => { + const { mechanism, outcomeType } = contract + return mechanism === 'cpmm-1' && + (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') + ? computeBinaryCpmmElasticity(bets, contract, betAmount) + : computeDpmElasticity(contract, betAmount) +} + +export const computeBinaryCpmmElasticity = ( + bets: Bet[], + contract: CPMMContract, + betAmount = 50 +) => { + const limitBets = bets + .filter( + (b) => + !b.isFilled && !b.isSold && !b.isRedemption && !b.sale && !b.isCancelled + ) + .sort((a, b) => a.createdTime - b.createdTime) + + const { newPool: poolY, newP: pY } = getBinaryCpmmBetInfo( + 'YES', + betAmount, + contract, + undefined, + limitBets as LimitBet[] + ) + const resultYes = getCpmmProbability(poolY, pY) + + const { newPool: poolN, newP: pN } = getBinaryCpmmBetInfo( + 'NO', + betAmount, + contract, + undefined, + limitBets as LimitBet[] + ) + const resultNo = getCpmmProbability(poolN, pN) + + return resultYes - resultNo +} + +export const computeDpmElasticity = (contract: DPMContract, betAmount = 50) => { + return getNewMultiBetInfo('', betAmount, contract).newBet.probAfter +} + const computeTotalPool = (userContracts: Contract[], startTime = 0) => { const periodFilteredContracts = userContracts.filter( (contract) => contract.createdTime >= startTime diff --git a/common/contract.ts b/common/contract.ts index 1255874d..2656b5d5 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -49,6 +49,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = { volume: number volume24Hours: number volume7Days: number + elasticity: number collectedFees: Fees diff --git a/common/new-bet.ts b/common/new-bet.ts index 91faf640..e9f5c554 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -17,8 +17,7 @@ import { import { CPMMBinaryContract, DPMBinaryContract, - FreeResponseContract, - MultipleChoiceContract, + DPMContract, NumericContract, PseudoNumericContract, } from './contract' @@ -325,7 +324,7 @@ export const getNewBinaryDpmBetInfo = ( export const getNewMultiBetInfo = ( outcome: string, amount: number, - contract: FreeResponseContract | MultipleChoiceContract + contract: DPMContract ) => { const { pool, totalShares, totalBets } = contract diff --git a/common/new-contract.ts b/common/new-contract.ts index 3580b164..8ab44d2e 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -70,6 +70,7 @@ export function getNewContract( volume: 0, volume24Hours: 0, volume7Days: 0, + elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.56, collectedFees: { creatorFee: 0, diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 70c7c742..24dc07e7 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -14,6 +14,7 @@ import { calculateNewPortfolioMetrics, calculateNewProfit, calculateProbChanges, + computeElasticity, computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' @@ -103,6 +104,7 @@ export async function updateMetricsCore() { fields: { volume24Hours: computeVolume(contractBets, now - DAY_MS), volume7Days: computeVolume(contractBets, now - DAY_MS * 7), + elasticity: computeElasticity(contractBets, contract), ...cpmmFields, }, } From a63405ca7c4d43a8f317a0d7028f7c4a5cd7be86 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 6 Oct 2022 16:47:52 -0500 Subject: [PATCH 10/44] change dpm elasticity --- common/calculate-metrics.ts | 15 +++++++-------- common/new-contract.ts | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 524dfadd..bf588345 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,11 +1,7 @@ import { last, sortBy, sum, sumBy } from 'lodash' import { calculatePayout } from './calculate' import { Bet, LimitBet } from './bet' -import { - Contract, - CPMMContract, - DPMContract, -} from './contract' +import { Contract, CPMMContract, DPMContract } from './contract' import { PortfolioMetrics, User } from './user' import { DAY_MS } from './util/time' import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet' @@ -61,7 +57,7 @@ export const computeElasticity = ( export const computeBinaryCpmmElasticity = ( bets: Bet[], contract: CPMMContract, - betAmount = 50 + betAmount: number ) => { const limitBets = bets .filter( @@ -91,8 +87,11 @@ export const computeBinaryCpmmElasticity = ( return resultYes - resultNo } -export const computeDpmElasticity = (contract: DPMContract, betAmount = 50) => { - return getNewMultiBetInfo('', betAmount, contract).newBet.probAfter +export const computeDpmElasticity = ( + contract: DPMContract, + betAmount: number +) => { + return getNewMultiBetInfo('', 2 * betAmount, contract).newBet.probAfter } const computeTotalPool = (userContracts: Contract[], startTime = 0) => { diff --git a/common/new-contract.ts b/common/new-contract.ts index 8ab44d2e..9a73e2ea 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -70,7 +70,7 @@ export function getNewContract( volume: 0, volume24Hours: 0, volume7Days: 0, - elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.56, + elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.75, collectedFees: { creatorFee: 0, From adb809f9733fe54a7a3862f75aa0ab35e78bb638 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 6 Oct 2022 15:19:37 -0700 Subject: [PATCH 11/44] Fix google lighthouse issues (#1013) --- web/components/nav/manifold-logo.tsx | 1 + web/components/nav/more-button.tsx | 4 ++-- web/pages/_app.tsx | 5 +---- web/pages/_document.tsx | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/web/components/nav/manifold-logo.tsx b/web/components/nav/manifold-logo.tsx index ec15d54b..b6dbc885 100644 --- a/web/components/nav/manifold-logo.tsx +++ b/web/components/nav/manifold-logo.tsx @@ -22,6 +22,7 @@ export function ManifoldLogo(props: { src={darkBackground ? '/logo-white.svg' : '/logo.svg'} width={45} height={45} + alt="" /> {!hideText && diff --git a/web/components/nav/more-button.tsx b/web/components/nav/more-button.tsx index 5e6653f3..9847541c 100644 --- a/web/components/nav/more-button.tsx +++ b/web/components/nav/more-button.tsx @@ -11,13 +11,13 @@ function SidebarButton(props: { }) { const { text, children } = props return ( - <a className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100"> + <button className="group flex w-full items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100"> <props.icon className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500" aria-hidden="true" /> <span className="truncate">{text}</span> {children} - </a> + </button> ) } diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index 48ad5a9a..7a96b2e2 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -74,10 +74,7 @@ function MyApp({ Component, pageProps }: AppProps<ManifoldPageProps>) { content="https://manifold.markets/logo-bg-white.png" key="image2" /> - <meta - name="viewport" - content="width=device-width, initial-scale=1, maximum-scale=1" - /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> </Head> <AuthProvider serverUser={pageProps.auth}> <QueryClientProvider client={queryClient}> diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx index b8cb657c..f2c46854 100644 --- a/web/pages/_document.tsx +++ b/web/pages/_document.tsx @@ -3,7 +3,7 @@ import { ENV_CONFIG } from 'common/envs/constants' export default function Document() { return ( - <Html data-theme="mantic" className="min-h-screen"> + <Html lang="en" data-theme="mantic" className="min-h-screen"> <Head> <link rel="icon" href={ENV_CONFIG.faviconPath} /> <link From d9c8925ea0a7e5c354307d4e4f1b4f24e2d8aad7 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 6 Oct 2022 15:20:46 -0700 Subject: [PATCH 12/44] don't hide free response panel on open resolve --- web/components/answers/answers-panel.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 08b1373f..6b35f74e 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -157,11 +157,9 @@ export function AnswersPanel(props: { <div className="pb-4 text-gray-500">No answers yet...</div> )} - {outcomeType === 'FREE_RESPONSE' && - tradingAllowed(contract) && - (!resolveOption || resolveOption === 'CANCEL') && ( - <CreateAnswerPanel contract={contract} /> - )} + {outcomeType === 'FREE_RESPONSE' && tradingAllowed(contract) && ( + <CreateAnswerPanel contract={contract} /> + )} {(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) && !resolution && ( From 9d12fa3af001185d1eedccc2889e004958c4fa4c Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 6 Oct 2022 18:03:44 -0500 Subject: [PATCH 13/44] Limit order trade log: '/' to 'of'. Remove 'of' in 'of YES'. --- web/components/feed/feed-bets.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index 900265cb..e164f6fa 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -64,11 +64,11 @@ export function BetStatusText(props: { }, [challengeSlug, contract.id]) const bought = amount >= 0 ? 'bought' : 'sold' + const money = formatMoney(Math.abs(amount)) const outOfTotalAmount = bet.limitProb !== undefined && bet.orderAmount !== undefined - ? ` / ${formatMoney(bet.orderAmount)}` + ? ` of ${bet.isCancelled ? money : formatMoney(bet.orderAmount)}` : '' - const money = formatMoney(Math.abs(amount)) const hadPoolMatch = (bet.limitProb === undefined || @@ -105,7 +105,6 @@ export function BetStatusText(props: { {!hideOutcome && ( <> {' '} - of{' '} <OutcomeLabel outcome={outcome} value={(bet as any).value} From 80622dc7ee59269d7d69100984f219a2243184a0 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 6 Oct 2022 18:23:27 -0500 Subject: [PATCH 14/44] liquidity sort --- web/components/contract-search.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 78eacd36..8d871d65 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -48,6 +48,7 @@ export const SORTS = [ { label: 'Daily trending', value: 'daily-score' }, { label: '24h volume', value: '24-hour-vol' }, { label: 'Most popular', value: 'most-popular' }, + { label: 'Liquidity', value: 'liquidity' }, { label: 'Last updated', value: 'last-updated' }, { label: 'Closing soon', value: 'close-date' }, { label: 'Resolve date', value: 'resolve-date' }, From badd67c278b7ae6a7c47ab486a9e2271012e285f Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 6 Oct 2022 18:36:27 -0500 Subject: [PATCH 15/44] Date doc: Toggle to disable creating a prediction market --- functions/src/create-post.ts | 30 +++++++++++++++++------------- web/pages/date-docs/[username].tsx | 20 +++++++++++--------- web/pages/date-docs/create.tsx | 26 +++++++++++++++++++------- 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index 96e3c66a..d1864ac2 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -71,19 +71,23 @@ export const createpost = newEndpoint({}, async (req, auth) => { if (question) { const closeTime = Date.now() + DAY_MS * 30 * 3 - const result = await createMarketHelper( - { - question, - closeTime, - outcomeType: 'BINARY', - visibility: 'unlisted', - initialProb: 50, - // Dating group! - groupId: 'j3ZE8fkeqiKmRGumy3O1', - }, - auth - ) - contractSlug = result.slug + try { + const result = await createMarketHelper( + { + question, + closeTime, + outcomeType: 'BINARY', + visibility: 'unlisted', + initialProb: 50, + // Dating group! + groupId: 'j3ZE8fkeqiKmRGumy3O1', + }, + auth + ) + contractSlug = result.slug + } catch (e) { + console.error(e) + } } const post: Post = removeUndefinedProps({ diff --git a/web/pages/date-docs/[username].tsx b/web/pages/date-docs/[username].tsx index dd7d5d73..6f6eaf5e 100644 --- a/web/pages/date-docs/[username].tsx +++ b/web/pages/date-docs/[username].tsx @@ -142,15 +142,17 @@ export function DateDocPost(props: { ) : ( <Content content={content} /> )} - <div className="mt-4 w-full max-w-lg self-center rounded-xl bg-gradient-to-r from-blue-200 via-purple-200 to-indigo-300 p-3"> - <iframe - height="405" - src={marketUrl} - title="" - frameBorder="0" - className="w-full rounded-xl bg-white p-10" - ></iframe> - </div> + {contractSlug && ( + <div className="mt-4 w-full max-w-lg self-center rounded-xl bg-gradient-to-r from-blue-200 via-purple-200 to-indigo-300 p-3"> + <iframe + height="405" + src={marketUrl} + title="" + frameBorder="0" + className="w-full rounded-xl bg-white p-10" + ></iframe> + </div> + )} </Col> ) } diff --git a/web/pages/date-docs/create.tsx b/web/pages/date-docs/create.tsx index ed1df677..a0fe8922 100644 --- a/web/pages/date-docs/create.tsx +++ b/web/pages/date-docs/create.tsx @@ -15,6 +15,8 @@ import { MINUTE_MS } from 'common/util/time' import { Col } from 'web/components/layout/col' import { MAX_QUESTION_LENGTH } from 'common/contract' import { NoSEO } from 'web/components/NoSEO' +import ShortToggle from 'web/components/widgets/short-toggle' +import { removeUndefinedProps } from 'common/util/object' export default function CreateDateDocPage() { const user = useUser() @@ -26,6 +28,7 @@ export default function CreateDateDocPage() { const title = `${user?.name}'s Date Doc` const subtitle = 'Manifold dating docs' const [birthday, setBirthday] = useState<undefined | string>(undefined) + const [createMarket, setCreateMarket] = useState(true) const [question, setQuestion] = useState( 'Will I find a partner in the next 3 months?' ) @@ -38,7 +41,11 @@ export default function CreateDateDocPage() { const birthdayTime = birthday ? dayjs(birthday).valueOf() : undefined const isValid = - user && birthday && editor && editor.isEmpty === false && question + user && + birthday && + editor && + editor.isEmpty === false && + (question || !createMarket) async function saveDateDoc() { if (!user || !editor || !birthdayTime) return @@ -46,15 +53,15 @@ export default function CreateDateDocPage() { const newPost: Omit< DateDoc, 'id' | 'creatorId' | 'createdTime' | 'slug' | 'contractSlug' - > & { question: string } = { + > & { question?: string } = removeUndefinedProps({ title, subtitle, content: editor.getJSON(), bounty: 0, birthday: birthdayTime, type: 'date-doc', - question, - } + question: createMarket ? question : undefined, + }) const result = await createPost(newPost) @@ -106,9 +113,13 @@ export default function CreateDateDocPage() { </Col> <Col className="gap-4"> - <div className=""> - Finally, we'll create an (unlisted) prediction market! - </div> + <Row className="items-center gap-4"> + <ShortToggle + on={createMarket} + setOn={(on) => setCreateMarket(on)} + /> + Create an (unlisted) prediction market attached to the date doc + </Row> <Col className="gap-2"> <Textarea @@ -116,6 +127,7 @@ export default function CreateDateDocPage() { maxLength={MAX_QUESTION_LENGTH} value={question} onChange={(e) => setQuestion(e.target.value || '')} + disabled={!createMarket} /> <div className="ml-2 text-gray-500">Cost: M$100</div> </Col> From 77e0631ea45aba6cff3a1b1e3d7268873faacabd Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 6 Oct 2022 18:03:44 -0500 Subject: [PATCH 16/44] Limit order trade log: '/' to 'of'. Remove 'of' in 'of YES'. --- web/components/feed/feed-bets.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index 900265cb..e164f6fa 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -64,11 +64,11 @@ export function BetStatusText(props: { }, [challengeSlug, contract.id]) const bought = amount >= 0 ? 'bought' : 'sold' + const money = formatMoney(Math.abs(amount)) const outOfTotalAmount = bet.limitProb !== undefined && bet.orderAmount !== undefined - ? ` / ${formatMoney(bet.orderAmount)}` + ? ` of ${bet.isCancelled ? money : formatMoney(bet.orderAmount)}` : '' - const money = formatMoney(Math.abs(amount)) const hadPoolMatch = (bet.limitProb === undefined || @@ -105,7 +105,6 @@ export function BetStatusText(props: { {!hideOutcome && ( <> {' '} - of{' '} <OutcomeLabel outcome={outcome} value={(bet as any).value} From d846b9fb3064fd82b698aaa3683921fa28d12e24 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 6 Oct 2022 18:36:27 -0500 Subject: [PATCH 17/44] Date doc: Toggle to disable creating a prediction market --- functions/src/create-post.ts | 30 +++++++++++++++++------------- web/pages/date-docs/[username].tsx | 20 +++++++++++--------- web/pages/date-docs/create.tsx | 26 +++++++++++++++++++------- 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index 96e3c66a..d1864ac2 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -71,19 +71,23 @@ export const createpost = newEndpoint({}, async (req, auth) => { if (question) { const closeTime = Date.now() + DAY_MS * 30 * 3 - const result = await createMarketHelper( - { - question, - closeTime, - outcomeType: 'BINARY', - visibility: 'unlisted', - initialProb: 50, - // Dating group! - groupId: 'j3ZE8fkeqiKmRGumy3O1', - }, - auth - ) - contractSlug = result.slug + try { + const result = await createMarketHelper( + { + question, + closeTime, + outcomeType: 'BINARY', + visibility: 'unlisted', + initialProb: 50, + // Dating group! + groupId: 'j3ZE8fkeqiKmRGumy3O1', + }, + auth + ) + contractSlug = result.slug + } catch (e) { + console.error(e) + } } const post: Post = removeUndefinedProps({ diff --git a/web/pages/date-docs/[username].tsx b/web/pages/date-docs/[username].tsx index dd7d5d73..6f6eaf5e 100644 --- a/web/pages/date-docs/[username].tsx +++ b/web/pages/date-docs/[username].tsx @@ -142,15 +142,17 @@ export function DateDocPost(props: { ) : ( <Content content={content} /> )} - <div className="mt-4 w-full max-w-lg self-center rounded-xl bg-gradient-to-r from-blue-200 via-purple-200 to-indigo-300 p-3"> - <iframe - height="405" - src={marketUrl} - title="" - frameBorder="0" - className="w-full rounded-xl bg-white p-10" - ></iframe> - </div> + {contractSlug && ( + <div className="mt-4 w-full max-w-lg self-center rounded-xl bg-gradient-to-r from-blue-200 via-purple-200 to-indigo-300 p-3"> + <iframe + height="405" + src={marketUrl} + title="" + frameBorder="0" + className="w-full rounded-xl bg-white p-10" + ></iframe> + </div> + )} </Col> ) } diff --git a/web/pages/date-docs/create.tsx b/web/pages/date-docs/create.tsx index ed1df677..a0fe8922 100644 --- a/web/pages/date-docs/create.tsx +++ b/web/pages/date-docs/create.tsx @@ -15,6 +15,8 @@ import { MINUTE_MS } from 'common/util/time' import { Col } from 'web/components/layout/col' import { MAX_QUESTION_LENGTH } from 'common/contract' import { NoSEO } from 'web/components/NoSEO' +import ShortToggle from 'web/components/widgets/short-toggle' +import { removeUndefinedProps } from 'common/util/object' export default function CreateDateDocPage() { const user = useUser() @@ -26,6 +28,7 @@ export default function CreateDateDocPage() { const title = `${user?.name}'s Date Doc` const subtitle = 'Manifold dating docs' const [birthday, setBirthday] = useState<undefined | string>(undefined) + const [createMarket, setCreateMarket] = useState(true) const [question, setQuestion] = useState( 'Will I find a partner in the next 3 months?' ) @@ -38,7 +41,11 @@ export default function CreateDateDocPage() { const birthdayTime = birthday ? dayjs(birthday).valueOf() : undefined const isValid = - user && birthday && editor && editor.isEmpty === false && question + user && + birthday && + editor && + editor.isEmpty === false && + (question || !createMarket) async function saveDateDoc() { if (!user || !editor || !birthdayTime) return @@ -46,15 +53,15 @@ export default function CreateDateDocPage() { const newPost: Omit< DateDoc, 'id' | 'creatorId' | 'createdTime' | 'slug' | 'contractSlug' - > & { question: string } = { + > & { question?: string } = removeUndefinedProps({ title, subtitle, content: editor.getJSON(), bounty: 0, birthday: birthdayTime, type: 'date-doc', - question, - } + question: createMarket ? question : undefined, + }) const result = await createPost(newPost) @@ -106,9 +113,13 @@ export default function CreateDateDocPage() { </Col> <Col className="gap-4"> - <div className=""> - Finally, we'll create an (unlisted) prediction market! - </div> + <Row className="items-center gap-4"> + <ShortToggle + on={createMarket} + setOn={(on) => setCreateMarket(on)} + /> + Create an (unlisted) prediction market attached to the date doc + </Row> <Col className="gap-2"> <Textarea @@ -116,6 +127,7 @@ export default function CreateDateDocPage() { maxLength={MAX_QUESTION_LENGTH} value={question} onChange={(e) => setQuestion(e.target.value || '')} + disabled={!createMarket} /> <div className="ml-2 text-gray-500">Cost: M$100</div> </Col> From 0dc8753a921366fb3d69814dc005c2d4a1264f46 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 6 Oct 2022 18:50:53 -0500 Subject: [PATCH 18/44] Listen for date doc changes --- web/hooks/use-post.ts | 14 ++++++++++++-- web/lib/firebase/posts.ts | 13 ++++++++++++- web/pages/date-docs/index.tsx | 4 +++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/web/hooks/use-post.ts b/web/hooks/use-post.ts index ff7bf6b9..1fd69888 100644 --- a/web/hooks/use-post.ts +++ b/web/hooks/use-post.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { Post } from 'common/post' -import { listenForPost } from 'web/lib/firebase/posts' +import { DateDoc, Post } from 'common/post' +import { listenForDateDocs, listenForPost } from 'web/lib/firebase/posts' export const usePost = (postId: string | undefined) => { const [post, setPost] = useState<Post | null | undefined>() @@ -37,3 +37,13 @@ export const usePosts = (postIds: string[]) => { ) .sort((a, b) => b.createdTime - a.createdTime) } + +export const useDateDocs = () => { + const [dateDocs, setDateDocs] = useState<DateDoc[]>() + + useEffect(() => { + return listenForDateDocs(setDateDocs) + }, []) + + return dateDocs +} diff --git a/web/lib/firebase/posts.ts b/web/lib/firebase/posts.ts index 22b9d095..343243cd 100644 --- a/web/lib/firebase/posts.ts +++ b/web/lib/firebase/posts.ts @@ -7,7 +7,13 @@ import { where, } from 'firebase/firestore' import { DateDoc, Post } from 'common/post' -import { coll, getValue, getValues, listenForValue } from './utils' +import { + coll, + getValue, + getValues, + listenForValue, + listenForValues, +} from './utils' import { getUserByUsername } from './users' export const posts = coll<Post>('posts') @@ -51,6 +57,11 @@ export async function getDateDocs() { return getValues<DateDoc>(q) } +export function listenForDateDocs(setDateDocs: (dateDocs: DateDoc[]) => void) { + const q = query(posts, where('type', '==', 'date-doc')) + return listenForValues<DateDoc>(q, setDateDocs) +} + export async function getDateDoc(username: string) { const user = await getUserByUsername(username) if (!user) return null diff --git a/web/pages/date-docs/index.tsx b/web/pages/date-docs/index.tsx index 48e0bb13..f25746ee 100644 --- a/web/pages/date-docs/index.tsx +++ b/web/pages/date-docs/index.tsx @@ -13,6 +13,7 @@ import { SiteLink } from 'web/components/site-link' import { getUser, User } from 'web/lib/firebase/users' import { DateDocPost } from './[username]' import { NoSEO } from 'web/components/NoSEO' +import { useDateDocs } from 'web/hooks/use-post' export async function getStaticProps() { const dateDocs = await getDateDocs() @@ -34,9 +35,10 @@ export default function DatePage(props: { dateDocs: DateDoc[] docCreators: User[] }) { - const { dateDocs, docCreators } = props + const { docCreators } = props const user = useUser() + const dateDocs = useDateDocs() ?? props.dateDocs const hasDoc = dateDocs.some((d) => d.creatorId === user?.id) return ( From b1d386ca5ad1c00e2f7eb1bc77f35296caa72e6f Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 6 Oct 2022 18:50:53 -0500 Subject: [PATCH 19/44] Listen for date doc changes --- web/hooks/use-post.ts | 14 ++++++++++++-- web/lib/firebase/posts.ts | 13 ++++++++++++- web/pages/date-docs/index.tsx | 4 +++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/web/hooks/use-post.ts b/web/hooks/use-post.ts index ff7bf6b9..1fd69888 100644 --- a/web/hooks/use-post.ts +++ b/web/hooks/use-post.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { Post } from 'common/post' -import { listenForPost } from 'web/lib/firebase/posts' +import { DateDoc, Post } from 'common/post' +import { listenForDateDocs, listenForPost } from 'web/lib/firebase/posts' export const usePost = (postId: string | undefined) => { const [post, setPost] = useState<Post | null | undefined>() @@ -37,3 +37,13 @@ export const usePosts = (postIds: string[]) => { ) .sort((a, b) => b.createdTime - a.createdTime) } + +export const useDateDocs = () => { + const [dateDocs, setDateDocs] = useState<DateDoc[]>() + + useEffect(() => { + return listenForDateDocs(setDateDocs) + }, []) + + return dateDocs +} diff --git a/web/lib/firebase/posts.ts b/web/lib/firebase/posts.ts index 22b9d095..343243cd 100644 --- a/web/lib/firebase/posts.ts +++ b/web/lib/firebase/posts.ts @@ -7,7 +7,13 @@ import { where, } from 'firebase/firestore' import { DateDoc, Post } from 'common/post' -import { coll, getValue, getValues, listenForValue } from './utils' +import { + coll, + getValue, + getValues, + listenForValue, + listenForValues, +} from './utils' import { getUserByUsername } from './users' export const posts = coll<Post>('posts') @@ -51,6 +57,11 @@ export async function getDateDocs() { return getValues<DateDoc>(q) } +export function listenForDateDocs(setDateDocs: (dateDocs: DateDoc[]) => void) { + const q = query(posts, where('type', '==', 'date-doc')) + return listenForValues<DateDoc>(q, setDateDocs) +} + export async function getDateDoc(username: string) { const user = await getUserByUsername(username) if (!user) return null diff --git a/web/pages/date-docs/index.tsx b/web/pages/date-docs/index.tsx index 48e0bb13..f25746ee 100644 --- a/web/pages/date-docs/index.tsx +++ b/web/pages/date-docs/index.tsx @@ -13,6 +13,7 @@ import { SiteLink } from 'web/components/site-link' import { getUser, User } from 'web/lib/firebase/users' import { DateDocPost } from './[username]' import { NoSEO } from 'web/components/NoSEO' +import { useDateDocs } from 'web/hooks/use-post' export async function getStaticProps() { const dateDocs = await getDateDocs() @@ -34,9 +35,10 @@ export default function DatePage(props: { dateDocs: DateDoc[] docCreators: User[] }) { - const { dateDocs, docCreators } = props + const { docCreators } = props const user = useUser() + const dateDocs = useDateDocs() ?? props.dateDocs const hasDoc = dateDocs.some((d) => d.creatorId === user?.id) return ( From 42a7d04b4dc344c55920be706000012b763dc5f6 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 6 Oct 2022 20:17:26 -0400 Subject: [PATCH 20/44] Tag ArbitrageBot with bot badge --- web/components/user-link.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx index c3a273fc..d7f660ae 100644 --- a/web/components/user-link.tsx +++ b/web/components/user-link.tsx @@ -39,7 +39,7 @@ export function UserLink(props: { ) } -const BOT_USERNAMES = ['v'] +const BOT_USERNAMES = ['v', 'ArbitrageBot'] function BotBadge() { return ( From 25333317b0561d1beead471e1bb8b0014c03175f Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 6 Oct 2022 21:52:15 -0500 Subject: [PATCH 21/44] Show elasticity; volume tooltip --- .../contract/contract-info-dialog.tsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 6cb0c7ed..1f33e4e1 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -5,7 +5,7 @@ import { useState } from 'react' import { capitalize } from 'lodash' import { Contract } from 'common/contract' -import { formatMoney } from 'common/util/format' +import { formatMoney, formatPercent } from 'common/util/format' import { contractPool, updateContract } from 'web/lib/firebase/contracts' import { LiquidityBountyPanel } from 'web/components/contract/liquidity-bounty-panel' import { Col } from '../layout/col' @@ -54,6 +54,7 @@ export function ContractInfoDialog(props: { mechanism, outcomeType, id, + elasticity, } = contract const typeDisplay = @@ -142,7 +143,10 @@ export function ContractInfoDialog(props: { )} <tr> - <td>Volume</td> + <td> + <span className="mr-1">Volume</span> + <InfoTooltip text="Total amount bought or sold" /> + </td> <td>{formatMoney(contract.volume)}</td> </tr> @@ -151,6 +155,22 @@ export function ContractInfoDialog(props: { <td>{uniqueBettorCount ?? '0'}</td> </tr> + <tr> + <td> + <Row> + <span className="mr-1">Elasticity</span> + <InfoTooltip + text={ + mechanism === 'cpmm-1' + ? 'Probability change between a M$50 bet on YES and NO' + : 'Probability change from a M$100 bet' + } + /> + </Row> + </td> + <td>{formatPercent(elasticity)}</td> + </tr> + <tr> <td> {mechanism === 'cpmm-1' ? 'Liquidity pool' : 'Betting pool'} From 71b0c7172901b6fc5ae9df15507a8f5fb4f149c9 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 6 Oct 2022 20:17:26 -0400 Subject: [PATCH 22/44] Tag ArbitrageBot with bot badge --- web/components/user-link.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx index c3a273fc..d7f660ae 100644 --- a/web/components/user-link.tsx +++ b/web/components/user-link.tsx @@ -39,7 +39,7 @@ export function UserLink(props: { ) } -const BOT_USERNAMES = ['v'] +const BOT_USERNAMES = ['v', 'ArbitrageBot'] function BotBadge() { return ( From f533d9bfcb11555a67dc885f0e04c971b1e4b6d9 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 6 Oct 2022 22:16:49 -0500 Subject: [PATCH 23/44] Verify balance of limit order "makers" (#1007) * Fetch balance of users with open limit orders & cancel orders with insufficient balance * Fix imports * Fix bugs * Fix a bug * Remove redundant cast * buttons overlaying content fix (#1005) * buttons overlaying content fix * stats: round DAU number * made set width for portfolio/profit fields (#1006) * tournaments: included resolved markets * made delete red, moved button for regular posts (#1008) * Fix localstorage saved user being overwritten on every page load * Market page: Show no right panel while user loading * Don't flash sign in button if user is loading * election map coloring * market group modal scroll fix (#1009) * midterms: posititoning, make mobile friendly * Un-daisy share buttons (#1010) * Make embed and challenge buttons non-daisyui * Allow link Buttons. Change tweet, dupe buttons. * lint * don't insert extra lines when upload photos * Map fixes (#1011) * usa map: fix sizing * useSetIframeBackbroundColor * preload contracts * seo * remove hook * turn off sprig on dev * Render timestamp only on client to prevent error of server not matching client * Make sized container have default height so graph doesn't jump * midterms: use null in static props * Create common card component (#1012) * Create common card component * lint * add key prop to pills * redirect to /home after login * create market: use transaction * card: reduce border size * Update groupContracts in db trigger * Default sort to best * Save comment sort per user rather than per contract * Refactor Pinned Items into a reusable component * Revert "create market: use transaction" This reverts commit e1f24f24a96fa8d811ebcaa3b10b19d9b67cb282. * Mark @v with a (Bot) label * fix padding on daily movers * fix type errors * Wrap sprig init in check for window * unindex date-docs from search engines * Auto-prettification * compute elasticity * change dpm elasticity * Fix google lighthouse issues (#1013) * don't hide free response panel on open resolve * liquidity sort * Limit order trade log: '/' to 'of'. Remove 'of' in 'of YES'. * Date doc: Toggle to disable creating a prediction market * Listen for date doc changes * Fix merge error * Don't cancel all a users limit orders if they go negative Co-authored-by: ingawei <46611122+ingawei@users.noreply.github.com> Co-authored-by: mantikoros <sgrugett@gmail.com> Co-authored-by: Sinclair Chen <abc.sinclair@gmail.com> Co-authored-by: mantikoros <95266179+mantikoros@users.noreply.github.com> Co-authored-by: Ian Philips <iansphilips@gmail.com> Co-authored-by: Pico2x <pico2x@gmail.com> Co-authored-by: Austin Chen <akrolsmir@gmail.com> Co-authored-by: sipec <sipec@users.noreply.github.com> --- common/calculate-cpmm.ts | 29 ++++++++++---- common/calculate-metrics.ts | 23 ++++++++--- common/calculate.ts | 12 ++++-- common/new-bet.ts | 35 ++++++++++++---- common/sell-bet.ts | 7 +++- functions/src/on-update-user.ts | 18 --------- functions/src/place-bet.ts | 44 ++++++++++++++++++--- functions/src/sell-shares.ts | 42 ++++++++++++-------- web/components/arrange-home.tsx | 2 +- web/components/bet-button.tsx | 7 +++- web/components/bet-inline.tsx | 9 +++-- web/components/bet-panel.tsx | 57 +++++++++++++++++++++------ web/components/bets-list.tsx | 29 ++++++++++---- web/components/contract/quick-bet.tsx | 10 +++-- web/hooks/use-bets.ts | 29 ++++++++++++++ 15 files changed, 256 insertions(+), 97 deletions(-) diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index b5153355..346fca79 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -147,7 +147,8 @@ function calculateAmountToBuyShares( state: CpmmState, shares: number, outcome: 'YES' | 'NO', - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { // Search for amount between bounds (0, shares). // Min share price is M$0, and max is M$1 each. @@ -157,7 +158,8 @@ function calculateAmountToBuyShares( amount, state, undefined, - unfilledBets + unfilledBets, + balanceByUserId ) const totalShares = sumBy(takers, (taker) => taker.shares) @@ -169,7 +171,8 @@ export function calculateCpmmSale( state: CpmmState, shares: number, outcome: 'YES' | 'NO', - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { if (Math.round(shares) < 0) { throw new Error('Cannot sell non-positive shares') @@ -180,15 +183,17 @@ export function calculateCpmmSale( state, shares, oppositeOutcome, - unfilledBets + unfilledBets, + balanceByUserId ) - const { cpmmState, makers, takers, totalFees } = computeFills( + const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills( oppositeOutcome, buyAmount, state, undefined, - unfilledBets + unfilledBets, + balanceByUserId ) // Transform buys of opposite outcome into sells. @@ -211,6 +216,7 @@ export function calculateCpmmSale( fees: totalFees, makers, takers: saleTakers, + ordersToCancel, } } @@ -218,9 +224,16 @@ export function getCpmmProbabilityAfterSale( state: CpmmState, shares: number, outcome: 'YES' | 'NO', - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { - const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets) + const { cpmmState } = calculateCpmmSale( + state, + shares, + outcome, + unfilledBets, + balanceByUserId + ) return getCpmmProbability(cpmmState.pool, cpmmState.p) } diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index bf588345..6cfb0421 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,4 +1,4 @@ -import { last, sortBy, sum, sumBy } from 'lodash' +import { last, sortBy, sum, sumBy, uniq } from 'lodash' import { calculatePayout } from './calculate' import { Bet, LimitBet } from './bet' import { Contract, CPMMContract, DPMContract } from './contract' @@ -62,16 +62,28 @@ export const computeBinaryCpmmElasticity = ( const limitBets = bets .filter( (b) => - !b.isFilled && !b.isSold && !b.isRedemption && !b.sale && !b.isCancelled + !b.isFilled && + !b.isSold && + !b.isRedemption && + !b.sale && + !b.isCancelled && + b.limitProb !== undefined ) - .sort((a, b) => a.createdTime - b.createdTime) + .sort((a, b) => a.createdTime - b.createdTime) as LimitBet[] + + const userIds = uniq(limitBets.map((b) => b.userId)) + // Assume all limit orders are good. + const userBalances = Object.fromEntries( + userIds.map((id) => [id, Number.MAX_SAFE_INTEGER]) + ) const { newPool: poolY, newP: pY } = getBinaryCpmmBetInfo( 'YES', betAmount, contract, undefined, - limitBets as LimitBet[] + limitBets, + userBalances ) const resultYes = getCpmmProbability(poolY, pY) @@ -80,7 +92,8 @@ export const computeBinaryCpmmElasticity = ( betAmount, contract, undefined, - limitBets as LimitBet[] + limitBets, + userBalances ) const resultNo = getCpmmProbability(poolN, pN) diff --git a/common/calculate.ts b/common/calculate.ts index 6481734f..44dc9113 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -78,7 +78,8 @@ export function calculateShares( export function calculateSaleAmount( contract: Contract, bet: Bet, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { return contract.mechanism === 'cpmm-1' && (contract.outcomeType === 'BINARY' || @@ -87,7 +88,8 @@ export function calculateSaleAmount( contract, Math.abs(bet.shares), bet.outcome as 'YES' | 'NO', - unfilledBets + unfilledBets, + balanceByUserId ).saleValue : calculateDpmSaleAmount(contract, bet) } @@ -102,14 +104,16 @@ export function getProbabilityAfterSale( contract: Contract, outcome: string, shares: number, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { return contract.mechanism === 'cpmm-1' ? getCpmmProbabilityAfterSale( contract, shares, outcome as 'YES' | 'NO', - unfilledBets + unfilledBets, + balanceByUserId ) : getDpmProbabilityAfterSale(contract.totalShares, outcome, shares) } diff --git a/common/new-bet.ts b/common/new-bet.ts index e9f5c554..8057cd5b 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -143,7 +143,8 @@ export const computeFills = ( betAmount: number, state: CpmmState, limitProb: number | undefined, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) => { if (isNaN(betAmount)) { throw new Error('Invalid bet amount: ${betAmount}') @@ -165,10 +166,12 @@ export const computeFills = ( shares: number timestamp: number }[] = [] + const ordersToCancel: LimitBet[] = [] let amount = betAmount let cpmmState = { pool: state.pool, p: state.p } let totalFees = noFees + const currentBalanceByUserId = { ...balanceByUserId } let i = 0 while (true) { @@ -185,9 +188,20 @@ export const computeFills = ( takers.push(taker) } else { // Matched against bet. + i++ + const { userId } = maker.bet + const makerBalance = currentBalanceByUserId[userId] + + if (floatingGreaterEqual(makerBalance, maker.amount)) { + currentBalanceByUserId[userId] = makerBalance - maker.amount + } else { + // Insufficient balance. Cancel maker bet. + ordersToCancel.push(maker.bet) + continue + } + takers.push(taker) makers.push(maker) - i++ } amount -= taker.amount @@ -195,7 +209,7 @@ export const computeFills = ( if (floatingEqual(amount, 0)) break } - return { takers, makers, totalFees, cpmmState } + return { takers, makers, totalFees, cpmmState, ordersToCancel } } export const getBinaryCpmmBetInfo = ( @@ -203,15 +217,17 @@ export const getBinaryCpmmBetInfo = ( betAmount: number, contract: CPMMBinaryContract | PseudoNumericContract, limitProb: number | undefined, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) => { const { pool, p } = contract - const { takers, makers, cpmmState, totalFees } = computeFills( + const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills( outcome, betAmount, { pool, p }, limitProb, - unfilledBets + unfilledBets, + balanceByUserId ) const probBefore = getCpmmProbability(contract.pool, contract.p) const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) @@ -246,6 +262,7 @@ export const getBinaryCpmmBetInfo = ( newP: cpmmState.p, newTotalLiquidity, makers, + ordersToCancel, } } @@ -254,14 +271,16 @@ export const getBinaryBetStats = ( betAmount: number, contract: CPMMBinaryContract | PseudoNumericContract, limitProb: number, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) => { const { newBet } = getBinaryCpmmBetInfo( outcome, betAmount ?? 0, contract, limitProb, - unfilledBets as LimitBet[] + unfilledBets, + balanceByUserId ) const remainingMatched = ((newBet.orderAmount ?? 0) - newBet.amount) / diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 96636ca0..1b56c819 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = ( outcome: 'YES' | 'NO', contract: CPMMContract, unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number }, loanPaid: number ) => { const { pool, p } = contract - const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale( + const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale( contract, shares, outcome, - unfilledBets + unfilledBets, + balanceByUserId, ) const probBefore = getCpmmProbability(pool, p) @@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = ( fees, makers, takers, + ordersToCancel } } diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts index b45809d0..66a6884c 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' import { createReferralNotification } from './create-notification' import { ReferralTxn } from '../../common/txn' import { Contract } from '../../common/contract' -import { LimitBet } from '../../common/bet' -import { QuerySnapshot } from 'firebase-admin/firestore' import { Group } from '../../common/group' import { REFERRAL_AMOUNT } from '../../common/economy' const firestore = admin.firestore() @@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore if (prevUser.referredByUserId !== user.referredByUserId) { await handleUserUpdatedReferral(user, eventId) } - - if (user.balance <= 0) { - await cancelLimitOrders(user.id) - } }) async function handleUserUpdatedReferral(user: User, eventId: string) { @@ -123,15 +117,3 @@ async function handleUserUpdatedReferral(user: User, eventId: string) { ) }) } - -async function cancelLimitOrders(userId: string) { - const snapshot = (await firestore - .collectionGroup('bets') - .where('userId', '==', userId) - .where('isFilled', '==', false) - .get()) as QuerySnapshot<LimitBet> - - await Promise.all( - snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true })) - ) -} diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 74df7dc3..50c89912 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -23,6 +23,7 @@ import { floatingEqual } from '../../common/util/math' import { redeemShares } from './redeem-shares' import { log } from './utils' import { addUserToContractFollowers } from './follow-market' +import { filterDefined } from '../../common/util/array' const bodySchema = z.object({ contractId: z.string(), @@ -73,9 +74,11 @@ export const placebet = newEndpoint({}, async (req, auth) => { newTotalLiquidity, newP, makers, + ordersToCancel, } = await (async (): Promise< BetInfo & { makers?: maker[] + ordersToCancel?: LimitBet[] } > => { if ( @@ -99,17 +102,16 @@ export const placebet = newEndpoint({}, async (req, auth) => { limitProb = Math.round(limitProb * 100) / 100 } - const unfilledBetsSnap = await trans.get( - getUnfilledBetsQuery(contractDoc) - ) - const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + const { unfilledBets, balanceByUserId } = + await getUnfilledBetsAndUserBalances(trans, contractDoc) return getBinaryCpmmBetInfo( outcome, amount, contract, limitProb, - unfilledBets + unfilledBets, + balanceByUserId ) } else if ( (outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && @@ -152,6 +154,13 @@ export const placebet = newEndpoint({}, async (req, auth) => { if (makers) { updateMakers(makers, betDoc.id, contractDoc, trans) } + if (ordersToCancel) { + for (const bet of ordersToCancel) { + trans.update(contractDoc.collection('bets').doc(bet.id), { + isCancelled: true, + }) + } + } if (newBet.amount !== 0) { trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) }) @@ -193,13 +202,36 @@ export const placebet = newEndpoint({}, async (req, auth) => { const firestore = admin.firestore() -export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { +const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { return contractDoc .collection('bets') .where('isFilled', '==', false) .where('isCancelled', '==', false) as Query<LimitBet> } +export const getUnfilledBetsAndUserBalances = async ( + trans: Transaction, + contractDoc: DocumentReference +) => { + const unfilledBetsSnap = await trans.get(getUnfilledBetsQuery(contractDoc)) + const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + + // Get balance of all users with open limit orders. + const userIds = uniq(unfilledBets.map((bet) => bet.userId)) + const userDocs = + userIds.length === 0 + ? [] + : await trans.getAll( + ...userIds.map((userId) => firestore.doc(`users/${userId}`)) + ) + const users = filterDefined(userDocs.map((doc) => doc.data() as User)) + const balanceByUserId = Object.fromEntries( + users.map((user) => [user.id, user.balance]) + ) + + return { unfilledBets, balanceByUserId } +} + type maker = { bet: LimitBet amount: number diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index f2f475cb..0c49bb24 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -1,6 +1,7 @@ import { mapValues, groupBy, sumBy, uniq } from 'lodash' import * as admin from 'firebase-admin' import { z } from 'zod' +import { FieldValue } from 'firebase-admin/firestore' import { APIError, newEndpoint, validate } from './api' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' @@ -10,8 +11,7 @@ import { addObjects, removeUndefinedProps } from '../../common/util/object' import { log } from './utils' import { Bet } from '../../common/bet' import { floatingEqual, floatingLesserEqual } from '../../common/util/math' -import { getUnfilledBetsQuery, updateMakers } from './place-bet' -import { FieldValue } from 'firebase-admin/firestore' +import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet' import { redeemShares } from './redeem-shares' import { removeUserFromContractFollowers } from './follow-market' @@ -29,16 +29,18 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) - const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] = - await Promise.all([ - transaction.getAll(contractDoc, userDoc), - transaction.get(betsQ), - transaction.get(getUnfilledBetsQuery(contractDoc)), - ]) + const [ + [contractSnap, userSnap], + userBetsSnap, + { unfilledBets, balanceByUserId }, + ] = await Promise.all([ + transaction.getAll(contractDoc, userDoc), + transaction.get(betsQ), + getUnfilledBetsAndUserBalances(transaction, contractDoc), + ]) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet) - const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) const contract = contractSnap.data() as Contract const user = userSnap.data() as User @@ -86,13 +88,15 @@ export const sellshares = newEndpoint({}, async (req, auth) => { let loanPaid = saleFrac * loanAmount if (!isFinite(loanPaid)) loanPaid = 0 - const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( - soldShares, - chosenOutcome, - contract, - unfilledBets, - loanPaid - ) + const { newBet, newPool, newP, fees, makers, ordersToCancel } = + getCpmmSellBetInfo( + soldShares, + chosenOutcome, + contract, + unfilledBets, + balanceByUserId, + loanPaid + ) if ( !newP || @@ -127,6 +131,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => { }) ) + for (const bet of ordersToCancel) { + transaction.update(contractDoc.collection('bets').doc(bet.id), { + isCancelled: true, + }) + } + return { newBet, makers, maxShares, soldShares } }) diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index 4953fc31..740e24ed 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -2,12 +2,12 @@ import clsx from 'clsx' import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd' import { MenuIcon } from '@heroicons/react/solid' import { toast } from 'react-hot-toast' +import { XCircleIcon } from '@heroicons/react/outline' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Subtitle } from 'web/components/subtitle' import { keyBy } from 'lodash' -import { XCircleIcon } from '@heroicons/react/outline' import { Button } from './button' import { updateUser } from 'web/lib/firebase/users' import { leaveGroup } from 'web/lib/firebase/groups' diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 3c401767..82f1789d 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -16,7 +16,7 @@ import { Button } from 'web/components/button' import { BetSignUpPrompt } from './sign-up-prompt' import { User } from 'web/lib/firebase/users' import { SellRow } from './sell-row' -import { useUnfilledBets } from 'web/hooks/use-bets' +import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets' import { PlayMoneyDisclaimer } from './play-money-disclaimer' /** Button that opens BetPanel in a new modal */ @@ -100,7 +100,9 @@ export function SignedInBinaryMobileBetting(props: { user: User }) { const { contract, user } = props - const unfilledBets = useUnfilledBets(contract.id) ?? [] + const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId( + contract.id + ) return ( <> @@ -111,6 +113,7 @@ export function SignedInBinaryMobileBetting(props: { contract={contract as CPMMBinaryContract} user={user} unfilledBets={unfilledBets} + balanceByUserId={balanceByUserId} mobileView={true} /> </Col> diff --git a/web/components/bet-inline.tsx b/web/components/bet-inline.tsx index a8f4d718..7362f144 100644 --- a/web/components/bet-inline.tsx +++ b/web/components/bet-inline.tsx @@ -10,7 +10,7 @@ import { BuyAmountInput } from './amount-input' import { Button } from './button' import { Row } from './layout/row' import { YesNoSelector } from './yes-no-selector' -import { useUnfilledBets } from 'web/hooks/use-bets' +import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets' import { useUser } from 'web/hooks/use-user' import { BetSignUpPrompt } from './sign-up-prompt' import { getCpmmProbability } from 'common/calculate-cpmm' @@ -34,14 +34,17 @@ export function BetInline(props: { const [error, setError] = useState<string>() const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' - const unfilledBets = useUnfilledBets(contract.id) ?? [] + const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId( + contract.id + ) const { newPool, newP } = getBinaryCpmmBetInfo( outcome ?? 'YES', amount ?? 0, contract, undefined, - unfilledBets + unfilledBets, + balanceByUserId ) const resultProb = getCpmmProbability(newPool, newP) useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb]) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 2667a93a..72a4fec3 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -35,7 +35,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares' import { BetSignUpPrompt } from './sign-up-prompt' import { ProbabilityOrNumericInput } from './probability-input' import { track } from 'web/lib/service/analytics' -import { useUnfilledBets } from 'web/hooks/use-bets' +import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets' import { LimitBets } from './limit-bets' import { PillButton } from './buttons/pill-button' import { YesNoSelector } from './yes-no-selector' @@ -55,7 +55,9 @@ export function BetPanel(props: { const { contract, className } = props const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) - const unfilledBets = useUnfilledBets(contract.id) ?? [] + const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId( + contract.id + ) const { sharesOutcome } = useSaveBinaryShares(contract, userBets) const [isLimitOrder, setIsLimitOrder] = useState(false) @@ -86,12 +88,14 @@ export function BetPanel(props: { contract={contract} user={user} unfilledBets={unfilledBets} + balanceByUserId={balanceByUserId} /> <LimitOrderPanel hidden={!isLimitOrder} contract={contract} user={user} unfilledBets={unfilledBets} + balanceByUserId={balanceByUserId} /> </> ) : ( @@ -117,7 +121,9 @@ export function SimpleBetPanel(props: { const user = useUser() const [isLimitOrder, setIsLimitOrder] = useState(false) - const unfilledBets = useUnfilledBets(contract.id) ?? [] + const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId( + contract.id + ) return ( <Col className={className}> @@ -142,6 +148,7 @@ export function SimpleBetPanel(props: { contract={contract} user={user} unfilledBets={unfilledBets} + balanceByUserId={balanceByUserId} onBuySuccess={onBetSuccess} /> <LimitOrderPanel @@ -149,6 +156,7 @@ export function SimpleBetPanel(props: { contract={contract} user={user} unfilledBets={unfilledBets} + balanceByUserId={balanceByUserId} onBuySuccess={onBetSuccess} /> @@ -167,13 +175,21 @@ export function SimpleBetPanel(props: { export function BuyPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract user: User | null | undefined - unfilledBets: Bet[] + unfilledBets: LimitBet[] + balanceByUserId: { [userId: string]: number } hidden: boolean onBuySuccess?: () => void mobileView?: boolean }) { - const { contract, user, unfilledBets, hidden, onBuySuccess, mobileView } = - props + const { + contract, + user, + unfilledBets, + balanceByUserId, + hidden, + onBuySuccess, + mobileView, + } = props const initialProb = getProbability(contract) const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' @@ -261,7 +277,8 @@ export function BuyPanel(props: { betAmount ?? 0, contract, undefined, - unfilledBets as LimitBet[] + unfilledBets, + balanceByUserId ) const [seeLimit, setSeeLimit] = useState(false) @@ -416,6 +433,7 @@ export function BuyPanel(props: { contract={contract} user={user} unfilledBets={unfilledBets} + balanceByUserId={balanceByUserId} /> <LimitBets contract={contract} @@ -431,11 +449,19 @@ export function BuyPanel(props: { function LimitOrderPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract user: User | null | undefined - unfilledBets: Bet[] + unfilledBets: LimitBet[] + balanceByUserId: { [userId: string]: number } hidden: boolean onBuySuccess?: () => void }) { - const { contract, user, unfilledBets, hidden, onBuySuccess } = props + const { + contract, + user, + unfilledBets, + balanceByUserId, + hidden, + onBuySuccess, + } = props const initialProb = getProbability(contract) const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' @@ -581,7 +607,8 @@ function LimitOrderPanel(props: { yesAmount, contract, yesLimitProb ?? initialProb, - unfilledBets as LimitBet[] + unfilledBets, + balanceByUserId ) const yesReturnPercent = formatPercent(yesReturn) @@ -595,7 +622,8 @@ function LimitOrderPanel(props: { noAmount, contract, noLimitProb ?? initialProb, - unfilledBets as LimitBet[] + unfilledBets, + balanceByUserId ) const noReturnPercent = formatPercent(noReturn) @@ -830,7 +858,9 @@ export function SellPanel(props: { const [isSubmitting, setIsSubmitting] = useState(false) const [wasSubmitted, setWasSubmitted] = useState(false) - const unfilledBets = useUnfilledBets(contract.id) ?? [] + const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId( + contract.id + ) const betDisabled = isSubmitting || !amount || error !== undefined @@ -889,7 +919,8 @@ export function SellPanel(props: { contract, sellQuantity ?? 0, sharesOutcome, - unfilledBets + unfilledBets, + balanceByUserId ) const netProceeds = saleValue - loanPaid const profit = saleValue - costBasis diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index bfe9ca95..a48dbe4d 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -37,7 +37,7 @@ import { NumericContract } from 'common/contract' import { formatNumericProbability } from 'common/pseudo-numeric' import { useUser } from 'web/hooks/use-user' import { useUserBets } from 'web/hooks/use-user-bets' -import { useUnfilledBets } from 'web/hooks/use-bets' +import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets' import { LimitBet } from 'common/bet' import { Pagination } from './pagination' import { LimitOrderTable } from './limit-bets' @@ -412,7 +412,9 @@ export function ContractBetsTable(props: { const isNumeric = outcomeType === 'NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' - const unfilledBets = useUnfilledBets(contract.id) ?? [] + const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId( + contract.id + ) return ( <div className="overflow-x-auto"> @@ -461,6 +463,7 @@ export function ContractBetsTable(props: { contract={contract} isYourBet={isYourBets} unfilledBets={unfilledBets} + balanceByUserId={balanceByUserId} /> ))} </tbody> @@ -475,8 +478,10 @@ function BetRow(props: { saleBet?: Bet isYourBet: boolean unfilledBets: LimitBet[] + balanceByUserId: { [userId: string]: number } }) { - const { bet, saleBet, contract, isYourBet, unfilledBets } = props + const { bet, saleBet, contract, isYourBet, unfilledBets, balanceByUserId } = + props const { amount, outcome, @@ -504,9 +509,9 @@ function BetRow(props: { } else if (contract.isResolved) { return resolvedPayout(contract, bet) } else { - return calculateSaleAmount(contract, bet, unfilledBets) + return calculateSaleAmount(contract, bet, unfilledBets, balanceByUserId) } - }, [contract, bet, saleBet, unfilledBets]) + }, [contract, bet, saleBet, unfilledBets, balanceByUserId]) const saleDisplay = isAnte ? ( 'ANTE' @@ -545,6 +550,7 @@ function BetRow(props: { contract={contract} bet={bet} unfilledBets={unfilledBets} + balanceByUserId={balanceByUserId} /> )} </td> @@ -590,8 +596,9 @@ function SellButton(props: { contract: Contract bet: Bet unfilledBets: LimitBet[] + balanceByUserId: { [userId: string]: number } }) { - const { contract, bet, unfilledBets } = props + const { contract, bet, unfilledBets, balanceByUserId } = props const { outcome, shares, loanAmount } = bet const [isSubmitting, setIsSubmitting] = useState(false) @@ -605,10 +612,16 @@ function SellButton(props: { contract, outcome, shares, - unfilledBets + unfilledBets, + balanceByUserId ) - const saleAmount = calculateSaleAmount(contract, bet, unfilledBets) + const saleAmount = calculateSaleAmount( + contract, + bet, + unfilledBets, + balanceByUserId + ) const profit = saleAmount - bet.amount return ( diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index a71b6c7d..e4a85139 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -33,7 +33,7 @@ import { sellShares } from 'web/lib/firebase/api' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { track } from 'web/lib/service/analytics' import { formatNumericProbability } from 'common/pseudo-numeric' -import { useUnfilledBets } from 'web/hooks/use-bets' +import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets' import { getBinaryProb } from 'common/contract-details' const BET_SIZE = 10 @@ -48,7 +48,10 @@ export function QuickBet(props: { const isCpmm = mechanism === 'cpmm-1' const userBets = useUserContractBets(user.id, contract.id) - const unfilledBets = useUnfilledBets(contract.id) ?? [] + // TODO: Below hook fetches a decent amount of data. Maybe not worth it to show prob change on hover? + const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId( + contract.id + ) const { hasYesShares, hasNoShares, yesShares, noShares } = useSaveBinaryShares(contract, userBets) @@ -94,7 +97,8 @@ export function QuickBet(props: { contract, sharesSold, sellOutcome, - unfilledBets + unfilledBets, + balanceByUserId ) saleAmount = saleValue previewProb = getCpmmProbability(cpmmState.pool, cpmmState.p) diff --git a/web/hooks/use-bets.ts b/web/hooks/use-bets.ts index 9155d25e..1d0a0762 100644 --- a/web/hooks/use-bets.ts +++ b/web/hooks/use-bets.ts @@ -8,6 +8,7 @@ import { withoutAnteBets, } from 'web/lib/firebase/bets' import { LimitBet } from 'common/bet' +import { getUser } from 'web/lib/firebase/users' export const useBets = ( contractId: string, @@ -62,3 +63,31 @@ export const useUnfilledBets = (contractId: string) => { ) return unfilledBets } + +export const useUnfilledBetsAndBalanceByUserId = (contractId: string) => { + const [data, setData] = useState<{ + unfilledBets: LimitBet[] + balanceByUserId: { [userId: string]: number } + }>({ unfilledBets: [], balanceByUserId: {} }) + + useEffect(() => { + let requestCount = 0 + + return listenForUnfilledBets(contractId, (unfilledBets) => { + requestCount++ + const count = requestCount + + Promise.all(unfilledBets.map((bet) => getUser(bet.userId))).then( + (users) => { + if (count === requestCount) { + const balanceByUserId = Object.fromEntries( + users.map((user) => [user.id, user.balance]) + ) + setData({ unfilledBets, balanceByUserId }) + } + } + ) + }) + }, [contractId]) + return data +} From 9e289146afce00f6ed39c9f0d974b386bd161e03 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 6 Oct 2022 23:04:48 -0500 Subject: [PATCH 24/44] flat trade fee of M$0.1 aka bot tax --- common/fees.ts | 2 ++ functions/src/place-bet.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/common/fees.ts b/common/fees.ts index f944933c..7421ef54 100644 --- a/common/fees.ts +++ b/common/fees.ts @@ -1,3 +1,5 @@ +export const FLAT_TRADE_FEE = 0.1 // M$0.1 + export const PLATFORM_FEE = 0 export const CREATOR_FEE = 0 export const LIQUIDITY_FEE = 0 diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 50c89912..e785565b 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -11,6 +11,7 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash' import { APIError, newEndpoint, validate } from './api' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { User } from '../../common/user' +import { FLAT_TRADE_FEE } from '../../common/fees' import { BetInfo, getBinaryCpmmBetInfo, @@ -162,10 +163,17 @@ export const placebet = newEndpoint({}, async (req, auth) => { } } - if (newBet.amount !== 0) { - trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) }) - log('Updated user balance.') + const balanceChange = + newBet.amount !== 0 + ? // quick bet + newBet.amount + FLAT_TRADE_FEE + : // limit order + FLAT_TRADE_FEE + trans.update(userDoc, { balance: FieldValue.increment(-balanceChange) }) + log('Updated user balance.') + + if (newBet.amount !== 0) { trans.update( contractDoc, removeUndefinedProps({ From 8f56ccad2282b8363573723d4ac550aba9f0c28c Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 7 Oct 2022 11:53:09 -0500 Subject: [PATCH 25/44] Set limits on bets and contracts loaded for portfolio page. Show warning if limit is hit --- web/components/bets-list.tsx | 15 ++++++++++++++- web/lib/firebase/bets.ts | 4 +++- web/lib/firebase/contracts.ts | 4 +++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index a48dbe4d..2b1f9243 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -4,7 +4,7 @@ import dayjs from 'dayjs' import { useMemo, useState } from 'react' import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' -import { Bet } from 'web/lib/firebase/bets' +import { Bet, MAX_USER_BETS_LOADED } from 'web/lib/firebase/bets' import { User } from 'web/lib/firebase/users' import { formatMoney, @@ -17,6 +17,7 @@ import { Contract, contractPath, getBinaryProbPercent, + MAX_USER_BET_CONTRACTS_LOADED, } from 'web/lib/firebase/contracts' import { Row } from './layout/row' import { sellBet } from 'web/lib/firebase/api' @@ -50,6 +51,7 @@ import { usePersistentState, } from 'web/hooks/use-persistent-state' import { safeLocalStorage } from 'web/lib/util/local' +import { ExclamationIcon } from '@heroicons/react/outline' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' @@ -80,6 +82,10 @@ export function BetsList(props: { user: User }) { return contractList ? keyBy(contractList, 'id') : undefined }, [contractList]) + const loadedPartialData = + userBets?.length === MAX_USER_BETS_LOADED || + contractList?.length === MAX_USER_BET_CONTRACTS_LOADED + const [sort, setSort] = usePersistentState<BetSort>('newest', { key: 'bets-list-sort', store: storageStore(safeLocalStorage()), @@ -167,6 +173,13 @@ export function BetsList(props: { user: User }) { return ( <Col> + {loadedPartialData && ( + <Row className="my-4 items-center gap-2 self-start rounded bg-yellow-50 p-4"> + <ExclamationIcon className="h-5 w-5" /> + <div>Partial trade data only</div> + </Row> + )} + <Col className="justify-between gap-4 sm:flex-row"> <Row className="gap-4"> <Col> diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 2da95f9d..d03cf565 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -74,11 +74,13 @@ export async function getUserBets(userId: string) { return getValues<Bet>(getUserBetsQuery(userId)) } +export const MAX_USER_BETS_LOADED = 10000 export function getUserBetsQuery(userId: string) { return query( collectionGroup(db, 'bets'), where('userId', '==', userId), - orderBy('createdTime', 'desc') + orderBy('createdTime', 'desc'), + limit(MAX_USER_BETS_LOADED) ) as Query<Bet> } diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 188c29bf..fc6993ec 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -168,10 +168,12 @@ export function getUserBetContracts(userId: string) { return getValues<Contract>(getUserBetContractsQuery(userId)) } +export const MAX_USER_BET_CONTRACTS_LOADED = 1000 export function getUserBetContractsQuery(userId: string) { return query( contracts, - where('uniqueBettorIds', 'array-contains', userId) + where('uniqueBettorIds', 'array-contains', userId), + limit(MAX_USER_BET_CONTRACTS_LOADED) ) as Query<Contract> } From f0b35993c9091cd252eee0dc77fdd4c006ef3c43 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 7 Oct 2022 10:56:27 -0700 Subject: [PATCH 26/44] fix hydration error (button inside button) --- web/components/nav/more-button.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/nav/more-button.tsx b/web/components/nav/more-button.tsx index 9847541c..7f3317e1 100644 --- a/web/components/nav/more-button.tsx +++ b/web/components/nav/more-button.tsx @@ -11,13 +11,13 @@ function SidebarButton(props: { }) { const { text, children } = props return ( - <button className="group flex w-full items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100"> + <div className="group flex w-full items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100"> <props.icon className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500" aria-hidden="true" /> <span className="truncate">{text}</span> {children} - </button> + </div> ) } From b57ff68654a83f076b7c41707dab8f3834dc94ea Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 7 Oct 2022 14:40:38 -0500 Subject: [PATCH 27/44] Fix highlight & scroll to comment --- web/components/feed/feed-comments.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 7111e88f..0cc7012d 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -109,12 +109,18 @@ export const FeedComment = memo(function FeedComment(props: { } const totalAwarded = bountiesAwarded ?? 0 - const router = useRouter() - const highlighted = router.asPath.endsWith(`#${comment.id}`) + const { isReady, asPath } = useRouter() + const [highlighted, setHighlighted] = useState(false) const commentRef = useRef<HTMLDivElement>(null) useEffect(() => { - if (highlighted && commentRef.current != null) { + if (isReady && asPath.endsWith(`#${comment.id}`)) { + setHighlighted(true) + } + }, [isReady, asPath, comment.id]) + + useEffect(() => { + if (highlighted && commentRef.current) { commentRef.current.scrollIntoView(true) } }, [highlighted]) @@ -126,7 +132,7 @@ export const FeedComment = memo(function FeedComment(props: { className={clsx( 'relative', indent ? 'ml-6' : '', - highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : '' + highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] px-2 py-4` : '' )} > {/*draw a gray line from the comment to the left:*/} From 443397b7dc27419a4140f2a3b5f1b24e73ff9127 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 7 Oct 2022 15:13:57 -0500 Subject: [PATCH 28/44] Action to merge main into main2 automatically --- .github/workflows/merge-main-into-main2.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/merge-main-into-main2.yml diff --git a/.github/workflows/merge-main-into-main2.yml b/.github/workflows/merge-main-into-main2.yml new file mode 100644 index 00000000..0a8de56f --- /dev/null +++ b/.github/workflows/merge-main-into-main2.yml @@ -0,0 +1,17 @@ +name: Merge main into main2 on every commit +on: + push: + branches: + - 'main' +jobs: + merge-branch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + + - name: Merge main -> main2 + uses: devmasx/merge-branch@master + with: + type: now + target_branch: main2 + github_token: ${{ github.token }} From 84bc490ed311991b138065eb3a214e6b14006528 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 7 Oct 2022 14:56:55 -0500 Subject: [PATCH 29/44] comment sort: move below input, newest as default --- web/components/contract/contract-tabs.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index fdfc5c3d..7b2886aa 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -80,7 +80,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { const { contract } = props const tips = useTipTxns({ contractId: contract.id }) const comments = useComments(contract.id) ?? props.comments - const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Best', { + const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', { key: `contract-comments-sort`, store: storageStore(safeLocalStorage()), }) @@ -123,7 +123,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { const topLevelComments = commentsByParent['_'] ?? [] const sortRow = comments.length > 0 && ( - <Row className="mb-4 items-center"> + <Row className="mb-2 items-center"> <Button size={'xs'} color={'gray-white'} @@ -177,8 +177,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { <Col className="mt-8 flex w-full"> <div className="text-md mt-8 mb-2 text-left">General Comments</div> <div className="mb-4 w-full border-b border-gray-200" /> - {sortRow} <ContractCommentInput className="mb-5" contract={contract} /> + {sortRow} + {generalTopLevelComments.map((comment) => ( <FeedCommentThread key={comment.id} @@ -194,8 +195,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { } else { return ( <> - {sortRow} <ContractCommentInput className="mb-5" contract={contract} /> + {sortRow} + {topLevelComments.map((parent) => ( <FeedCommentThread key={parent.id} From efa2e44937c3e1cd9a17d9817767326b1db65da5 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 7 Oct 2022 16:26:23 -0500 Subject: [PATCH 30/44] comment bounty dialog, styling --- .../contract/bountied-contract-badge.tsx | 66 +++++++++++++------ ...t-bounty.tsx => comment-bounty-dialog.tsx} | 59 ++++++++++------- web/components/contract/contract-tabs.tsx | 2 +- .../contract/liquidity-bounty-panel.tsx | 7 +- 4 files changed, 83 insertions(+), 51 deletions(-) rename web/components/contract/{add-comment-bounty.tsx => comment-bounty-dialog.tsx} (52%) diff --git a/web/components/contract/bountied-contract-badge.tsx b/web/components/contract/bountied-contract-badge.tsx index 79589990..1f371b14 100644 --- a/web/components/contract/bountied-contract-badge.tsx +++ b/web/components/contract/bountied-contract-badge.tsx @@ -1,12 +1,16 @@ +import clsx from 'clsx' +import { useState } from 'react' + import { CurrencyDollarIcon } from '@heroicons/react/outline' import { Contract } from 'common/contract' import { Tooltip } from 'web/components/tooltip' import { formatMoney } from 'common/util/format' import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' +import { CommentBountyDialog } from './comment-bounty-dialog' 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"> + <span className="inline-flex items-center gap-1 rounded-full bg-indigo-300 px-3 py-0.5 text-sm font-medium text-white"> <CurrencyDollarIcon className={'h4 w-4'} /> Bounty </span> ) @@ -18,30 +22,50 @@ export function BountiedContractSmallBadge(props: { }) { const { contract, showAmount } = props const { openCommentBounties } = contract - if (!openCommentBounties) return <div /> - return ( - <Tooltip - text={CommentBountiesTooltipText( - contract.creatorName, - openCommentBounties - )} - placement="bottom" - > - <span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white"> - <CurrencyDollarIcon className={'h3 w-3'} /> - {showAmount && formatMoney(openCommentBounties)} Bounty - </span> - </Tooltip> + const [open, setOpen] = useState(false) + + if (!openCommentBounties && !showAmount) return <></> + + const modal = ( + <CommentBountyDialog open={open} setOpen={setOpen} contract={contract} /> ) -} + if (!openCommentBounties) + return ( + <> + {modal} + <SmallBadge text="Add bounty" onClick={() => setOpen(true)} /> + </> + ) -export const CommentBountiesTooltipText = ( - creator: string, - openCommentBounties: number -) => - `${creator} may award ${formatMoney( + const tooltip = `${contract.creatorName} may award ${formatMoney( COMMENT_BOUNTY_AMOUNT )} for good comments. ${formatMoney( openCommentBounties )} currently available.` + + return ( + <Tooltip text={tooltip} placement="bottom"> + {modal} + <SmallBadge + text={`${formatMoney(openCommentBounties)} bounty`} + onClick={() => setOpen(true)} + /> + </Tooltip> + ) +} + +function SmallBadge(props: { text: string; onClick?: () => void }) { + const { text, onClick } = props + return ( + <button + onClick={onClick} + className={clsx( + 'inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white' + )} + > + <CurrencyDollarIcon className={'h4 w-4'} /> + {text} + </button> + ) +} diff --git a/web/components/contract/add-comment-bounty.tsx b/web/components/contract/comment-bounty-dialog.tsx similarity index 52% rename from web/components/contract/add-comment-bounty.tsx rename to web/components/contract/comment-bounty-dialog.tsx index 8b716e71..e5ae4c39 100644 --- a/web/components/contract/add-comment-bounty.tsx +++ b/web/components/contract/comment-bounty-dialog.tsx @@ -8,9 +8,16 @@ import clsx from 'clsx' import { formatMoney } from 'common/util/format' import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' import { Button } from 'web/components/button' +import { Title } from '../title' +import { Col } from '../layout/col' +import { Modal } from '../layout/modal' -export function AddCommentBountyPanel(props: { contract: Contract }) { - const { contract } = props +export function CommentBountyDialog(props: { + contract: Contract + open: boolean + setOpen: (open: boolean) => void +}) { + const { contract, open, setOpen } = props const { id: contractId, slug } = contract const user = useUser() @@ -45,30 +52,34 @@ export function AddCommentBountyPanel(props: { contract: Contract }) { } 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> + <Modal open={open} setOpen={setOpen}> + <Col className="gap-4 rounded bg-white p-6"> + <Title className="!mt-0 !mb-0" text="Comment bounty" /> - <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> + <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> - {isSuccess && amount && ( - <div>Success! Added {formatMoney(amount)} in bounties.</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> - {isLoading && <div>Processing...</div>} - </> + {isSuccess && amount && ( + <div>Success! Added {formatMoney(amount)} in bounties.</div> + )} + + {isLoading && <div>Processing...</div>} + </Col> + </Modal> ) } diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 7b2886aa..8531697b 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -123,7 +123,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { const topLevelComments = commentsByParent['_'] ?? [] const sortRow = comments.length > 0 && ( - <Row className="mb-2 items-center"> + <Row className="mb-4 items-center"> <Button size={'xs'} color={'gray-white'} diff --git a/web/components/contract/liquidity-bounty-panel.tsx b/web/components/contract/liquidity-bounty-panel.tsx index 4cc7fd70..347f41b3 100644 --- a/web/components/contract/liquidity-bounty-panel.tsx +++ b/web/components/contract/liquidity-bounty-panel.tsx @@ -16,7 +16,6 @@ import { InfoTooltip } from 'web/components/info-tooltip' import { BETTORS, PRESENT_BET } from 'common/user' import { buildArray } from 'common/util/array' import { useAdmin } from 'web/hooks/use-admin' -import { AddCommentBountyPanel } from 'web/components/contract/add-comment-bounty' export function LiquidityBountyPanel(props: { contract: Contract }) { const { contract } = props @@ -36,13 +35,11 @@ export function LiquidityBountyPanel(props: { contract: Contract }) { const isCreator = user?.id === contract.creatorId const isAdmin = useAdmin() + if (!isCreator && !isAdmin && !showWithdrawal) return <></> + return ( <Tabs tabs={buildArray( - { - title: 'Bounty Comments', - content: <AddCommentBountyPanel contract={contract} />, - }, (isCreator || isAdmin) && isCPMM && { title: (isAdmin ? '[Admin] ' : '') + 'Subsidize', From f3dedfb27a8811e9edca58731b69d18e0ef71c58 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 7 Oct 2022 17:10:12 -0500 Subject: [PATCH 31/44] Call updatemetrics v2 cloud function from scheduled function (#1014) * Call updatemetrics v2 cloud function from scheduled function * Set limits on bets and contracts loaded for portfolio page. Show warning if limit is hit * mqp review: Use console.error if !response.ok --- functions/src/api.ts | 21 +++++++++++++++++++++ functions/src/index.ts | 5 ++++- functions/src/update-metrics.ts | 33 ++++++++++++++++++++++++++++----- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/functions/src/api.ts b/functions/src/api.ts index 7134c8d8..24677567 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { }, } as EndpointDefinition } + +export const newEndpointNoAuth = ( + endpointOpts: EndpointOptions, + fn: (req: Request) => Promise<Output> +) => { + const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts) + return { + opts, + handler: async (req: Request, res: Response) => { + log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`) + try { + if (opts.method !== req.method) { + throw new APIError(405, `This endpoint supports only ${opts.method}.`) + } + res.status(200).json(await fn(req)) + } catch (e) { + writeResponseError(e, res) + } + }, + } as EndpointDefinition +} diff --git a/functions/src/index.ts b/functions/src/index.ts index f5c45004..763fd8bb 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -9,7 +9,7 @@ export * from './on-create-user' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' -export * from './update-metrics' +export { updateMetrics } from './update-metrics' export * from './update-stats' export * from './update-loans' export * from './backup-db' @@ -77,6 +77,7 @@ import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' import { createpost } from './create-post' import { savetwitchcredentials } from './save-twitch-credentials' +import { updatemetrics } from './update-metrics' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -106,6 +107,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) const createPostFunction = toCloudFunction(createpost) const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) +const updateMetricsFunction = toCloudFunction(updatemetrics) export { healthFunction as health, @@ -133,4 +135,5 @@ export { saveTwitchCredentials as savetwitchcredentials, addCommentBounty as addcommentbounty, awardCommentBounty as awardcommentbounty, + updateMetricsFunction as updatemetrics, } diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 24dc07e7..887dbbdc 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,10 +1,11 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' +import fetch from 'node-fetch' + import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract, CPMM } from '../../common/contract' - import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' @@ -20,13 +21,35 @@ import { import { getProbability } from '../../common/calculate' import { Group } from '../../common/group' import { batchedWaitAll } from '../../common/util/promise' +import { newEndpointNoAuth } from './api' +import { getFunctionUrl } from '../../common/api' const firestore = admin.firestore() -export const updateMetrics = functions - .runWith({ memory: '8GB', timeoutSeconds: 540 }) - .pubsub.schedule('every 15 minutes') - .onRun(updateMetricsCore) +export const updateMetrics = functions.pubsub + .schedule('every 15 minutes') + .onRun(async () => { + const response = await fetch(getFunctionUrl('updatemetrics'), { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify({}), + }) + + const json = await response.json() + + if (response.ok) console.log(json) + else console.error(json) + }) + +export const updatemetrics = newEndpointNoAuth( + { timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 }, + async (_req) => { + await updateMetricsCore() + return { success: true } + } +) export async function updateMetricsCore() { console.log('Loading users') From 60bb5379cbd5e470bce65265b62585b9807180b2 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 7 Oct 2022 17:30:18 -0500 Subject: [PATCH 32/44] Update bounties doc --- docs/docs/bounties.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/docs/bounties.md b/docs/docs/bounties.md index ba4e865b..750bd084 100644 --- a/docs/docs/bounties.md +++ b/docs/docs/bounties.md @@ -15,6 +15,21 @@ Our community is the beating heart of Manifold; your individual contributions ar ## Awarded bounties +💥 *Awarded on 2022-10-07* + +**[Pepe](https://manifold.markets/Pepe): M$10,000** +**[Jack](https://manifold.markets/jack): M$2,000** +**[Martin](https://manifold.markets/MartinRandall): M$2,000** +**[Yev](https://manifold.markets/Yev): M$2,000** +**[Michael](https://manifold.markets/MichaelWheatley): M$2,000** + +- For discovering an infinite mana exploit using limit orders, and informing the Manifold team of it privately. + +**[Yev](https://manifold.markets/Yev): M$5,000** +**[Adrian](https://manifold.markets/ahalekelly): M$5,000** + +- For discovering an AMM liquidity exploit and informing the Manifold team of it privately. + 🎈 *Awarded on 2022-06-14* **[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000** From d00ea652798efca0f3a9f2cffa85c6f5b3509fad Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 7 Oct 2022 17:45:42 -0500 Subject: [PATCH 33/44] Add MattP to winners for AMM liquidity exploit --- docs/docs/bounties.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/docs/bounties.md b/docs/docs/bounties.md index 750bd084..48b04dc1 100644 --- a/docs/docs/bounties.md +++ b/docs/docs/bounties.md @@ -25,8 +25,9 @@ Our community is the beating heart of Manifold; your individual contributions ar - For discovering an infinite mana exploit using limit orders, and informing the Manifold team of it privately. -**[Yev](https://manifold.markets/Yev): M$5,000** +**[Matt](https://manifold.markets/MattP): M$5,000** **[Adrian](https://manifold.markets/ahalekelly): M$5,000** +**[Yev](https://manifold.markets/Yev): M$5,000** - For discovering an AMM liquidity exploit and informing the Manifold team of it privately. From e1636d0f13e352d4af23cf1c6b8caa957096a181 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 8 Oct 2022 12:16:38 -0500 Subject: [PATCH 34/44] update metrics: fix divide by zero, elasticity NaN bug --- common/calculate-metrics.ts | 6 +++++- functions/src/update-metrics.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 6cfb0421..9ad44522 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -97,7 +97,11 @@ export const computeBinaryCpmmElasticity = ( ) const resultNo = getCpmmProbability(poolN, pN) - return resultYes - resultNo + // handle AMM overflow + const safeYes = Number.isFinite(resultYes) ? resultYes : 1 + const safeNo = Number.isFinite(resultNo) ? resultNo : 0 + + return safeYes - safeNo } export const computeDpmElasticity = ( diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 887dbbdc..4739dcc1 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -169,7 +169,7 @@ export async function updateMetricsCore() { return 0 } const contractRatio = - contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1) + contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1) return contractRatio }) From 310a41d63ee922e591fbcea9306b10b3d00818d6 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 8 Oct 2022 12:51:58 -0500 Subject: [PATCH 35/44] Make loan and bet streak links hoverable in notifications --- web/components/profile/loans-modal.tsx | 4 ++-- web/pages/notifications.tsx | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx index 5dcb8b6b..cf138945 100644 --- a/web/components/profile/loans-modal.tsx +++ b/web/components/profile/loans-modal.tsx @@ -35,8 +35,8 @@ export function LoansModal(props: { </span> <span className={'text-indigo-700'}>• What is an example?</span> <span className={'ml-2'}> - For example, if you bet M$1000 on "Will I become a millionare?" - today, you will get M$20 back tomorrow. + For example, if you bet M$1000 on "Will I become a millionare?", you + will get M$20 back tomorrow. </span> <span className={'ml-2'}> Previous loans count against your total bet amount. So on the next diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index aee142cd..34218911 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -469,8 +469,11 @@ function IncomeNotificationItem(props: { simple ? ( <span className={'ml-1 font-bold'}>🏦 Loan</span> ) : ( - <SiteLink className={'ml-1 font-bold'} href={'/loans'}> - 🏦 Loan + <SiteLink + className={'relative ml-1 font-bold'} + href={`/${sourceUserUsername}/?show=loans`} + > + 🏦 Loan <span className="font-normal">(learn more)</span> </SiteLink> ) ) : sourceType === 'betting_streak_bonus' ? ( @@ -478,8 +481,8 @@ function IncomeNotificationItem(props: { <span className={'ml-1 font-bold'}>{bettingStreakText}</span> ) : ( <SiteLink - className={'ml-1 font-bold'} - href={'/betting-streak-bonus'} + className={'relative ml-1 font-bold'} + href={`/${sourceUserUsername}/?show=betting-streak`} > {bettingStreakText} </SiteLink> From 8bd21c669348891712f9ad9f3eeaca46b59df1ac Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Sat, 8 Oct 2022 22:52:36 -0700 Subject: [PATCH 36/44] hotfix %mention, add load-fail state --- common/util/parse.ts | 1 + web/components/editor/contract-mention.tsx | 23 +++++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/common/util/parse.ts b/common/util/parse.ts index 6cca448a..7e3774c6 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -73,6 +73,7 @@ export const exhibitExts = [ Image, Link, Mention, + Mention.extend({ name: 'contract-mention' }), Iframe, TiptapTweet, TiptapSpoiler, diff --git a/web/components/editor/contract-mention.tsx b/web/components/editor/contract-mention.tsx index 01517aae..ea0d6e0d 100644 --- a/web/components/editor/contract-mention.tsx +++ b/web/components/editor/contract-mention.tsx @@ -6,16 +6,28 @@ import { } from '@tiptap/react' import clsx from 'clsx' import { useContract } from 'web/hooks/use-contract' -import { ContractMention } from '../contract/contract-mention' +import { ContractMention } from 'web/components/contract/contract-mention' +import Link from 'next/link' const name = 'contract-mention-component' const ContractMentionComponent = (props: any) => { - const contract = useContract(props.node.attrs.id) + const { label, id } = props.node.attrs + const contract = useContract(id) return ( <NodeViewWrapper className={clsx(name, 'not-prose inline')}> - {contract && <ContractMention contract={contract} />} + {contract ? ( + <ContractMention contract={contract} /> + ) : label ? ( + <Link href={label}> + <a className="rounded-sm !text-indigo-700 hover:bg-indigo-50"> + {label} + </a> + </Link> + ) : ( + '[loading...]' + )} </NodeViewWrapper> ) } @@ -29,8 +41,5 @@ export const DisplayContractMention = Mention.extend({ name: 'contract-mention', parseHTML: () => [{ tag: name }], renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], - addNodeView: () => - ReactNodeViewRenderer(ContractMentionComponent, { - // On desktop, render cards below half-width so you can stack two - }), + addNodeView: () => ReactNodeViewRenderer(ContractMentionComponent), }) From 565177b76f22036b0d7d3bd91360a462ffc5d7d5 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 9 Oct 2022 17:02:28 -0500 Subject: [PATCH 37/44] track midterms, date docs --- web/components/usa-map/state-election-map.tsx | 10 ++++++++-- web/pages/date-docs/index.tsx | 2 ++ web/pages/midterms.tsx | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/web/components/usa-map/state-election-map.tsx b/web/components/usa-map/state-election-map.tsx index a9a53a91..1eb5915e 100644 --- a/web/components/usa-map/state-election-map.tsx +++ b/web/components/usa-map/state-election-map.tsx @@ -7,6 +7,7 @@ import { CPMMBinaryContract } from 'common/contract' import { Customize, USAMap } from './usa-map' import { listenForContract } from 'web/lib/firebase/contracts' import { interpolateColor } from 'common/util/color' +import { track } from 'web/lib/service/analytics' export interface StateElectionMarket { creatorUsername: string @@ -35,8 +36,13 @@ export function StateElectionMap(props: { market.state, { fill: probToColor(prob, market.isWinRepublican), - clickHandler: () => - Router.push(`/${market.creatorUsername}/${market.slug}`), + clickHandler: () => { + Router.push(`/${market.creatorUsername}/${market.slug}`) + track('state election map click', { + state: market.state, + slug: market.slug, + }) + }, }, ]) diff --git a/web/pages/date-docs/index.tsx b/web/pages/date-docs/index.tsx index f25746ee..64584c5e 100644 --- a/web/pages/date-docs/index.tsx +++ b/web/pages/date-docs/index.tsx @@ -14,6 +14,7 @@ import { getUser, User } from 'web/lib/firebase/users' import { DateDocPost } from './[username]' import { NoSEO } from 'web/components/NoSEO' import { useDateDocs } from 'web/hooks/use-post' +import { useTracking } from 'web/hooks/use-tracking' export async function getStaticProps() { const dateDocs = await getDateDocs() @@ -40,6 +41,7 @@ export default function DatePage(props: { const dateDocs = useDateDocs() ?? props.dateDocs const hasDoc = dateDocs.some((d) => d.creatorId === user?.id) + useTracking('view date docs page') return ( <Page> diff --git a/web/pages/midterms.tsx b/web/pages/midterms.tsx index d14f9d0f..d9566a13 100644 --- a/web/pages/midterms.tsx +++ b/web/pages/midterms.tsx @@ -8,6 +8,7 @@ import { StateElectionMarket, StateElectionMap, } from 'web/components/usa-map/state-election-map' +import { useTracking } from 'web/hooks/use-tracking' import { getContractFromSlug } from 'web/lib/firebase/contracts' const senateMidterms: StateElectionMarket[] = [ @@ -203,6 +204,8 @@ const App = (props: { }) => { const { senateContracts, governorContracts } = props + useTracking('view midterms 2022') + return ( <Page className=""> <Col className="items-center justify-center"> From 4b8d381da5efbe67bbe351c09427be2b59300916 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 9 Oct 2022 17:14:20 -0500 Subject: [PATCH 38/44] hide comment bounty when market closed or resolved --- .../contract/bountied-contract-badge.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/web/components/contract/bountied-contract-badge.tsx b/web/components/contract/bountied-contract-badge.tsx index 1f371b14..27901cf0 100644 --- a/web/components/contract/bountied-contract-badge.tsx +++ b/web/components/contract/bountied-contract-badge.tsx @@ -3,9 +3,9 @@ import { useState } from 'react' import { CurrencyDollarIcon } from '@heroicons/react/outline' import { Contract } from 'common/contract' -import { Tooltip } from 'web/components/tooltip' -import { formatMoney } from 'common/util/format' import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' +import { formatMoney } from 'common/util/format' +import { Tooltip } from 'web/components/tooltip' import { CommentBountyDialog } from './comment-bounty-dialog' export function BountiedContractBadge() { @@ -30,13 +30,20 @@ export function BountiedContractSmallBadge(props: { const modal = ( <CommentBountyDialog open={open} setOpen={setOpen} contract={contract} /> ) - if (!openCommentBounties) + + const bountiesClosed = + contract.isResolved || (contract.closeTime ?? Infinity) < Date.now() + + if (!openCommentBounties) { + if (bountiesClosed) return <></> + return ( <> {modal} <SmallBadge text="Add bounty" onClick={() => setOpen(true)} /> </> ) + } const tooltip = `${contract.creatorName} may award ${formatMoney( COMMENT_BOUNTY_AMOUNT @@ -49,7 +56,7 @@ export function BountiedContractSmallBadge(props: { {modal} <SmallBadge text={`${formatMoney(openCommentBounties)} bounty`} - onClick={() => setOpen(true)} + onClick={bountiesClosed ? undefined : () => setOpen(true)} /> </Tooltip> ) @@ -57,11 +64,13 @@ export function BountiedContractSmallBadge(props: { function SmallBadge(props: { text: string; onClick?: () => void }) { const { text, onClick } = props + return ( <button onClick={onClick} className={clsx( - 'inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white' + 'inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white', + !onClick && 'cursor-default' )} > <CurrencyDollarIcon className={'h4 w-4'} /> From 60f2552139e7d9ac8268130bd34a9cad1a475e9c Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Sun, 9 Oct 2022 16:09:21 -0700 Subject: [PATCH 39/44] copy: Referrals -> Refer a friend --- web/components/nav/sidebar.tsx | 4 ++-- web/pages/referrals.tsx | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 23b1115c..c8c4cd80 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -156,7 +156,7 @@ function getMoreDesktopNavigation(user?: User | null) { return buildArray( { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Groups', href: '/groups' }, - { name: 'Referrals', href: '/referrals' }, + { name: 'Refer a friend', href: '/referrals' }, { name: 'Charity', href: '/charity' }, { name: 'Labs', href: '/labs' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, @@ -215,7 +215,7 @@ function getMoreMobileNav() { return buildArray<MenuItem>( { name: 'Groups', href: '/groups' }, - { name: 'Referrals', href: '/referrals' }, + { name: 'Refer a friend', href: '/referrals' }, { name: 'Charity', href: '/charity' }, { name: 'Labs', href: '/labs' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, diff --git a/web/pages/referrals.tsx b/web/pages/referrals.tsx index 2e330980..15f75ff4 100644 --- a/web/pages/referrals.tsx +++ b/web/pages/referrals.tsx @@ -10,6 +10,7 @@ import { ENV_CONFIG } from 'common/envs/constants' import { InfoBox } from 'web/components/info-box' import { QRCode } from 'web/components/qr-code' import { REFERRAL_AMOUNT } from 'common/economy' +import { formatMoney } from 'common/util/format' export const getServerSideProps = redirectIfLoggedOut('/') @@ -23,15 +24,15 @@ export default function ReferralsPage() { return ( <Page> <SEO - title="Referrals" - description={`Manifold's referral program. Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they + title="Refer a friend" + description={`Invite new users to Manifold and get ${formatMoney(REFERRAL_AMOUNT)} if they sign up!`} url="/referrals" /> <Col className="items-center"> <Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md"> - <Title className="!mt-0" text="Referrals" /> + <Title className="!mt-0" text="Refer a friend" /> <img className="mb-6 block -scale-x-100 self-center" src="/logo-flapping-with-money.gif" @@ -40,7 +41,7 @@ export default function ReferralsPage() { /> <div className={'mb-4'}> - Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they + Invite new users to Manifold and get {formatMoney(REFERRAL_AMOUNT)} if they sign up! </div> From 4831c25ce045fde73ecd76e6c09594f5eda46496 Mon Sep 17 00:00:00 2001 From: sipec <sipec@users.noreply.github.com> Date: Sun, 9 Oct 2022 23:10:02 +0000 Subject: [PATCH 40/44] Auto-prettification --- web/pages/referrals.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/pages/referrals.tsx b/web/pages/referrals.tsx index 15f75ff4..46b7b7b0 100644 --- a/web/pages/referrals.tsx +++ b/web/pages/referrals.tsx @@ -25,7 +25,9 @@ export default function ReferralsPage() { <Page> <SEO title="Refer a friend" - description={`Invite new users to Manifold and get ${formatMoney(REFERRAL_AMOUNT)} if they + description={`Invite new users to Manifold and get ${formatMoney( + REFERRAL_AMOUNT + )} if they sign up!`} url="/referrals" /> @@ -41,8 +43,8 @@ export default function ReferralsPage() { /> <div className={'mb-4'}> - Invite new users to Manifold and get {formatMoney(REFERRAL_AMOUNT)} if they - sign up! + Invite new users to Manifold and get {formatMoney(REFERRAL_AMOUNT)}{' '} + if they sign up! </div> <CopyLinkButton From dc51e2cf46b7d5ad870c80fd5aba8e10d2099420 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 9 Oct 2022 19:11:44 -0500 Subject: [PATCH 41/44] Rename `updateMetrics` to `scheduleUpdateMetrics` --- functions/src/index.ts | 2 +- functions/src/update-metrics.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/src/index.ts b/functions/src/index.ts index 763fd8bb..a6d120c8 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -9,7 +9,7 @@ export * from './on-create-user' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' -export { updateMetrics } from './update-metrics' +export { scheduleUpdateMetrics } from './update-metrics' export * from './update-stats' export * from './update-loans' export * from './backup-db' diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 4739dcc1..e77ab71f 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -26,7 +26,7 @@ import { getFunctionUrl } from '../../common/api' const firestore = admin.firestore() -export const updateMetrics = functions.pubsub +export const scheduleUpdateMetrics = functions.pubsub .schedule('every 15 minutes') .onRun(async () => { const response = await fetch(getFunctionUrl('updatemetrics'), { From 8d06e4b4d2f664971fcd45d26ccdf2fea2a265ce Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Sun, 9 Oct 2022 19:37:24 -0700 Subject: [PATCH 42/44] refactor text input into one component (#1016) * Add responsive text input component * Add styled expanding textarea component --- web/components/amount-input.tsx | 5 ++-- web/components/answers/answer-item.tsx | 5 ++-- .../answers/create-answer-panel.tsx | 6 ++--- .../answers/multiple-choice-answers.tsx | 6 ++--- .../challenges/create-challenge-modal.tsx | 6 ++--- web/components/contract-search.tsx | 7 +++--- .../contract/contract-description.tsx | 7 +++--- web/components/contract/contract-details.tsx | 9 ++++--- web/components/create-post.tsx | 8 +++--- web/components/expanding-input.tsx | 16 ++++++++++++ web/components/filter-select-users.tsx | 5 ++-- web/components/groups/create-group-button.tsx | 4 +-- web/components/groups/edit-group-button.tsx | 4 +-- web/components/input.tsx | 22 ++++++++++++++++ .../manalinks/create-links-button.tsx | 15 ++++++----- web/components/number-input.tsx | 5 ++-- web/components/probability-input.tsx | 8 +++--- web/components/probability-selector.tsx | 5 ++-- web/pages/charity/index.tsx | 5 ++-- web/pages/contract-search-firestore.tsx | 5 ++-- web/pages/create.tsx | 25 ++++++++----------- web/pages/date-docs/create.tsx | 10 +++----- web/pages/groups.tsx | 9 ++++--- web/pages/home/index.tsx | 5 ++-- web/pages/profile.tsx | 19 ++++++-------- 25 files changed, 128 insertions(+), 93 deletions(-) create mode 100644 web/components/expanding-input.tsx create mode 100644 web/components/input.tsx diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 65a79c20..8cd43369 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -6,6 +6,7 @@ import { Col } from './layout/col' import { ENV_CONFIG } from 'common/envs/constants' import { Row } from './layout/row' import { AddFundsModal } from './add-funds-modal' +import { Input } from './input' export function AmountInput(props: { amount: number | undefined @@ -44,9 +45,9 @@ export function AmountInput(props: { <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2"> {label} </span> - <input + <Input className={clsx( - 'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9', + 'pl-9', error && 'input-error', 'w-24 md:w-auto', inputClassName diff --git a/web/components/answers/answer-item.tsx b/web/components/answers/answer-item.tsx index f1ab2f88..323a8b9b 100644 --- a/web/components/answers/answer-item.tsx +++ b/web/components/answers/answer-item.tsx @@ -10,6 +10,7 @@ import { formatPercent } from 'common/util/format' import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { tradingAllowed } from 'web/lib/firebase/contracts' import { Linkify } from '../linkify' +import { Input } from '../input' export function AnswerItem(props: { answer: Answer @@ -74,8 +75,8 @@ export function AnswerItem(props: { <Row className="items-center justify-end gap-4 self-end sm:self-start"> {!wasResolvedTo && (showChoice === 'checkbox' ? ( - <input - className="input input-bordered w-24 justify-self-end text-2xl" + <Input + className="w-24 justify-self-end !text-2xl" type="number" placeholder={`${roundedProb}`} maxLength={9} diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 58f55327..4012e587 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx' import React, { useState } from 'react' -import Textarea from 'react-expanding-textarea' import { findBestMatch } from 'string-similarity' import { FreeResponseContract } from 'common/contract' @@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer' import { withTracking } from 'web/lib/service/analytics' import { lowerCase } from 'lodash' import { Button } from '../button' +import { ExpandingInput } from '../expanding-input' export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const { contract } = props @@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { <Col className="gap-4 rounded"> <Col className="flex-1 gap-2 px-4 xl:px-0"> <div className="mb-1">Add your answer</div> - <Textarea + <ExpandingInput value={text} onChange={(e) => changeAnswer(e.target.value)} - className="textarea textarea-bordered w-full resize-none" + className="w-full" placeholder="Type your answer..." rows={1} maxLength={MAX_ANSWER_LENGTH} diff --git a/web/components/answers/multiple-choice-answers.tsx b/web/components/answers/multiple-choice-answers.tsx index c2857eb2..bdc61022 100644 --- a/web/components/answers/multiple-choice-answers.tsx +++ b/web/components/answers/multiple-choice-answers.tsx @@ -1,8 +1,8 @@ import { MAX_ANSWER_LENGTH } from 'common/answer' -import Textarea from 'react-expanding-textarea' import { XIcon } from '@heroicons/react/solid' import { Col } from '../layout/col' import { Row } from '../layout/row' +import { ExpandingInput } from '../expanding-input' export function MultipleChoiceAnswers(props: { answers: string[] @@ -27,10 +27,10 @@ export function MultipleChoiceAnswers(props: { {answers.map((answer, i) => ( <Row className="mb-2 items-center gap-2 align-middle"> {i + 1}.{' '} - <Textarea + <ExpandingInput value={answer} onChange={(e) => setAnswer(i, e.target.value)} - className="textarea textarea-bordered ml-2 w-full resize-none" + className="ml-2 w-full" placeholder="Type your answer..." rows={1} maxLength={MAX_ANSWER_LENGTH} diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index f18fbdad..f8d91a7b 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -20,11 +20,11 @@ import { getProbability } from 'common/calculate' import { createMarket } from 'web/lib/firebase/api' import { removeUndefinedProps } from 'common/util/object' import { FIXED_ANTE } from 'common/economy' -import Textarea from 'react-expanding-textarea' import { useTextEditor } from 'web/components/editor' import { LoadingIndicator } from 'web/components/loading-indicator' import { track } from 'web/lib/service/analytics' import { CopyLinkButton } from '../copy-link-button' +import { ExpandingInput } from '../expanding-input' type challengeInfo = { amount: number @@ -153,9 +153,9 @@ function CreateChallengeForm(props: { {contract ? ( <span className="underline">{contract.question}</span> ) : ( - <Textarea + <ExpandingInput placeholder="e.g. Will a Democrat be the next president?" - className="input input-bordered mt-1 w-full resize-none" + className="mt-1 w-full" autoFocus={true} maxLength={MAX_QUESTION_LENGTH} value={challengeInfo.question} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 8d871d65..8365e83b 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -41,6 +41,7 @@ import { AdjustmentsIcon } from '@heroicons/react/solid' import { Button } from './button' import { Modal } from './layout/modal' import { Title } from './title' +import { Input } from './input' export const SORTS = [ { label: 'Newest', value: 'newest' }, @@ -438,13 +439,13 @@ function ContractSearchControls(props: { return ( <Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}> <Row className="gap-1 sm:gap-2"> - <input + <Input type="text" value={query} onChange={(e) => updateQuery(e.target.value)} onBlur={trackCallback('search', { query: query })} - placeholder={'Search'} - className="input input-bordered w-full" + placeholder="Search" + className="w-full" autoFocus={autoFocus} /> {!isMobile && !query && ( diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index 259e39df..dc94d339 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -1,8 +1,6 @@ import clsx from 'clsx' import dayjs from 'dayjs' import { useState } from 'react' -import Textarea from 'react-expanding-textarea' - import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract' import { exhibitExts } from 'common/util/parse' import { useAdmin } from 'web/hooks/use-admin' @@ -15,6 +13,7 @@ import { Button } from '../button' import { Spacer } from '../layout/spacer' import { Editor, Content as ContentType } from '@tiptap/react' import { insertContent } from '../editor/utils' +import { ExpandingInput } from '../expanding-input' export function ContractDescription(props: { contract: Contract @@ -138,8 +137,8 @@ function EditQuestion(props: { return editing ? ( <div className="mt-4"> - <Textarea - className="textarea textarea-bordered mb-1 h-24 w-full resize-none" + <ExpandingInput + className="mb-1 h-24 w-full" rows={2} value={text} onChange={(e) => setText(e.target.value || '')} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 3b308667..d2734ab5 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -40,6 +40,7 @@ import { BountiedContractBadge, BountiedContractSmallBadge, } from 'web/components/contract/bountied-contract-badge' +import { Input } from '../input' export type ShowTime = 'resolve-date' | 'close-date' @@ -445,17 +446,17 @@ function EditableCloseDate(props: { <Col className="rounded bg-white px-8 pb-8"> <Subtitle text="Edit market close time" /> <Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2"> - <input + <Input type="date" - className="input input-bordered w-full shrink-0 sm:w-fit" + className="w-full shrink-0 sm:w-fit" onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseDate(e.target.value)} min={Date.now()} value={closeDate} /> - <input + <Input type="time" - className="input input-bordered w-full shrink-0 sm:w-max" + className="w-full shrink-0 sm:w-max" onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseHoursMinutes(e.target.value)} min="00:00" diff --git a/web/components/create-post.tsx b/web/components/create-post.tsx index 6d42051c..f7d9b8bd 100644 --- a/web/components/create-post.tsx +++ b/web/components/create-post.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { Spacer } from 'web/components/layout/spacer' import { Title } from 'web/components/title' -import Textarea from 'react-expanding-textarea' import { TextEditor, useTextEditor } from 'web/components/editor' import { createPost } from 'web/lib/firebase/api' @@ -10,6 +9,7 @@ import Router from 'next/router' import { MAX_POST_TITLE_LENGTH } from 'common/post' import { postPath } from 'web/lib/firebase/posts' import { Group } from 'common/group' +import { ExpandingInput } from './expanding-input' export function CreatePost(props: { group?: Group }) { const [title, setTitle] = useState('') @@ -60,9 +60,8 @@ export function CreatePost(props: { group?: Group }) { Title<span className={'text-red-700'}> *</span> </span> </label> - <Textarea + <ExpandingInput placeholder="e.g. Elon Mania Post" - className="input input-bordered resize-none" autoFocus maxLength={MAX_POST_TITLE_LENGTH} value={title} @@ -74,9 +73,8 @@ export function CreatePost(props: { group?: Group }) { Subtitle<span className={'text-red-700'}> *</span> </span> </label> - <Textarea + <ExpandingInput placeholder="e.g. How Elon Musk is getting everyone's attention" - className="input input-bordered resize-none" autoFocus maxLength={MAX_POST_TITLE_LENGTH} value={subtitle} diff --git a/web/components/expanding-input.tsx b/web/components/expanding-input.tsx new file mode 100644 index 00000000..ff442348 --- /dev/null +++ b/web/components/expanding-input.tsx @@ -0,0 +1,16 @@ +import clsx from 'clsx' +import Textarea from 'react-expanding-textarea' + +/** Expanding `<textarea>` with same style as input.tsx */ +export const ExpandingInput = (props: Parameters<typeof Textarea>[0]) => { + const { className, ...rest } = props + return ( + <Textarea + className={clsx( + 'textarea textarea-bordered resize-none text-[16px] md:text-[14px]', + className + )} + {...rest} + /> + ) +} diff --git a/web/components/filter-select-users.tsx b/web/components/filter-select-users.tsx index 415a6d57..8ec453d2 100644 --- a/web/components/filter-select-users.tsx +++ b/web/components/filter-select-users.tsx @@ -8,6 +8,7 @@ import { Avatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' import { searchInAny } from 'common/util/parse' import { UserLink } from 'web/components/user-link' +import { Input } from './input' export function FilterSelectUsers(props: { setSelectedUsers: (users: User[]) => void @@ -50,13 +51,13 @@ export function FilterSelectUsers(props: { <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> <UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" /> </div> - <input + <Input type="text" name="user name" id="user name" value={query} onChange={(e) => setQuery(e.target.value)} - className="input input-bordered block w-full pl-10 focus:border-gray-300 " + className="block w-full pl-10" placeholder="Austin Chen" /> </div> diff --git a/web/components/groups/create-group-button.tsx b/web/components/groups/create-group-button.tsx index 1df29764..017fa4a6 100644 --- a/web/components/groups/create-group-button.tsx +++ b/web/components/groups/create-group-button.tsx @@ -8,6 +8,7 @@ import { Title } from '../title' import { User } from 'common/user' import { MAX_GROUP_NAME_LENGTH } from 'common/group' import { createGroup } from 'web/lib/firebase/api' +import { Input } from '../input' export function CreateGroupButton(props: { user: User @@ -104,9 +105,8 @@ export function CreateGroupButton(props: { <div className="form-control w-full"> <label className="mb-2 ml-1 mt-0">Group name</label> - <input + <Input placeholder={'Your group name'} - className="input input-bordered resize-none" disabled={isSubmitting} value={name} maxLength={MAX_GROUP_NAME_LENGTH} diff --git a/web/components/groups/edit-group-button.tsx b/web/components/groups/edit-group-button.tsx index 71c6034e..c427be5c 100644 --- a/web/components/groups/edit-group-button.tsx +++ b/web/components/groups/edit-group-button.tsx @@ -10,6 +10,7 @@ import { Modal } from 'web/components/layout/modal' import { FilterSelectUsers } from 'web/components/filter-select-users' import { User } from 'common/user' import { useMemberIds } from 'web/hooks/use-group' +import { Input } from '../input' export function EditGroupButton(props: { group: Group; className?: string }) { const { group, className } = props @@ -54,9 +55,8 @@ export function EditGroupButton(props: { group: Group; className?: string }) { <span className="mb-1">Group name</span> </label> - <input + <Input placeholder="Your group name" - className="input input-bordered resize-none" disabled={isSubmitting} value={name} onChange={(e) => setName(e.target.value || '')} diff --git a/web/components/input.tsx b/web/components/input.tsx new file mode 100644 index 00000000..f37bfc78 --- /dev/null +++ b/web/components/input.tsx @@ -0,0 +1,22 @@ +import clsx from 'clsx' +import React from 'react' + +/** Text input. Wraps html `<input>` */ +export const Input = (props: JSX.IntrinsicElements['input']) => { + const { className, ...rest } = props + + return ( + <input + className={clsx('input input-bordered text-base md:text-sm', className)} + {...rest} + /> + ) +} + +/* + TODO: replace daisyui style with our own. For reference: + + james: text-lg placeholder:text-gray-400 + inga: placeholder:text-greyscale-4 border-greyscale-2 rounded-md + austin: border-gray-300 text-gray-400 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm + */ diff --git a/web/components/manalinks/create-links-button.tsx b/web/components/manalinks/create-links-button.tsx index 449d6c76..8d5bac67 100644 --- a/web/components/manalinks/create-links-button.tsx +++ b/web/components/manalinks/create-links-button.tsx @@ -7,12 +7,13 @@ import { User } from 'common/user' import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card' import { createManalink } from 'web/lib/firebase/manalinks' import { Modal } from 'web/components/layout/modal' -import Textarea from 'react-expanding-textarea' import dayjs from 'dayjs' import { Button } from '../button' import { getManalinkUrl } from 'web/pages/links' import { DuplicateIcon } from '@heroicons/react/outline' import { QRCode } from '../qr-code' +import { Input } from '../input' +import { ExpandingInput } from '../expanding-input' export function CreateLinksButton(props: { user: User @@ -120,8 +121,8 @@ function CreateManalinkForm(props: { <span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> M$ </span> - <input - className="input input-bordered w-full pl-10" + <Input + className="w-full pl-10" type="number" min="1" value={newManalink.amount} @@ -136,8 +137,7 @@ function CreateManalinkForm(props: { <div className="flex flex-col gap-2 md:flex-row"> <div className="form-control w-full md:w-1/2"> <label className="label">Uses</label> - <input - className="input input-bordered" + <Input type="number" min="1" value={newManalink.maxUses ?? ''} @@ -146,7 +146,7 @@ function CreateManalinkForm(props: { return { ...m, maxUses: parseInt(e.target.value) } }) } - ></input> + /> </div> <div className="form-control w-full md:w-1/2"> <label className="label">Expires in</label> @@ -165,10 +165,9 @@ function CreateManalinkForm(props: { </div> <div className="form-control w-full"> <label className="label">Message</label> - <Textarea + <ExpandingInput placeholder={defaultMessage} maxLength={200} - className="input input-bordered resize-none" autoFocus value={newManalink.message} rows="3" diff --git a/web/components/number-input.tsx b/web/components/number-input.tsx index 0b48df6e..6509a5d7 100644 --- a/web/components/number-input.tsx +++ b/web/components/number-input.tsx @@ -4,6 +4,7 @@ import { ReactNode } from 'react' import React from 'react' import { Col } from './layout/col' import { Spacer } from './layout/spacer' +import { Input } from './input' export function NumberInput(props: { numberString: string @@ -32,9 +33,9 @@ export function NumberInput(props: { return ( <Col className={className}> <label className="input-group"> - <input + <Input className={clsx( - 'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400', + 'max-w-[200px] !text-lg', error && 'input-error', inputClassName )} diff --git a/web/components/probability-input.tsx b/web/components/probability-input.tsx index cc8b9259..9b58dc64 100644 --- a/web/components/probability-input.tsx +++ b/web/components/probability-input.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { getPseudoProbability } from 'common/pseudo-numeric' import { BucketInput } from './bucket-input' +import { Input } from './input' import { Col } from './layout/col' import { Spacer } from './layout/spacer' @@ -30,11 +31,8 @@ export function ProbabilityInput(props: { return ( <Col className={className}> <label className="input-group"> - <input - className={clsx( - 'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400', - inputClassName - )} + <Input + className={clsx('max-w-[200px] !text-lg', inputClassName)} type="number" max={99} min={1} diff --git a/web/components/probability-selector.tsx b/web/components/probability-selector.tsx index b13dcfd9..010c7bfa 100644 --- a/web/components/probability-selector.tsx +++ b/web/components/probability-selector.tsx @@ -1,3 +1,4 @@ +import { Input } from './input' import { Row } from './layout/row' export function ProbabilitySelector(props: { @@ -10,10 +11,10 @@ export function ProbabilitySelector(props: { return ( <Row className="items-center gap-2"> <label className="input-group input-group-lg text-lg"> - <input + <Input type="number" value={probabilityInt} - className="input input-bordered input-md w-28 text-lg" + className="input-md w-28 !text-lg" disabled={isSubmitting} min={1} max={99} diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 2f4407d9..4ee7635e 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -24,6 +24,7 @@ import { getUser } from 'web/lib/firebase/users' import { SiteLink } from 'web/components/site-link' import { User } from 'common/user' import { SEO } from 'web/components/SEO' +import { Input } from 'web/components/input' export async function getStaticProps() { let txns = await getAllCharityTxns() @@ -171,11 +172,11 @@ export default function Charity(props: { /> <Spacer h={10} /> - <input + <Input type="text" onChange={(e) => debouncedQuery(e.target.value)} placeholder="Find a charity" - className="input input-bordered mb-6 w-full" + className="mb-6 w-full" /> </Col> <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 self-center lg:max-w-full lg:grid-cols-2 xl:grid-cols-3"> diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 4d6ada1d..0f81499f 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -9,6 +9,7 @@ import { urlParamStore, } from 'web/hooks/use-persistent-state' import { PAST_BETS } from 'common/user' +import { Input } from 'web/components/input' const MAX_CONTRACTS_RENDERED = 100 @@ -88,12 +89,12 @@ export default function ContractSearchFirestore(props: { <div> {/* Show a search input next to a sort dropdown */} <div className="mt-2 mb-8 flex justify-between gap-2"> - <input + <Input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search markets" - className="input input-bordered w-full" + className="w-full" /> <select className="select select-bordered" diff --git a/web/pages/create.tsx b/web/pages/create.tsx index e23ebf75..7ba99a39 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -2,7 +2,6 @@ import router, { useRouter } from 'next/router' import { useEffect, useState } from 'react' import clsx from 'clsx' import dayjs from 'dayjs' -import Textarea from 'react-expanding-textarea' import { Spacer } from 'web/components/layout/spacer' import { getUserAndPrivateUser } from 'web/lib/firebase/users' import { Contract, contractPath } from 'web/lib/firebase/contracts' @@ -39,6 +38,8 @@ import { SiteLink } from 'web/components/site-link' import { Button } from 'web/components/button' import { AddFundsModal } from 'web/components/add-funds-modal' import ShortToggle from 'web/components/widgets/short-toggle' +import { Input } from 'web/components/input' +import { ExpandingInput } from 'web/components/expanding-input' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { return { props: { auth: await getUserAndPrivateUser(creds.uid) } } @@ -104,9 +105,8 @@ export default function Create(props: { auth: { user: User } }) { </span> </label> - <Textarea + <ExpandingInput placeholder="e.g. Will the Democrats win the 2024 US presidential election?" - className="input input-bordered resize-none" autoFocus maxLength={MAX_QUESTION_LENGTH} value={question} @@ -329,9 +329,9 @@ export function NewContract(props: { </label> <Row className="gap-2"> - <input + <Input type="number" - className="input input-bordered w-32" + className="w-32" placeholder="LOW" onClick={(e) => e.stopPropagation()} onChange={(e) => setMinString(e.target.value)} @@ -340,9 +340,9 @@ export function NewContract(props: { disabled={isSubmitting} value={minString ?? ''} /> - <input + <Input type="number" - className="input input-bordered w-32" + className="w-32" placeholder="HIGH" onClick={(e) => e.stopPropagation()} onChange={(e) => setMaxString(e.target.value)} @@ -374,9 +374,8 @@ export function NewContract(props: { </label> <Row className="gap-2"> - <input + <Input type="number" - className="input input-bordered" placeholder="Initial value" onClick={(e) => e.stopPropagation()} onChange={(e) => setInitialValueString(e.target.value)} @@ -446,19 +445,17 @@ export function NewContract(props: { className={'col-span-4 sm:col-span-2'} /> </Row> - <Row> - <input + <Row className="mt-4 gap-2"> + <Input type={'date'} - className="input input-bordered mt-4" onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseDate(e.target.value)} min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS} disabled={isSubmitting} value={closeDate} /> - <input + <Input type={'time'} - className="input input-bordered mt-4 ml-2" onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseHoursMinutes(e.target.value)} min={'00:00'} diff --git a/web/pages/date-docs/create.tsx b/web/pages/date-docs/create.tsx index a0fe8922..639ed51c 100644 --- a/web/pages/date-docs/create.tsx +++ b/web/pages/date-docs/create.tsx @@ -1,7 +1,5 @@ import Router from 'next/router' import { useEffect, useState } from 'react' -import Textarea from 'react-expanding-textarea' - import { DateDoc } from 'common/post' import { useTextEditor, TextEditor } from 'web/components/editor' import { Page } from 'web/components/page' @@ -17,6 +15,8 @@ import { MAX_QUESTION_LENGTH } from 'common/contract' import { NoSEO } from 'web/components/NoSEO' import ShortToggle from 'web/components/widgets/short-toggle' import { removeUndefinedProps } from 'common/util/object' +import { Input } from 'web/components/input' +import { ExpandingInput } from 'web/components/expanding-input' export default function CreateDateDocPage() { const user = useUser() @@ -94,9 +94,8 @@ export default function CreateDateDocPage() { <Col className="gap-8"> <Col className="max-w-[160px] justify-start gap-4"> <div className="">Birthday</div> - <input + <Input type={'date'} - className="input input-bordered" onClick={(e) => e.stopPropagation()} onChange={(e) => setBirthday(e.target.value)} max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS} @@ -122,8 +121,7 @@ export default function CreateDateDocPage() { </Row> <Col className="gap-2"> - <Textarea - className="input input-bordered resize-none" + <ExpandingInput maxLength={MAX_QUESTION_LENGTH} value={question} onChange={(e) => setQuestion(e.target.value || '')} diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index d5c73913..90781da9 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -20,6 +20,7 @@ import { SEO } from 'web/components/SEO' import { GetServerSideProps } from 'next' import { authenticateOnServer } from 'web/lib/firebase/server-auth' import { useUser } from 'web/hooks/use-user' +import { Input } from 'web/components/input' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) @@ -106,12 +107,12 @@ export default function Groups(props: { title: 'All', content: ( <Col> - <input + <Input type="text" onChange={(e) => debouncedQuery(e.target.value)} placeholder="Search groups" value={query} - className="input input-bordered mb-4 w-full" + className="mb-4 w-full" /> <div className="flex flex-wrap justify-center gap-4"> @@ -134,12 +135,12 @@ export default function Groups(props: { title: 'My Groups', content: ( <Col> - <input + <Input type="text" value={query} onChange={(e) => debouncedQuery(e.target.value)} placeholder="Search your groups" - className="input input-bordered mb-4 w-full" + className="mb-4 w-full" /> <div className="flex flex-wrap justify-center gap-4"> diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index 7b3f79d2..0b0a47c1 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -48,6 +48,7 @@ import { } from 'web/hooks/use-contracts' import { ProfitBadge } from 'web/components/profit-badge' import { LoadingIndicator } from 'web/components/loading-indicator' +import { Input } from 'web/components/input' export default function Home() { const user = useUser() @@ -99,10 +100,10 @@ export default function Home() { <Row className={'mb-2 w-full items-center justify-between gap-4 sm:gap-8'} > - <input + <Input type="text" placeholder={'Search'} - className="input input-bordered w-full" + className="w-full" onClick={() => Router.push('/search')} /> <CustomizeButton justIcon /> diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index b7607f8b..04878c6c 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -3,8 +3,9 @@ import { PrivateUser, User } from 'common/user' import { cleanDisplayName, cleanUsername } from 'common/util/clean-username' import Link from 'next/link' import React, { useState } from 'react' -import Textarea from 'react-expanding-textarea' import { ConfirmationButton } from 'web/components/confirmation-button' +import { ExpandingInput } from 'web/components/expanding-input' +import { Input } from 'web/components/input' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' @@ -43,16 +44,15 @@ function EditUserField(props: { <label className="label">{label}</label> {field === 'bio' ? ( - <Textarea - className="textarea textarea-bordered w-full resize-none" + <ExpandingInput + className="w-full" value={value} onChange={(e) => setValue(e.target.value)} onBlur={updateField} /> ) : ( - <input + <Input type="text" - className="input input-bordered" value={value} onChange={(e) => setValue(e.target.value || '')} onBlur={updateField} @@ -152,10 +152,9 @@ export default function ProfilePage(props: { <div> <label className="label">Display name</label> - <input + <Input type="text" placeholder="Display name" - className="input input-bordered" value={name} onChange={(e) => setName(e.target.value || '')} onBlur={updateDisplayName} @@ -164,10 +163,9 @@ export default function ProfilePage(props: { <div> <label className="label">Username</label> - <input + <Input type="text" placeholder="Username" - className="input input-bordered" value={username} onChange={(e) => setUsername(e.target.value || '')} onBlur={updateUsername} @@ -199,10 +197,9 @@ export default function ProfilePage(props: { <div> <label className="label">API key</label> <div className="input-group w-full"> - <input + <Input type="text" placeholder="Click refresh to generate key" - className="input input-bordered w-full" value={apiKey} readOnly /> From a31096395231e28a0af26e9589e69405c3837b94 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 07:01:44 -0600 Subject: [PATCH 43/44] update prefs safely --- .../add-new-notification-preference.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/functions/src/scripts/add-new-notification-preference.ts b/functions/src/scripts/add-new-notification-preference.ts index d7e7072b..a9d3baef 100644 --- a/functions/src/scripts/add-new-notification-preference.ts +++ b/functions/src/scripts/add-new-notification-preference.ts @@ -1,25 +1,33 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { getAllPrivateUsers } from 'functions/src/utils' +import { filterDefined } from 'common/lib/util/array' +import { getPrivateUser } from '../utils' initAdmin() const firestore = admin.firestore() async function main() { - const privateUsers = await getAllPrivateUsers() + // const privateUsers = await getAllPrivateUsers() + const privateUsers = filterDefined([ + await getPrivateUser('ddSo9ALC15N9FAZdKdA2qE3iIvH3'), + ]) await Promise.all( privateUsers.map((privateUser) => { if (!privateUser.id) return Promise.resolve() - return firestore - .collection('private-users') - .doc(privateUser.id) - .update({ - notificationPreferences: { - ...privateUser.notificationPreferences, - opt_out_all: [], - }, - }) + if (privateUser.notificationPreferences.opt_out_all === undefined) { + console.log('updating opt out all', privateUser.id) + return firestore + .collection('private-users') + .doc(privateUser.id) + .update({ + notificationPreferences: { + ...privateUser.notificationPreferences, + opt_out_all: [], + }, + }) + } + return }) ) } From dea65a4ba03a4ef0d756c991f4006ca1cbf2820c Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 07:41:41 -0600 Subject: [PATCH 44/44] Better error handling for notification destinations --- common/user-notification-preferences.ts | 63 ++++++++++++++---------- functions/src/emails.ts | 56 +++++++-------------- functions/src/weekly-portfolio-emails.ts | 33 +++++-------- 3 files changed, 68 insertions(+), 84 deletions(-) diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index ae199e77..de01a6cb 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -178,31 +178,44 @@ export const getNotificationDestinationsForUser = ( reason: notification_reason_types | notification_preference ) => { const notificationSettings = privateUser.notificationPreferences - let destinations - let subscriptionType: notification_preference | undefined - if (Object.keys(notificationSettings).includes(reason)) { - subscriptionType = reason as notification_preference - destinations = notificationSettings[subscriptionType] - } else { - const key = reason as notification_reason_types - subscriptionType = notificationReasonToSubscriptionType[key] - destinations = subscriptionType - ? notificationSettings[subscriptionType] - : [] - } - const optOutOfAllSettings = notificationSettings['opt_out_all'] - // Your market closure notifications are high priority, opt-out doesn't affect their delivery - const optedOutOfEmail = - optOutOfAllSettings.includes('email') && - subscriptionType !== 'your_contract_closed' - const optedOutOfBrowser = - optOutOfAllSettings.includes('browser') && - subscriptionType !== 'your_contract_closed' const unsubscribeEndpoint = getFunctionUrl('unsubscribe') - return { - sendToEmail: destinations.includes('email') && !optedOutOfEmail, - sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, - unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, - urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + try { + let destinations + let subscriptionType: notification_preference | undefined + if (Object.keys(notificationSettings).includes(reason)) { + subscriptionType = reason as notification_preference + destinations = notificationSettings[subscriptionType] + } else { + const key = reason as notification_reason_types + subscriptionType = notificationReasonToSubscriptionType[key] + destinations = subscriptionType + ? notificationSettings[subscriptionType] + : [] + } + const optOutOfAllSettings = notificationSettings['opt_out_all'] + // Your market closure notifications are high priority, opt-out doesn't affect their delivery + const optedOutOfEmail = + optOutOfAllSettings.includes('email') && + subscriptionType !== 'your_contract_closed' + const optedOutOfBrowser = + optOutOfAllSettings.includes('browser') && + subscriptionType !== 'your_contract_closed' + return { + sendToEmail: destinations.includes('email') && !optedOutOfEmail, + sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, + unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, + urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + } + } catch (e) { + // Fail safely + console.log( + `couldn't get notification destinations for type ${reason} for user ${privateUser.id}` + ) + return { + sendToEmail: false, + sendToBrowser: false, + unsubscribeUrl: '', + urlToManageThisNotification: '', + } } } diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 993fac81..31129b71 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { contractUrl, getUser } from './utils' +import { contractUrl, getUser, log } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { notification_reason_types } from '../../common/notification' import { Dictionary } from 'lodash' @@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async ( user: User, privateUser: PrivateUser ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.onboarding_flow.includes('email') - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'onboarding_flow' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, @@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async ( privateUser: PrivateUser, sendTime: string ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.onboarding_flow.includes('email') - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'onboarding_flow' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, 'Create your own prediction market', @@ -279,22 +271,16 @@ export const sendThankYouEmail = async ( user: User, privateUser: PrivateUser ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.thank_you_for_purchases.includes( - 'email' - ) - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'thank_you_for_purchases' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, 'Thanks for your Manifold purchase', @@ -466,17 +452,13 @@ export const sendInterestingMarketsEmail = async ( contractsToSend: Contract[], deliveryTime?: string ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.trending_markets.includes('email') - ) - return + if (!privateUser || !privateUser.email) return - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'trending_markets' ) + if (!sendToEmail) return const { name } = user const firstName = name.split(' ')[0] @@ -620,18 +602,15 @@ export const sendWeeklyPortfolioUpdateEmail = async ( investments: PerContractInvestmentsData[], overallPerformance: OverallPerformanceData ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.profit_loss_updates.includes('email') - ) - return + if (!privateUser || !privateUser.email) return - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'profit_loss_updates' ) + if (!sendToEmail) return + const { name } = user const firstName = name.split(' ')[0] const templateData: Record<string, string> = { @@ -656,4 +635,5 @@ export const sendWeeklyPortfolioUpdateEmail = async ( : 'portfolio-update', templateData ) + log('Sent portfolio update email to', privateUser.email) } diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index bcf6da17..215694eb 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { ) ) ) - log('Found', contractsUsersBetOn.length, 'contracts') - let count = 0 await Promise.all( privateUsersToSendEmailsTo.map(async (privateUser) => { const user = await getUser(privateUser.id) // Don't send to a user unless they're over 5 days old - if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return + if (!user || user.createdTime > Date.now() - 5 * DAY_MS) + return await setEmailFlagAsSent(privateUser.id) const userBets = usersBets[privateUser.id] as Bet[] const contractsUserBetOn = contractsUsersBetOn.filter((contract) => userBets.some((bet) => bet.contractId === contract.id) @@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { (differences) => Math.abs(differences.profit) ).reverse() - log( - 'Found', - investmentValueDifferences.length, - 'investment differences for user', - privateUser.id - ) - const [winningInvestments, losingInvestments] = partition( investmentValueDifferences.filter( (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1 @@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { usersToContractsCreated[privateUser.id].length === 0 ) { log( - 'No bets in last week, no market movers, no markets created. Not sending an email.' + `No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .` ) - await firestore.collection('private-users').doc(privateUser.id).update({ - weeklyPortfolioUpdateEmailSent: true, - }) - return + return await setEmailFlagAsSent(privateUser.id) } + // Set the flag beforehand just to be safe + await setEmailFlagAsSent(privateUser.id) await sendWeeklyPortfolioUpdateEmail( user, privateUser, topInvestments.concat(worstInvestments) as PerContractInvestmentsData[], performanceData ) - await firestore.collection('private-users').doc(privateUser.id).update({ - weeklyPortfolioUpdateEmailSent: true, - }) - log('Sent weekly portfolio update email to', privateUser.email) - count++ - log('sent out emails to users:', count) }) ) } +async function setEmailFlagAsSent(privateUserId: string) { + await firestore.collection('private-users').doc(privateUserId).update({ + weeklyPortfolioUpdateEmailSent: true, + }) +} + export type PerContractInvestmentsData = { questionTitle: string questionUrl: string