From 6dcbc92a665fa68f689cc8d7cc59863e1c9a892c Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Wed, 22 Jun 2022 19:04:25 -0500 Subject: [PATCH 001/220] Update firestore.indexes.json --- firestore.indexes.json | 236 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/firestore.indexes.json b/firestore.indexes.json index e611f18a..5dd029df 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -1,5 +1,69 @@ { "indexes": [ + { + "collectionGroup": "bets", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "isAnte", + "order": "ASCENDING" + }, + { + "fieldPath": "isRedemption", + "order": "ASCENDING" + }, + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "bets", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "comments", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "creatorId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "ASCENDING" + } + ] + }, { "collectionGroup": "contracts", "queryScope": "COLLECTION", @@ -14,6 +78,20 @@ } ] }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "autoResolutionTime", + "order": "ASCENDING" + } + ] + }, { "collectionGroup": "contracts", "queryScope": "COLLECTION", @@ -104,6 +182,24 @@ } ] }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "volume7Days", + "order": "ASCENDING" + } + ] + }, { "collectionGroup": "contracts", "queryScope": "COLLECTION", @@ -118,6 +214,84 @@ } ] }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "volume7Days", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "volume7Days", + "order": "ASCENDING" + }, + { + "fieldPath": "closeTime", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "volume7Days", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "volume7Days", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "lowercaseTags", + "arrayConfig": "CONTAINS" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "contracts", "queryScope": "COLLECTION", @@ -131,6 +305,24 @@ "order": "DESCENDING" } ] + }, + { + "collectionGroup": "txns", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "toId", + "order": "ASCENDING" + }, + { + "fieldPath": "toType", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] } ], "fieldOverrides": [ @@ -295,6 +487,50 @@ "queryScope": "COLLECTION_GROUP" } ] + }, + { + "collectionGroup": "follows", + "fieldPath": "userId", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, + { + "collectionGroup": "portfolioHistory", + "fieldPath": "timestamp", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] } ] } From 4eedf65b211a76bd0c8d0e2a74dbeea1c2bb4be6 Mon Sep 17 00:00:00 2001 From: ahalekelly Date: Thu, 23 Jun 2022 00:37:04 -0700 Subject: [PATCH 002/220] API update pool (#553) * Update api pool and totalLiquidity * fix pool type * reverting totalLiquidity changes * pool and totalLiquidity docs description * Changed pool type to match elsewhere --- docs/docs/api.md | 6 +++--- web/pages/api/v0/_types.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/docs/api.md b/docs/docs/api.md index e7e6a341..a25d245c 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -115,10 +115,10 @@ Requires no authorization. outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC mechanism: string // dpm-2 or cpmm-1 - pool: number // sum of YES and NO shares in liquidity pool for CPMM, null for DPM probability: number - p?: number // probability constant in y^p * n^(1-p) = k - totalLiquidity?: number + pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer. + p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k + totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool volume: number volume7Days: number diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index 4909efc9..d3a74053 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -24,7 +24,7 @@ export type LiteMarket = { outcomeType: string mechanism: string - pool: number + pool: { [outcome: string]: number } probability?: number p?: number totalLiquidity?: number @@ -96,7 +96,7 @@ export function toLiteMarket(contract: Contract): LiteMarket { description, tags, url: `https://manifold.markets/${creatorUsername}/${slug}`, - pool: pool.YES + pool.NO, + pool, probability, p, totalLiquidity, From 6cc2d8af58bf9680ba2574e2fec4e93c25882518 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Thu, 23 Jun 2022 03:07:52 -0500 Subject: [PATCH 003/220] Manalink: Send mana to anyone via link (#114) * Set up Firestore structure for mana bounty links * Split up manalinks into successes and failures * Allow clients to create manalinks * Track txnId and successful users * Store custom amounts in the link * List all manalinks you've created * Support backend for claiming manalinks * Add some more error handling * Tweak readme * Fix typescript breakage * Revert "Convert common imports in functions to be absolute" This reverts commit c03518e9063848f4116e61d3a4f1188f7acedbb8. * Scaffolding so `claimManalink` works * Clean up imports * Barebones endpoint to claim mana * Fix rules to only allow link creators to query * Design out claim giftcard * List all claimed transactions * Style in a more awesome card * Fix import * Padding tweak * Fix useManalinkTxns hook * /send -> /link * Tidy up some details * Do a bunch of random manalinks work * Fix up LinksTable to build * Clean up LinksTable an absurd amount * Basic details functionality on manalinks table * Work on manalink claim stuff * Fix up some merge mess * Not-signed-in flow implemented * Better manalinks table * Only show outstanding links in table * Use new `ManalinkTxn` type * /link -> /links * Change manalinks page UI to use nice looking tabs * Many fixes to manalinks UI * Default to 1 use * Tidying up * Some copy changes based on feedback * Add required index Co-authored-by: Marshall Polaris --- common/manalink.ts | 35 +++ common/txn.ts | 10 +- firestore.indexes.json | 14 ++ firestore.rules | 10 + functions/README.md | 2 +- functions/src/claim-manalink.ts | 102 ++++++++ functions/src/index.ts | 1 + functions/src/transact.ts | 124 ++++------ web/components/layout/tabs.tsx | 1 + web/components/manalink-card.tsx | 69 ++++++ web/components/user-page.tsx | 24 +- web/lib/firebase/fn-call.ts | 5 + web/lib/firebase/manalinks.ts | 94 ++++++++ web/lib/firebase/txns.ts | 30 ++- web/lib/firebase/users.ts | 2 +- web/package.json | 1 + web/pages/link/[slug].tsx | 68 ++++++ web/pages/links.tsx | 386 +++++++++++++++++++++++++++++++ yarn.lock | 5 + 19 files changed, 901 insertions(+), 82 deletions(-) create mode 100644 common/manalink.ts create mode 100644 functions/src/claim-manalink.ts create mode 100644 web/components/manalink-card.tsx create mode 100644 web/lib/firebase/manalinks.ts create mode 100644 web/pages/link/[slug].tsx create mode 100644 web/pages/links.tsx diff --git a/common/manalink.ts b/common/manalink.ts new file mode 100644 index 00000000..7dc3b8dc --- /dev/null +++ b/common/manalink.ts @@ -0,0 +1,35 @@ +export type Manalink = { + // The link to send: https://manifold.markets/send/{slug} + // Also functions as the unique id for the link. + slug: string + + // Note: we assume both fromId and toId are of SourceType 'USER' + fromId: string + + // Displayed to people claiming the link + message: string + + // How much to send with the link + amount: number + token: 'M$' // TODO: could send eg YES shares too?? + + createdTime: number + // If null, the link is valid forever + expiresTime: number | null + // If null, the link can be used infinitely + maxUses: number | null + + // Used for simpler caching + claimedUserIds: string[] + // Successful redemptions of the link + claims: Claim[] +} + +export type Claim = { + toId: string + + // The ID of the successful txn that tracks the money moved + txnId: string + + claimedTime: number +} diff --git a/common/txn.ts b/common/txn.ts index c64eddbb..25d4a1c3 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -1,6 +1,6 @@ // A txn (pronounced "texan") respresents a payment between two ids on Manifold // Shortened from "transaction" to distinguish from Firebase transactions (and save chars) -type AnyTxnType = Donation | Tip +type AnyTxnType = Donation | Tip | Manalink type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn = { @@ -16,6 +16,7 @@ export type Txn = { amount: number token: 'M$' // | 'USD' | MarketOutcome + category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET' // Any extra data data?: { [key: string]: any } @@ -39,5 +40,12 @@ type Tip = { } } +type Manalink = { + fromType: 'USER' + toType: 'USER' + category: 'MANALINK' +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip +export type ManalinkTxn = Txn & Manalink diff --git a/firestore.indexes.json b/firestore.indexes.json index 5dd029df..064f6f2f 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -323,6 +323,20 @@ "order": "DESCENDING" } ] + }, + { + "collectionGroup": "manalinks", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "fromId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] } ], "fieldOverrides": [ diff --git a/firestore.rules b/firestore.rules index 2be7f21a..7f02a43d 100644 --- a/firestore.rules +++ b/firestore.rules @@ -104,6 +104,16 @@ service cloud.firestore { allow read; } + // Note: `resource` = existing doc, `request.resource` = incoming doc + match /manalinks/{slug} { + // Anyone can view any manalink + allow get; + // Only you can create a manalink with your fromId + allow create: if request.auth.uid == request.resource.data.fromId; + // Only you can list and change your own manalinks + allow list, update: if request.auth.uid == resource.data.fromId; + } + match /users/{userId}/notifications/{notificationId} { allow read; allow update: if resource.data.userId == request.auth.uid diff --git a/functions/README.md b/functions/README.md index 7fd312c3..031cc4fa 100644 --- a/functions/README.md +++ b/functions/README.md @@ -52,7 +52,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started ## Deploying 0. `$ firebase use prod` to switch to prod -1. `$ yarn deploy` to push your changes live! +1. `$ firebase deploy --only functions` to push your changes live! (Future TODO: auto-deploy functions on Git push) ## Secrets management diff --git a/functions/src/claim-manalink.ts b/functions/src/claim-manalink.ts new file mode 100644 index 00000000..4bcd8b16 --- /dev/null +++ b/functions/src/claim-manalink.ts @@ -0,0 +1,102 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { User } from 'common/user' +import { Manalink } from 'common/manalink' +import { runTxn, TxnData } from './transact' + +export const claimManalink = functions + .runWith({ minInstances: 1 }) + .https.onCall(async (slug: string, context) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + // Look up the manalink + const manalinkDoc = firestore.doc(`manalinks/${slug}`) + const manalinkSnap = await transaction.get(manalinkDoc) + if (!manalinkSnap.exists) { + return { status: 'error', message: 'Manalink not found' } + } + const manalink = manalinkSnap.data() as Manalink + + const { amount, fromId, claimedUserIds } = manalink + + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) + return { status: 'error', message: 'Invalid amount' } + + const fromDoc = firestore.doc(`users/${fromId}`) + const fromSnap = await transaction.get(fromDoc) + if (!fromSnap.exists) { + return { status: 'error', message: `User ${fromId} not found` } + } + const fromUser = fromSnap.data() as User + + // Only permit one redemption per user per link + if (claimedUserIds.includes(userId)) { + return { + status: 'error', + message: `${fromUser.name} already redeemed manalink ${slug}`, + } + } + + // Disallow expired or maxed out links + if (manalink.expiresTime != null && manalink.expiresTime < Date.now()) { + return { + status: 'error', + message: `Manalink ${slug} expired on ${new Date( + manalink.expiresTime + ).toLocaleString()}`, + } + } + if ( + manalink.maxUses != null && + manalink.maxUses <= manalink.claims.length + ) { + return { + status: 'error', + message: `Manalink ${slug} has reached its max uses of ${manalink.maxUses}`, + } + } + + if (fromUser.balance < amount) { + return { + status: 'error', + message: `Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} `, + } + } + + // Actually execute the txn + const data: TxnData = { + fromId, + fromType: 'USER', + toId: userId, + toType: 'USER', + amount, + token: 'M$', + category: 'MANALINK', + description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${userId}`, + } + const result = await runTxn(transaction, data) + const txnId = result.txn?.id + if (!txnId) { + return { status: 'error', message: result.message } + } + + // Update the manalink object with this info + const claim = { + toId: userId, + txnId, + claimedTime: Date.now(), + } + transaction.update(manalinkDoc, { + claimedUserIds: [...claimedUserIds, userId], + claims: [...manalink.claims, claim], + }) + + return { status: 'success', message: 'Manalink claimed' } + }) + }) + +const firestore = admin.firestore() diff --git a/functions/src/index.ts b/functions/src/index.ts index 7bcd2199..0a538ff8 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -4,6 +4,7 @@ admin.initializeApp() // v1 // export * from './keep-awake' +export * from './claim-manalink' export * from './transact' export * from './resolve-market' export * from './stripe' diff --git a/functions/src/transact.ts b/functions/src/transact.ts index 397632ea..cd091b83 100644 --- a/functions/src/transact.ts +++ b/functions/src/transact.ts @@ -5,23 +5,15 @@ import { User } from '../../common/user' import { Txn } from '../../common/txn' import { removeUndefinedProps } from '../../common/util/object' +export type TxnData = Omit + export const transact = functions .runWith({ minInstances: 1 }) - .https.onCall(async (data: Omit, context) => { + .https.onCall(async (data: TxnData, context) => { const userId = context?.auth?.uid if (!userId) return { status: 'error', message: 'Not authorized' } - const { - amount, - fromType, - fromId, - toId, - toType, - category, - token, - data: innerData, - description, - } = data + const { amount, fromType, fromId } = data if (fromType !== 'USER') return { @@ -40,69 +32,53 @@ export const transact = functions // Run as transaction to prevent race conditions. return await firestore.runTransaction(async (transaction) => { - const fromDoc = firestore.doc(`users/${userId}`) - const fromSnap = await transaction.get(fromDoc) - if (!fromSnap.exists) { - return { status: 'error', message: 'User not found' } - } - const fromUser = fromSnap.data() as User - - if (amount > 0 && fromUser.balance < amount) { - return { - status: 'error', - message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `, - } - } - - if (toType === 'USER') { - const toDoc = firestore.doc(`users/${toId}`) - const toSnap = await transaction.get(toDoc) - if (!toSnap.exists) { - return { status: 'error', message: 'User not found' } - } - const toUser = toSnap.data() as User - if (amount < 0 && toUser.balance < -amount) { - return { - status: 'error', - message: `Insufficient balance: ${ - toUser.username - } needed ${-amount} but only had ${toUser.balance} `, - } - } - - transaction.update(toDoc, { - balance: toUser.balance + amount, - totalDeposits: toUser.totalDeposits + amount, - }) - } - - const newTxnDoc = firestore.collection(`txns/`).doc() - - const txn = removeUndefinedProps({ - id: newTxnDoc.id, - createdTime: Date.now(), - - fromId, - fromType, - toId, - toType, - - amount, - category, - data: innerData, - token, - - description, - }) - - transaction.create(newTxnDoc, txn) - transaction.update(fromDoc, { - balance: fromUser.balance - amount, - totalDeposits: fromUser.totalDeposits - amount, - }) - - return { status: 'success', txn } + await runTxn(transaction, data) }) }) +export async function runTxn( + fbTransaction: admin.firestore.Transaction, + data: TxnData +) { + const { amount, fromId, toId, toType } = data + + const fromDoc = firestore.doc(`users/${fromId}`) + const fromSnap = await fbTransaction.get(fromDoc) + if (!fromSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const fromUser = fromSnap.data() as User + + if (fromUser.balance < amount) { + return { + status: 'error', + message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `, + } + } + + // TODO: Track payments received by charities, bank, contracts too. + if (toType === 'USER') { + const toDoc = firestore.doc(`users/${toId}`) + const toSnap = await fbTransaction.get(toDoc) + if (!toSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const toUser = toSnap.data() as User + fbTransaction.update(toDoc, { + balance: toUser.balance + amount, + totalDeposits: toUser.totalDeposits + amount, + }) + } + + const newTxnDoc = firestore.collection(`txns/`).doc() + const txn = { id: newTxnDoc.id, createdTime: Date.now(), ...data } + fbTransaction.create(newTxnDoc, removeUndefinedProps(txn)) + fbTransaction.update(fromDoc, { + balance: fromUser.balance - amount, + totalDeposits: fromUser.totalDeposits - amount, + }) + + return { status: 'success', txn } +} + const firestore = admin.firestore() diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index b5a70a5e..69e8cfab 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -28,6 +28,7 @@ export function Tabs(props: { {tabs.map((tab, i) => ( { if (!tab.href) { diff --git a/web/components/manalink-card.tsx b/web/components/manalink-card.tsx new file mode 100644 index 00000000..97f5951c --- /dev/null +++ b/web/components/manalink-card.tsx @@ -0,0 +1,69 @@ +import clsx from 'clsx' +import { formatMoney } from 'common/util/format' +import { fromNow } from 'web/lib/util/time' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' + +export type ManalinkInfo = { + expiresTime: number | null + maxUses: number | null + uses: number + amount: number + message: string +} + +export function ManalinkCard(props: { + className?: string + info: ManalinkInfo + defaultMessage: string + isClaiming: boolean + onClaim?: () => void +}) { + const { className, defaultMessage, isClaiming, info, onClaim } = props + const { expiresTime, maxUses, uses, amount, message } = info + return ( +
+ +
+ {maxUses != null + ? `${maxUses - uses}/${maxUses} uses left` + : `Unlimited use`} +
+
+ {expiresTime != null + ? `Expires ${fromNow(expiresTime)}` + : 'Never expires'} +
+ + + + + +
+ {formatMoney(amount)} +
+
{message || defaultMessage}
+ + +
+ +
+
+
+ ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 04aaf67f..cd896c59 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -1,7 +1,10 @@ import clsx from 'clsx' import { uniq } from 'lodash' +import { useEffect, useState } from 'react' +import { useRouter } from 'next/router' import { LinkIcon } from '@heroicons/react/solid' import { PencilIcon } from '@heroicons/react/outline' +import Confetti from 'react-confetti' import { follow, unfollow, User } from 'web/lib/firebase/users' import { CreatorContractsList } from './contract/contracts-list' @@ -16,7 +19,7 @@ import { Row } from './layout/row' import { genHash } from 'common/util/random' import { Tabs } from './layout/tabs' import { UserCommentsList } from './comments-list' -import { useEffect, useState } from 'react' +import { useWindowSize } from 'web/hooks/use-window-size' import { Comment, getUsersComments } from 'web/lib/firebase/comments' import { Contract } from 'common/contract' import { getContractFromId, listContracts } from 'web/lib/firebase/contracts' @@ -27,7 +30,6 @@ import { getUserBets } from 'web/lib/firebase/bets' import { FollowersButton, FollowingButton } from './following-button' import { useFollows } from 'web/hooks/use-follows' import { FollowButton } from './follow-button' -import { useRouter } from 'next/router' export function UserLink(props: { name: string @@ -57,6 +59,7 @@ export function UserPage(props: { defaultTabTitle?: string | undefined }) { const { user, currentUser, defaultTabTitle } = props + const router = useRouter() const isCurrentUser = user.id === currentUser?.id const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) const [usersComments, setUsersComments] = useState([] as Comment[]) @@ -67,7 +70,13 @@ export function UserPage(props: { const [commentsByContract, setCommentsByContract] = useState< Map | 'loading' >('loading') - const router = useRouter() + const [showConfetti, setShowConfetti] = useState(false) + const { width, height } = useWindowSize() + + useEffect(() => { + const claimedMana = router.query['claimed-mana'] === 'yes' + setShowConfetti(claimedMana) + }, [router]) useEffect(() => { if (!user) return @@ -117,7 +126,14 @@ export function UserPage(props: { description={user.bio ?? ''} url={`/${user.username}`} /> - + {showConfetti && ( + + )} {/* Banner image up top, with an circle avatar overlaid */}
{ .then((r) => r.data as { status: string }) .catch((e) => ({ status: 'error', message: e.message })) } + +export const claimManalink = cloudFunction< + string, + { status: 'error' | 'success'; message?: string } +>('claimManalink') diff --git a/web/lib/firebase/manalinks.ts b/web/lib/firebase/manalinks.ts new file mode 100644 index 00000000..67c7a00a --- /dev/null +++ b/web/lib/firebase/manalinks.ts @@ -0,0 +1,94 @@ +import { + collection, + getDoc, + orderBy, + query, + setDoc, + where, +} from 'firebase/firestore' +import { doc } from 'firebase/firestore' +import { Manalink } from '../../../common/manalink' +import { db } from './init' +import { customAlphabet } from 'nanoid' +import { listenForValues } from './utils' +import { useEffect, useState } from 'react' + +export async function createManalink(data: { + fromId: string + amount: number + expiresTime: number | null + maxUses: number | null + message: string +}) { + const { fromId, amount, expiresTime, maxUses, message } = data + + // At 100 IDs per hour, using this alphabet and 8 chars, there's a 1% chance of collision in 2 years + // See https://zelark.github.io/nano-id-cc/ + const nanoid = customAlphabet( + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', + 8 + ) + const slug = nanoid() + + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) return null + + const manalink: Manalink = { + slug, + fromId, + amount, + token: 'M$', + createdTime: Date.now(), + expiresTime, + maxUses, + claimedUserIds: [], + claims: [], + message, + } + + const ref = doc(db, 'manalinks', slug) + await setDoc(ref, manalink) + return slug +} + +const manalinkCol = collection(db, 'manalinks') + +// TODO: This required an index, make sure to also set up in prod +function listUserManalinks(fromId?: string) { + return query( + manalinkCol, + where('fromId', '==', fromId), + orderBy('createdTime', 'desc') + ) +} + +export async function getManalink(slug: string) { + const docSnap = await getDoc(doc(db, 'manalinks', slug)) + return docSnap.data() as Manalink +} + +export function useManalink(slug: string) { + const [manalink, setManalink] = useState(null) + useEffect(() => { + if (slug) { + getManalink(slug).then(setManalink) + } + }, [slug]) + return manalink +} + +export function listenForUserManalinks( + fromId: string | undefined, + setLinks: (links: Manalink[]) => void +) { + return listenForValues(listUserManalinks(fromId), setLinks) +} + +export const useUserManalinks = (fromId: string) => { + const [links, setLinks] = useState([]) + + useEffect(() => { + return listenForUserManalinks(fromId, setLinks) + }, [fromId]) + + return links +} diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts index 58ba7bf6..c4c8aa93 100644 --- a/web/lib/firebase/txns.ts +++ b/web/lib/firebase/txns.ts @@ -1,7 +1,9 @@ -import { DonationTxn, TipTxn } from 'common/txn' +import { ManalinkTxn, DonationTxn, TipTxn } from 'common/txn' import { collection, orderBy, query, where } from 'firebase/firestore' import { db } from './init' import { getValues, listenForValues } from './utils' +import { useState, useEffect } from 'react' +import { orderBy as _orderBy } from 'lodash' const txnCollection = collection(db, 'txns') @@ -39,3 +41,29 @@ export function listenForTipTxns( ) { return listenForValues(getTipsQuery(contractId), setTxns) } + +// Find all manalink Txns that are from or to this user +export function useManalinkTxns(userId: string) { + const [fromTxns, setFromTxns] = useState([]) + const [toTxns, setToTxns] = useState([]) + + useEffect(() => { + // TODO: Need to instantiate these indexes too + const fromQuery = query( + txnCollection, + where('fromId', '==', userId), + where('category', '==', 'MANALINK'), + orderBy('createdTime', 'desc') + ) + const toQuery = query( + txnCollection, + where('toId', '==', userId), + where('category', '==', 'MANALINK'), + orderBy('createdTime', 'desc') + ) + listenForValues(fromQuery, setFromTxns) + listenForValues(toQuery, setToTxns) + }, [userId]) + + return _orderBy([...fromTxns, ...toTxns], ['createdTime'], ['desc']) +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index bc743cf2..97e30aa5 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -128,7 +128,7 @@ export function listenForLogin(onUser: (user: User | null) => void) { export async function firebaseLogin() { const provider = new GoogleAuthProvider() - signInWithPopup(auth, provider) + return signInWithPopup(auth, provider) } export async function firebaseLogout() { diff --git a/web/package.json b/web/package.json index 4fc83ad0..5b05a292 100644 --- a/web/package.json +++ b/web/package.json @@ -32,6 +32,7 @@ "gridjs": "5.0.2", "gridjs-react": "5.0.2", "lodash": "4.17.21", + "nanoid": "^3.3.4", "next": "12.1.2", "node-fetch": "3.2.4", "react": "17.0.2", diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx new file mode 100644 index 00000000..0b0186ed --- /dev/null +++ b/web/pages/link/[slug].tsx @@ -0,0 +1,68 @@ +import { useRouter } from 'next/router' +import { useState } from 'react' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { claimManalink } from 'web/lib/firebase/fn-call' +import { useManalink } from 'web/lib/firebase/manalinks' +import { ManalinkCard } from 'web/components/manalink-card' +import { useUser } from 'web/hooks/use-user' +import { useUserById } from 'web/hooks/use-users' +import { firebaseLogin } from 'web/lib/firebase/users' + +export default function ClaimPage() { + const user = useUser() + const router = useRouter() + const { slug } = router.query as { slug: string } + const manalink = useManalink(slug) + const [claiming, setClaiming] = useState(false) + const [error, setError] = useState(undefined) + + const fromUser = useUserById(manalink?.fromId) + if (!manalink) { + return <> + } + + const info = { ...manalink, uses: manalink.claims.length } + return ( + <> + +
+ + <ManalinkCard + defaultMessage={fromUser?.name || 'Enjoy this mana!'} + info={info} + isClaiming={claiming} + onClaim={async () => { + setClaiming(true) + try { + if (user == null) { + await firebaseLogin() + } + const result = await claimManalink(manalink.slug) + if (result.data.status == 'error') { + throw new Error(result.data.message) + } + router.push('/account?claimed-mana=yes') + } catch (e) { + console.log(e) + const message = + e && e instanceof Object ? e.toString() : 'An error occurred.' + setError(message) + } + setClaiming(false) + }} + /> + {error && ( + <section className="my-5 text-red-500"> + <p>Failed to claim manalink.</p> + <p>{error}</p> + </section> + )} + </div> + </> + ) +} diff --git a/web/pages/links.tsx b/web/pages/links.tsx new file mode 100644 index 00000000..08c99460 --- /dev/null +++ b/web/pages/links.tsx @@ -0,0 +1,386 @@ +import clsx from 'clsx' +import { useState } from 'react' +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' +import { Claim, Manalink } from 'common/manalink' +import { formatMoney } from 'common/util/format' +import { Col } from 'web/components/layout/col' +import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { useUser } from 'web/hooks/use-user' +import { createManalink, useUserManalinks } from 'web/lib/firebase/manalinks' +import { fromNow } from 'web/lib/util/time' +import { useUserById } from 'web/hooks/use-users' +import { ManalinkTxn } from 'common/txn' +import { User } from 'common/user' +import { Tabs } from 'web/components/layout/tabs' +import { Avatar } from 'web/components/avatar' +import { RelativeTimestamp } from 'web/components/relative-timestamp' +import { UserLink } from 'web/components/user-page' +import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card' + +import Textarea from 'react-expanding-textarea' + +import dayjs from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +dayjs.extend(customParseFormat) + +function getLinkUrl(slug: string) { + return `${location.protocol}//${location.host}/link/${slug}` +} + +// TODO: incredibly gross, but the tab component is wrongly designed and +// keeps the tab state inside of itself, so this seems like the only +// way we can tell it to switch tabs from outside after initial render. +function setTabIndex(tabIndex: number) { + const tabHref = document.getElementById(`tab-${tabIndex}`) + if (tabHref) { + tabHref.click() + } +} + +export default function LinkPage() { + const user = useUser() + const links = useUserManalinks(user?.id ?? '') + // const manalinkTxns = useManalinkTxns(user?.id ?? '') + const [highlightedSlug, setHighlightedSlug] = useState('') + const unclaimedLinks = links.filter( + (l) => + (l.maxUses == null || l.claimedUserIds.length < l.maxUses) && + (l.expiresTime == null || l.expiresTime > Date.now()) + ) + + if (user == null) { + return null + } + + return ( + <Page> + <SEO + title="Manalinks" + description="Send mana to anyone via link!" + url="/send" + /> + <Col className="w-full px-8"> + <Title text="Manalinks" /> + <Tabs + className={'pb-2 pt-1 '} + defaultIndex={0} + tabs={[ + { + title: 'Create a link', + content: ( + <CreateManalinkForm + user={user} + onCreate={async (newManalink) => { + const slug = await createManalink({ + fromId: user.id, + amount: newManalink.amount, + expiresTime: newManalink.expiresTime, + maxUses: newManalink.maxUses, + message: newManalink.message, + }) + setTabIndex(1) + setHighlightedSlug(slug || '') + }} + /> + ), + }, + { + title: 'Unclaimed links', + content: ( + <LinksTable + links={unclaimedLinks} + highlightedSlug={highlightedSlug} + /> + ), + }, + // TODO: we have no use case for this atm and it's also really inefficient + // { + // title: 'Claimed', + // content: <ClaimsList txns={manalinkTxns} />, + // }, + ]} + /> + </Col> + </Page> + ) +} + +function CreateManalinkForm(props: { + user: User + onCreate: (m: ManalinkInfo) => Promise<void> +}) { + const { user, onCreate } = props + const [isCreating, setIsCreating] = useState(false) + const [newManalink, setNewManalink] = useState<ManalinkInfo>({ + expiresTime: null, + amount: 100, + maxUses: 1, + uses: 0, + message: '', + }) + return ( + <> + <p> + You can use manalinks to send mana to other people, even if they + don't yet have a Manifold account. + </p> + <form + className="my-5" + onSubmit={(e) => { + e.preventDefault() + setIsCreating(true) + onCreate(newManalink).finally(() => setIsCreating(false)) + }} + > + <div className="flex flex-row flex-wrap gap-x-5 gap-y-2"> + <div className="form-control flex-auto"> + <label className="label">Amount</label> + <input + className="input" + type="number" + value={newManalink.amount} + onChange={(e) => + setNewManalink((m) => { + return { ...m, amount: parseInt(e.target.value) } + }) + } + ></input> + </div> + <div className="form-control flex-auto"> + <label className="label">Uses</label> + <input + className="input" + type="number" + value={newManalink.maxUses ?? ''} + onChange={(e) => + setNewManalink((m) => { + return { ...m, maxUses: parseInt(e.target.value) } + }) + } + ></input> + </div> + <div className="form-control flex-auto"> + <label className="label">Expires at</label> + <input + value={ + newManalink.expiresTime != null + ? dayjs(newManalink.expiresTime).format('YYYY-MM-DDTHH:mm') + : '' + } + className="input" + type="datetime-local" + onChange={(e) => { + setNewManalink((m) => { + return { + ...m, + expiresTime: e.target.value + ? dayjs(e.target.value, 'YYYY-MM-DDTHH:mm').valueOf() + : null, + } + }) + }} + ></input> + </div> + </div> + <div className="form-control w-full"> + <label className="label">Message</label> + <Textarea + placeholder={`From ${user.name}`} + className="input input-bordered resize-none" + autoFocus + value={newManalink.message} + onChange={(e) => + setNewManalink((m) => { + return { ...m, message: e.target.value } + }) + } + /> + </div> + <button + type="submit" + className={clsx('btn mt-5', isCreating ? 'loading disabled' : '')} + > + {isCreating ? '' : 'Create'} + </button> + </form> + + <Title text="Preview" /> + <p>This is what the person you send the link to will see:</p> + <ManalinkCard + className="my-5" + defaultMessage={`From ${user.name}`} + info={newManalink} + isClaiming={false} + /> + </> + ) +} + +export function ClaimsList(props: { txns: ManalinkTxn[] }) { + const { txns } = props + return ( + <> + <h1 className="mb-4 text-xl font-semibold text-gray-900"> + Claimed links + </h1> + {txns.map((txn) => ( + <ClaimDescription txn={txn} key={txn.id} /> + ))} + </> + ) +} + +export function ClaimDescription(props: { txn: ManalinkTxn }) { + const { txn } = props + const from = useUserById(txn.fromId) + const to = useUserById(txn.toId) + + if (!from || !to) { + return <>Loading...</> + } + + return ( + <div className="mb-2 flow-root pr-2 md:pr-0"> + <div className="relative flex items-center space-x-3"> + <Avatar username={to.name} avatarUrl={to.avatarUrl} size="sm" /> + <div className="min-w-0 flex-1"> + <p className="mt-0.5 text-sm text-gray-500"> + <UserLink + className="text-gray-500" + username={to.username} + name={to.name} + />{' '} + claimed {formatMoney(txn.amount)} from{' '} + <UserLink + className="text-gray-500" + username={from.username} + name={from.name} + /> + <RelativeTimestamp time={txn.createdTime} /> + </p> + </div> + </div> + </div> + ) +} + +function ClaimTableRow(props: { claim: Claim }) { + const { claim } = props + const who = useUserById(claim.toId) + return ( + <tr> + <td className="px-5 py-2">{who?.name || 'Loading...'}</td> + <td className="px-5 py-2">{`${new Date( + claim.claimedTime + ).toLocaleString()}, ${fromNow(claim.claimedTime)}`}</td> + </tr> + ) +} + +function LinkDetailsTable(props: { link: Manalink }) { + const { link } = props + return ( + <table className="w-full divide-y divide-gray-300 border border-gray-400"> + <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> + <tr> + <th className="px-5 py-2">Claimed by</th> + <th className="px-5 py-2">Time</th> + </tr> + </thead> + <tbody className="divide-y divide-gray-200 bg-white text-sm text-gray-500"> + {link.claims.length ? ( + link.claims.map((claim) => <ClaimTableRow claim={claim} />) + ) : ( + <tr> + <td className="px-5 py-2" colSpan={2}> + No claims yet. + </td> + </tr> + )} + </tbody> + </table> + ) +} + +function LinkTableRow(props: { link: Manalink; highlight: boolean }) { + const { link, highlight } = props + const [expanded, setExpanded] = useState(false) + return ( + <> + <LinkSummaryRow + link={link} + highlight={highlight} + expanded={expanded} + onToggle={() => setExpanded((exp) => !exp)} + /> + {expanded && ( + <tr> + <td className="bg-gray-100 p-3" colSpan={5}> + <LinkDetailsTable link={link} /> + </td> + </tr> + )} + </> + ) +} + +function LinkSummaryRow(props: { + link: Manalink + highlight: boolean + expanded: boolean + onToggle: () => void +}) { + const { link, highlight, expanded, onToggle } = props + const className = clsx( + 'whitespace-nowrap text-sm hover:cursor-pointer', + highlight ? 'bg-primary' : 'text-gray-500 hover:bg-sky-50 bg-white' + ) + return ( + <tr id={link.slug} key={link.slug} className={className}> + <td className="py-4 pl-5" onClick={onToggle}> + {expanded ? ( + <ChevronUpIcon className="h-5 w-5" /> + ) : ( + <ChevronDownIcon className="h-5 w-5" /> + )} + </td> + + <td className="px-5 py-4 font-medium text-gray-900"> + {formatMoney(link.amount)} + </td> + <td className="px-5 py-4">{getLinkUrl(link.slug)}</td> + <td className="px-5 py-4">{link.claimedUserIds.length}</td> + <td className="px-5 py-4">{link.maxUses == null ? '∞' : link.maxUses}</td> + <td className="px-5 py-4"> + {link.expiresTime == null ? 'Never' : fromNow(link.expiresTime)} + </td> + </tr> + ) +} + +function LinksTable(props: { links: Manalink[]; highlightedSlug?: string }) { + const { links, highlightedSlug } = props + return links.length == 0 ? ( + <p>You don't currently have any outstanding manalinks.</p> + ) : ( + <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> + <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> + <tr> + <th></th> + <th className="px-5 py-3.5">Amount</th> + <th className="px-5 py-3.5">Link</th> + <th className="px-5 py-3.5">Uses</th> + <th className="px-5 py-3.5">Max Uses</th> + <th className="px-5 py-3.5">Expires</th> + </tr> + </thead> + <tbody className="divide-y divide-gray-200 bg-white"> + {links.map((link) => ( + <LinkTableRow link={link} highlight={link.slug === highlightedSlug} /> + ))} + </tbody> + </table> + ) +} diff --git a/yarn.lock b/yarn.lock index 3b65ca2e..83742947 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8026,6 +8026,11 @@ nanoid@^3.1.23, nanoid@^3.1.30, nanoid@^3.3.4: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== +nanoid@^3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" + integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" From 4a1a907b370d017631324f52b8b6cb9d61bd0751 Mon Sep 17 00:00:00 2001 From: Ben Congdon <ben@congdon.dev> Date: Thu, 23 Jun 2022 08:51:41 -0700 Subject: [PATCH 004/220] Fix API docs formatting (#568) --- docs/docs/api.md | 338 ++++++++++++++++++++++++----------------------- 1 file changed, 170 insertions(+), 168 deletions(-) diff --git a/docs/docs/api.md b/docs/docs/api.md index a25d245c..b7420b52 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -106,7 +106,7 @@ Requires no authorization. // A list of tags on each market. Any user can add tags to any market. // This list also includes the predefined categories shown as filters on the home page. tags: string[] - + // Note: This url always points to https://manifold.markets, regardless of what instance the api is running on. // This url includes the creator's username, but this doesn't need to be correct when constructing valid URLs. // i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market @@ -127,7 +127,7 @@ Requires no authorization. isResolved: boolean resolutionTime?: number resolution?: string - resolutionProbability?: number // Used for BINARY markets resolved to MKT + resolutionProbability?: number // Used for BINARY markets resolved to MKT } ``` @@ -147,202 +147,204 @@ Requires no authorization. ```json { - "id":"lEoqtnDgJzft6apSKzYK", - "creatorUsername":"Angela", - "creatorName":"Angela", - "createdTime":1655258914863, - "creatorAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", - "closeTime":1655265001448, - "question":"What is good?", - "description":"Resolves proportionally to the answer(s) which I find most compelling. (Obviously I’ll refrain from giving my own answers)\n\n(Please have at it with philosophy, ethics, etc etc)\n\n\nContract resolved automatically.", - "tags":[], - "url":"https://manifold.markets/Angela/what-is-good", - "pool":null, - "outcomeType":"FREE_RESPONSE", - "mechanism":"dpm-2", - "volume":112, - "volume7Days":212, - "volume24Hours":0, - "isResolved":true, - "resolution":"MKT", - "resolutionTime":1655265001448, - "answers":[ + "id": "lEoqtnDgJzft6apSKzYK", + "creatorUsername": "Angela", + "creatorName": "Angela", + "createdTime": 1655258914863, + "creatorAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", + "closeTime": 1655265001448, + "question": "What is good?", + "description": "Resolves proportionally to the answer(s) which I find most compelling. (Obviously I’ll refrain from giving my own answers)\n\n(Please have at it with philosophy, ethics, etc etc)\n\n\nContract resolved automatically.", + "tags": [], + "url": "https://manifold.markets/Angela/what-is-good", + "pool": null, + "outcomeType": "FREE_RESPONSE", + "mechanism": "dpm-2", + "volume": 112, + "volume7Days": 212, + "volume24Hours": 0, + "isResolved": true, + "resolution": "MKT", + "resolutionTime": 1655265001448, + "answers": [ { - "createdTime":1655258941573, - "avatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", - "id":"1", - "username":"Angela", - "number":1, - "name":"Angela", - "contractId":"lEoqtnDgJzft6apSKzYK", - "text":"ANTE", - "userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2", - "probability":0.66749733001068 + "createdTime": 1655258941573, + "avatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", + "id": "1", + "username": "Angela", + "number": 1, + "name": "Angela", + "contractId": "lEoqtnDgJzft6apSKzYK", + "text": "ANTE", + "userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2", + "probability": 0.66749733001068 }, { - "name":"Isaac King", - "username":"IsaacKing", - "text":"This answer", - "userId":"y1hb6k7txdZPV5mgyxPFApZ7nQl2", - "id":"2", - "number":2, - "avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GhNVriOvxK2VUAmE-jvYZwC-XIymatzVirT0Bqb2g=s96-c", - "contractId":"lEoqtnDgJzft6apSKzYK", - "createdTime":1655261198074, - "probability":0.008922214311142757 + "name": "Isaac King", + "username": "IsaacKing", + "text": "This answer", + "userId": "y1hb6k7txdZPV5mgyxPFApZ7nQl2", + "id": "2", + "number": 2, + "avatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GhNVriOvxK2VUAmE-jvYZwC-XIymatzVirT0Bqb2g=s96-c", + "contractId": "lEoqtnDgJzft6apSKzYK", + "createdTime": 1655261198074, + "probability": 0.008922214311142757 }, { - "createdTime":1655263226587, - "userId":"jbgplxty4kUKIa1MmgZk22byJq03", - "id":"3", - "avatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FMartin%2Fgiphy.gif?alt=media&token=422ef610-553f-47e3-bf6f-c0c5cc16c70a", - "text":"Toyota Camry", - "contractId":"lEoqtnDgJzft6apSKzYK", - "name":"Undox", - "username":"Undox", - "number":3, - "probability":0.008966714133143469 + "createdTime": 1655263226587, + "userId": "jbgplxty4kUKIa1MmgZk22byJq03", + "id": "3", + "avatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FMartin%2Fgiphy.gif?alt=media&token=422ef610-553f-47e3-bf6f-c0c5cc16c70a", + "text": "Toyota Camry", + "contractId": "lEoqtnDgJzft6apSKzYK", + "name": "Undox", + "username": "Undox", + "number": 3, + "probability": 0.008966714133143469 }, { - "number":4, - "name":"James Grugett", - "userId":"5LZ4LgYuySdL1huCWe7bti02ghx2", - "text":"Utility (Defined by your personal utility function.)", - "createdTime":1655264793224, - "contractId":"lEoqtnDgJzft6apSKzYK", - "username":"JamesGrugett", - "id":"4", - "avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c", - "probability":0.09211463154147384 + "number": 4, + "name": "James Grugett", + "userId": "5LZ4LgYuySdL1huCWe7bti02ghx2", + "text": "Utility (Defined by your personal utility function.)", + "createdTime": 1655264793224, + "contractId": "lEoqtnDgJzft6apSKzYK", + "username": "JamesGrugett", + "id": "4", + "avatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c", + "probability": 0.09211463154147384 } ], - "comments":[ + "comments": [ { - "id":"ZdHIyfQazHyl8nI0ENS7", - "userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2", - "createdTime":1655265807433, - "text":"ok what\ni did not resolve this intentionally", - "contractId":"lEoqtnDgJzft6apSKzYK", - "userName":"Angela", - "userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", - "userUsername":"Angela" + "id": "ZdHIyfQazHyl8nI0ENS7", + "userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2", + "createdTime": 1655265807433, + "text": "ok what\ni did not resolve this intentionally", + "contractId": "lEoqtnDgJzft6apSKzYK", + "userName": "Angela", + "userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", + "userUsername": "Angela" }, { - "userName":"James Grugett", - "userUsername":"JamesGrugett", - "id":"F7fvHGhTiFal8uTsUc9P", - "userAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c","replyToCommentId":"ZdHIyfQazHyl8nI0ENS7", - "text":"@Angela Sorry! There was an error that automatically resolved several markets that were created in the last few hours.", - "createdTime":1655266286514, - "userId":"5LZ4LgYuySdL1huCWe7bti02ghx2", - "contractId":"lEoqtnDgJzft6apSKzYK" + "userName": "James Grugett", + "userUsername": "JamesGrugett", + "id": "F7fvHGhTiFal8uTsUc9P", + "userAvatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c", + "replyToCommentId": "ZdHIyfQazHyl8nI0ENS7", + "text": "@Angela Sorry! There was an error that automatically resolved several markets that were created in the last few hours.", + "createdTime": 1655266286514, + "userId": "5LZ4LgYuySdL1huCWe7bti02ghx2", + "contractId": "lEoqtnDgJzft6apSKzYK" }, { - "userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2", - "contractId":"lEoqtnDgJzft6apSKzYK", - "id":"PIHhXy5hLHSgW8uoUD0Q", - "userName":"Angela", - "text":"lmk if anyone lost manna from this situation and i'll try to fix it", - "userUsername":"Angela", - "createdTime":1655277581308, - "userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476" - },{ - "userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", - "userName":"Angela", - "text":"from my end it looks like no one did", - "replyToCommentId":"PIHhXy5hLHSgW8uoUD0Q", - "createdTime":1655287149528, - "userUsername":"Angela", - "id":"5slnWEQWwm6dHjDi6oiH", - "contractId":"lEoqtnDgJzft6apSKzYK", - "userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2" + "userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2", + "contractId": "lEoqtnDgJzft6apSKzYK", + "id": "PIHhXy5hLHSgW8uoUD0Q", + "userName": "Angela", + "text": "lmk if anyone lost manna from this situation and i'll try to fix it", + "userUsername": "Angela", + "createdTime": 1655277581308, + "userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476" + }, + { + "userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", + "userName": "Angela", + "text": "from my end it looks like no one did", + "replyToCommentId": "PIHhXy5hLHSgW8uoUD0Q", + "createdTime": 1655287149528, + "userUsername": "Angela", + "id": "5slnWEQWwm6dHjDi6oiH", + "contractId": "lEoqtnDgJzft6apSKzYK", + "userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2" } ], - "bets":[ + "bets": [ { - "outcome":"0", - "contractId":"lEoqtnDgJzft6apSKzYK", - "fees":{ - "liquidityFee":0, - "creatorFee":0, - "platformFee":0 + "outcome": "0", + "contractId": "lEoqtnDgJzft6apSKzYK", + "fees": { + "liquidityFee": 0, + "creatorFee": 0, + "platformFee": 0 }, - "isAnte":true, - "shares":100, - "probAfter":1, - "amount":100, - "userId":"IPTOzEqrpkWmEzh6hwvAyY9PqFb2", - "createdTime":1655258914863, - "probBefore":0, - "id":"2jNZqnwoEQL7WDTTAWDP" + "isAnte": true, + "shares": 100, + "probAfter": 1, + "amount": 100, + "userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2", + "createdTime": 1655258914863, + "probBefore": 0, + "id": "2jNZqnwoEQL7WDTTAWDP" }, { - "shares":173.20508075688772, - "fees":{ - "platformFee":0, - "liquidityFee":0, - "creatorFee":0 + "shares": 173.20508075688772, + "fees": { + "platformFee": 0, + "liquidityFee": 0, + "creatorFee": 0 }, - "contractId":"lEoqtnDgJzft6apSKzYK", - "probBefore":0, - "createdTime":1655258941573, - "loanAmount":0, - "userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2", - "amount":100, - "outcome":"1", - "probAfter":0.75, - "id":"xuc3JoiNkE8lXPh15mUb" + "contractId": "lEoqtnDgJzft6apSKzYK", + "probBefore": 0, + "createdTime": 1655258941573, + "loanAmount": 0, + "userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2", + "amount": 100, + "outcome": "1", + "probAfter": 0.75, + "id": "xuc3JoiNkE8lXPh15mUb" }, { - "userId":"y1hb6k7txdZPV5mgyxPFApZ7nQl2", - "contractId":"lEoqtnDgJzft6apSKzYK", - "loanAmount":0, - "probAfter":0.009925496893641248, - "id":"8TBlzPtOdO0q5BgSyRbi", - "createdTime":1655261198074, - "shares":20.024984394500787, - "amount":1, - "outcome":"2", - "probBefore":0, - "fees":{ - "liquidityFee":0, - "creatorFee":0, - "platformFee":0 + "userId": "y1hb6k7txdZPV5mgyxPFApZ7nQl2", + "contractId": "lEoqtnDgJzft6apSKzYK", + "loanAmount": 0, + "probAfter": 0.009925496893641248, + "id": "8TBlzPtOdO0q5BgSyRbi", + "createdTime": 1655261198074, + "shares": 20.024984394500787, + "amount": 1, + "outcome": "2", + "probBefore": 0, + "fees": { + "liquidityFee": 0, + "creatorFee": 0, + "platformFee": 0 } }, { - "probAfter":0.00987648269777473, - "outcome":"3", - "id":"9vdwes6s9QxbYZUBhHs4", - "createdTime":1655263226587, - "shares":20.074859899884732, - "amount":1, - "loanAmount":0, - "fees":{ - "liquidityFee":0, - "platformFee":0, - "creatorFee":0 + "probAfter": 0.00987648269777473, + "outcome": "3", + "id": "9vdwes6s9QxbYZUBhHs4", + "createdTime": 1655263226587, + "shares": 20.074859899884732, + "amount": 1, + "loanAmount": 0, + "fees": { + "liquidityFee": 0, + "platformFee": 0, + "creatorFee": 0 }, - "userId":"jbgplxty4kUKIa1MmgZk22byJq03", - "contractId":"lEoqtnDgJzft6apSKzYK", - "probBefore":0 + "userId": "jbgplxty4kUKIa1MmgZk22byJq03", + "contractId": "lEoqtnDgJzft6apSKzYK", + "probBefore": 0 }, { - "createdTime":1655264793224, - "fees":{ - "creatorFee":0, - "liquidityFee":0, - "platformFee":0 + "createdTime": 1655264793224, + "fees": { + "creatorFee": 0, + "liquidityFee": 0, + "platformFee": 0 }, - "probAfter":0.09211463154147384, - "amount":10, - "id":"BehiSGgk1wAkIWz1a8L4", - "userId":"5LZ4LgYuySdL1huCWe7bti02ghx2", - "contractId":"lEoqtnDgJzft6apSKzYK", - "loanAmount":0, - "probBefore":0, - "outcome":"4", - "shares":64.34283176858165 + "probAfter": 0.09211463154147384, + "amount": 10, + "id": "BehiSGgk1wAkIWz1a8L4", + "userId": "5LZ4LgYuySdL1huCWe7bti02ghx2", + "contractId": "lEoqtnDgJzft6apSKzYK", + "loanAmount": 0, + "probBefore": 0, + "outcome": "4", + "shares": 64.34283176858165 } ] } From cdd8af241b11b7a104d987127497778cbd475ed8 Mon Sep 17 00:00:00 2001 From: Ben Congdon <ben@congdon.dev> Date: Thu, 23 Jun 2022 10:12:57 -0700 Subject: [PATCH 005/220] Show resolution time in market cards when appropriate (#565) * Show resolution time in market cards when appropriate * Rebase and fix contract-search-firestore --- web/components/contract-search.tsx | 8 ++++- web/components/contract/contract-card.tsx | 9 +++--- web/components/contract/contract-details.tsx | 31 +++++++++++++++----- web/components/contract/contracts-list.tsx | 7 +++-- web/pages/contract-search-firestore.tsx | 9 ++++-- 5 files changed, 45 insertions(+), 19 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 2f22918a..4c557648 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -238,12 +238,18 @@ export function ContractSearchInner(props: { if (isInitialLoad && contracts.length === 0) return <></> + const showTime = index.endsWith('close-date') + ? 'close-date' + : index.endsWith('resolve-date') + ? 'resolve-date' + : undefined + return ( <ContractsGrid contracts={contracts} loadMore={showMore} hasMore={!isLastPage} - showCloseTime={index.endsWith('close-date')} + showTime={showTime} onContractClick={onContractClick} /> ) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index bac24586..87239465 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -17,7 +17,7 @@ import { FreeResponseOutcomeLabel, } from '../outcome-label' import { getOutcomeProbability, getTopAnswer } from 'common/calculate' -import { AvatarDetails, MiscDetails } from './contract-details' +import { AvatarDetails, MiscDetails, ShowTime } from './contract-details' import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' import { QuickBet, ProbBar, getColor } from './quick-bet' import { useContractWithPreload } from 'web/hooks/use-contract' @@ -28,13 +28,12 @@ import { trackCallback } from 'web/lib/service/analytics' export function ContractCard(props: { contract: Contract showHotVolume?: boolean - showCloseTime?: boolean + showTime?: ShowTime className?: string onClick?: () => void hideQuickBet?: boolean }) { - const { showHotVolume, showCloseTime, className, onClick, hideQuickBet } = - props + const { showHotVolume, showTime, className, onClick, hideQuickBet } = props const contract = useContractWithPreload(props.contract) ?? props.contract const { question, outcomeType } = contract const { resolution } = contract @@ -118,7 +117,7 @@ export function ContractCard(props: { <MiscDetails contract={contract} showHotVolume={showHotVolume} - showCloseTime={showCloseTime} + showTime={showTime} /> </Col> {showQuickBet ? ( diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 863d8c27..03925a35 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -30,14 +30,23 @@ import { SiteLink } from 'web/components/site-link' import { DAY_MS } from 'common/util/time' import { useGroupsWithContract } from 'web/hooks/use-group' +export type ShowTime = 'resolve-date' | 'close-date' + export function MiscDetails(props: { contract: Contract showHotVolume?: boolean - showCloseTime?: boolean + showTime?: ShowTime }) { - const { contract, showHotVolume, showCloseTime } = props - const { volume, volume24Hours, closeTime, tags, isResolved, createdTime } = - contract + const { contract, showHotVolume, showTime } = props + const { + volume, + volume24Hours, + closeTime, + tags, + isResolved, + createdTime, + resolutionTime, + } = contract // Show at most one category that this contract is tagged by const categories = CATEGORY_LIST.filter((category) => tags.map((t) => t.toLowerCase()).includes(category) @@ -50,12 +59,18 @@ export function MiscDetails(props: { <Row className="gap-0.5"> <TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)} </Row> - ) : showCloseTime ? ( + ) : showTime === 'close-date' ? ( <Row className="gap-0.5"> <ClockIcon className="h-5 w-5" /> {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '} {fromNow(closeTime || 0)} </Row> + ) : showTime === 'resolve-date' && resolutionTime !== undefined ? ( + <Row className="gap-0.5"> + <ClockIcon className="h-5 w-5" /> + {'Resolved '} + {fromNow(resolutionTime || 0)} + </Row> ) : volume > 0 || !isNew ? ( <Row>{contractPool(contract)} pool</Row> ) : ( @@ -88,9 +103,9 @@ export function AvatarDetails(props: { contract: Contract }) { export function AbbrContractDetails(props: { contract: Contract showHotVolume?: boolean - showCloseTime?: boolean + showTime?: ShowTime }) { - const { contract, showHotVolume, showCloseTime } = props + const { contract, showHotVolume, showTime } = props return ( <Row className="items-center justify-between"> <AvatarDetails contract={contract} /> @@ -98,7 +113,7 @@ export function AbbrContractDetails(props: { <MiscDetails contract={contract} showHotVolume={showHotVolume} - showCloseTime={showCloseTime} + showTime={showTime} /> </Row> ) diff --git a/web/components/contract/contracts-list.tsx b/web/components/contract/contracts-list.tsx index bc8dbe76..e24090d9 100644 --- a/web/components/contract/contracts-list.tsx +++ b/web/components/contract/contracts-list.tsx @@ -3,6 +3,7 @@ import { User } from '../../lib/firebase/users' import { Col } from '../layout/col' import { SiteLink } from '../site-link' import { ContractCard } from './contract-card' +import { ShowTime } from './contract-details' import { ContractSearch } from '../contract-search' import { useIsVisible } from 'web/hooks/use-is-visible' import { useEffect, useState } from 'react' @@ -12,14 +13,14 @@ export function ContractsGrid(props: { contracts: Contract[] loadMore: () => void hasMore: boolean - showCloseTime?: boolean + showTime?: ShowTime onContractClick?: (contract: Contract) => void overrideGridClassName?: string hideQuickBet?: boolean }) { const { contracts, - showCloseTime, + showTime, hasMore, loadMore, onContractClick, @@ -60,7 +61,7 @@ export function ContractsGrid(props: { <ContractCard contract={contract} key={contract.id} - showCloseTime={showCloseTime} + showTime={showTime} onClick={ onContractClick ? () => onContractClick(contract) : undefined } diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 98a0f6cb..00d8fe49 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -62,6 +62,12 @@ export default function ContractSearchFirestore(props: { matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) } + const showTime = ['close-date', 'closed'].includes(sort) + ? 'close-date' + : sort === 'resolve-date' + ? 'resolve-date' + : undefined + return ( <div> {/* Show a search input next to a sort dropdown */} @@ -85,7 +91,6 @@ export default function ContractSearchFirestore(props: { <option value="close-date">Closing soon</option> </select> </div> - {contracts === undefined ? ( <LoadingIndicator /> ) : ( @@ -93,7 +98,7 @@ export default function ContractSearchFirestore(props: { contracts={matches} loadMore={() => {}} hasMore={false} - showCloseTime={['close-date', 'closed'].includes(sort)} + showTime={showTime} /> )} </div> From 28c8cc6863ec6e6ff248134bcf47e5acc473ba5a Mon Sep 17 00:00:00 2001 From: ahalekelly <ahalekelly@gmail.com> Date: Thu, 23 Jun 2022 10:13:13 -0700 Subject: [PATCH 006/220] Fix line off the right of closed markets (#569) --- web/components/contract/contract-prob-graph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index ed75d94a..7386d748 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -56,7 +56,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints const points: { x: Date; y: number }[] = [] - for (let i = 0; i < times.length; i++) { + for (let i = 0; i < times.length - 1; i++) { points[points.length] = { x: times[i], y: probs[i] * 100 } const numPoints: number = Math.floor( dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep From b569f67fc1a92b11e461682f5bf739de6f64692a Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 23 Jun 2022 12:36:09 -0500 Subject: [PATCH 007/220] Group discussion ux improvements --- web/components/groups/discussion.tsx | 93 +++++++++++++++------------- 1 file changed, 50 insertions(+), 43 deletions(-) diff --git a/web/components/groups/discussion.tsx b/web/components/groups/discussion.tsx index 69bb1fde..a4a83745 100644 --- a/web/components/groups/discussion.tsx +++ b/web/components/groups/discussion.tsx @@ -33,6 +33,7 @@ export function Discussion(props: { const [scrollToMessageRef, setScrollToMessageRef] = useState<HTMLDivElement | null>(null) const [replyToUsername, setReplyToUsername] = useState('') + const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) const router = useRouter() useEffect(() => { @@ -40,8 +41,9 @@ export function Discussion(props: { }, [scrollToMessageRef]) useEffect(() => { - scrollToBottomRef?.scrollIntoView() - }, [isSubmitting, scrollToBottomRef]) + if (!isSubmitting) + scrollToBottomRef?.scrollTo({ top: scrollToBottomRef?.scrollHeight || 0 }) + }, [scrollToBottomRef, isSubmitting]) useEffect(() => { const elementInUrl = router.asPath.split('#')[1] @@ -65,6 +67,7 @@ export function Discussion(props: { setMessageText('') setIsSubmitting(false) setReplyToUsername('') + inputRef?.focus() } return ( @@ -73,8 +76,9 @@ export function Discussion(props: { className={ 'max-h-[65vh] w-full space-y-2 overflow-x-hidden overflow-y-scroll' } + ref={setScrollToBottomRef} > - {messages.map((message, i) => ( + {messages.map((message) => ( <GroupMessage user={user} key={message.id} @@ -85,8 +89,6 @@ export function Discussion(props: { setRef={ scrollToMessageId === message.id ? setScrollToMessageRef - : i === messages.length - 1 - ? setScrollToBottomRef : undefined } /> @@ -116,6 +118,7 @@ export function Discussion(props: { submitComment={submitMessage} isSubmitting={isSubmitting} enterToSubmit={true} + setRef={setInputRef} /> </div> </div> @@ -128,58 +131,62 @@ const GroupMessage = memo(function GroupMessage_(props: { user: User | null | undefined comment: Comment group: Group - truncate?: boolean - smallAvatar?: boolean onReplyClick?: (comment: Comment) => void setRef?: (ref: HTMLDivElement) => void highlight?: boolean }) { - const { comment, truncate, onReplyClick, group, setRef, highlight, user } = - props + const { comment, onReplyClick, group, setRef, highlight, user } = props const { text, userUsername, userName, userAvatarUrl, createdTime } = comment + const isCreatorsComment = user && comment.userId === user.id return ( - <Row + <Col ref={setRef} className={clsx( - comment.userId === user?.id ? 'mr-2 self-end' : ' ml-2', - 'w-fit space-x-1.5 rounded-md bg-white p-2 px-4 transition-all duration-1000 sm:space-x-3', + isCreatorsComment ? 'mr-2 self-end' : ' ml-2', + 'w-fit max-w-md gap-1 space-x-3 rounded-md bg-white p-2 p-2 px-4 text-sm text-gray-500 transition-all duration-1000', highlight ? `-m-1 bg-indigo-500/[0.2] p-2` : '' )} > - <Avatar - className={'ml-1'} - size={'sm'} - username={userUsername} - avatarUrl={userAvatarUrl} - /> - <div className="w-full"> - <div className="mt-0.5 pl-0.5 text-sm text-gray-500"> - <UserLink - className="text-gray-500" - username={userUsername} - name={userName} - />{' '} - <CopyLinkDateTimeComponent - prefix={'group'} - slug={group.slug} - createdTime={createdTime} - elementId={comment.id} - /> - </div> + <Row className={'items-center'}> + {!isCreatorsComment && ( + <Col> + <Avatar + className={'mx-2 ml-0'} + size={'sm'} + username={userUsername} + avatarUrl={userAvatarUrl} + /> + </Col> + )} + {!isCreatorsComment ? ( + <UserLink username={userUsername} name={userName} /> + ) : ( + <span>{'You'}</span> + )} + <CopyLinkDateTimeComponent + prefix={'group'} + slug={group.slug} + createdTime={createdTime} + elementId={comment.id} + /> + </Row> + <Row className={'text-black'}> <TruncatedComment comment={text} moreHref={groupPath(group.slug)} - shouldTruncate={truncate} + shouldTruncate={false} /> - {onReplyClick && ( - <button - className={'text-xs font-bold text-gray-500 hover:underline'} - onClick={() => onReplyClick(comment)} - > - Reply - </button> - )} - </div> - </Row> + </Row> + {!isCreatorsComment && onReplyClick && ( + <button + className={ + 'self-start py-1 text-xs font-bold text-gray-500 hover:underline' + } + onClick={() => onReplyClick(comment)} + > + Reply + </button> + )} + </Col> ) }) From 75907f6c18d1cfc4136235236cff92a04dbcc4a8 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 23 Jun 2022 12:40:32 -0500 Subject: [PATCH 008/220] Join group button --- web/pages/group/[...slugs]/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 9ca1e1fa..4899f57e 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -14,7 +14,7 @@ import { } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' -import { getUser, User } from 'web/lib/firebase/users' +import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Spacer } from 'web/components/layout/spacer' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' @@ -541,8 +541,11 @@ function JoinGroupButton(props: { } return ( <div> - <button onClick={joinGroup} className={'btn-md btn-outline btn '}> - Join Group + <button + onClick={user ? joinGroup : firebaseLogin} + className={'btn-md btn-outline btn whitespace-nowrap normal-case'} + > + {user ? 'Join group' : 'Login to join group'} </button> </div> ) From 211905c27f644db5a7b4d48be429d59f388f77c5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 23 Jun 2022 13:00:14 -0500 Subject: [PATCH 009/220] Free daily markets on hiatus --- functions/src/create-contract.ts | 22 ++---------- web/components/nav/sidebar.tsx | 41 ---------------------- web/pages/create.tsx | 60 ++++++++++---------------------- 3 files changed, 22 insertions(+), 101 deletions(-) diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index ec81ba6e..71d778b3 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -22,7 +22,6 @@ import { getCpmmInitialLiquidity, getFreeAnswerAnte, getNumericAnte, - HOUSE_LIQUIDITY_PROVIDER_ID, } from '../../common/antes' import { getNoneAnswer } from '../../common/answer' import { getNewContract } from '../../common/new-contract' @@ -64,31 +63,16 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => { ;({ initialProb } = validate(binarySchema, req.body)) } - // Uses utc time on server: - const today = new Date() - let freeMarketResetTime = new Date().setUTCHours(16, 0, 0, 0) - if (today.getTime() < freeMarketResetTime) { - freeMarketResetTime = freeMarketResetTime - 24 * 60 * 60 * 1000 - } - 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 userContractsCreatedTodaySnapshot = await firestore - .collection(`contracts`) - .where('creatorId', '==', auth.uid) - .where('createdTime', '>=', freeMarketResetTime) - .get() - console.log('free market reset time: ', freeMarketResetTime) - const isFree = userContractsCreatedTodaySnapshot.size === 0 - const ante = FIXED_ANTE // TODO: this is broken because it's not in a transaction - if (ante > user.balance && !isFree) + if (ante > user.balance) throw new APIError(400, `Balance must be at least ${ante}.`) const slug = await getSlug(question) @@ -140,11 +124,11 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => { max ?? 0 ) - if (!isFree && ante) await chargeUser(user.id, ante, true) + if (ante) await chargeUser(user.id, ante, true) await contractRef.create(contract) - const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : user.id + const providerId = user.id if (outcomeType === 'BINARY') { const liquidityDoc = firestore diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 5716e5cd..0f8d59eb 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -181,28 +181,8 @@ export default function Sidebar(props: { className?: string }) { const { className } = props const router = useRouter() const currentPage = router.pathname - const [countdown, setCountdown] = useState('...') - useEffect(() => { - const nextUtcResetTime = getUtcFreeMarketResetTime({ previousTime: false }) - const interval = setInterval(() => { - const now = new Date().getTime() - const timeUntil = nextUtcResetTime - now - const hoursUntil = timeUntil / 1000 / 60 / 60 - const minutesUntil = (hoursUntil * 60) % 60 - const secondsUntil = Math.round((hoursUntil * 60 * 60) % 60) - const timeString = - hoursUntil < 1 && minutesUntil < 1 - ? `${secondsUntil}s` - : hoursUntil < 1 - ? `${Math.round(minutesUntil)}m` - : `${Math.floor(hoursUntil)}h` - setCountdown(timeString) - }, 1000) - return () => clearInterval(interval) - }, []) const user = useUser() - const mustWaitForFreeMarketStatus = useHasCreatedContractToday(user) const navigationOptions = !user ? signedOutNavigation : getNavigation(user?.username || 'error') @@ -306,27 +286,6 @@ export default function Sidebar(props: { className?: string }) { /> </div> <CreateQuestionButton user={user} /> - - {user && - mustWaitForFreeMarketStatus != 'loading' && - mustWaitForFreeMarketStatus ? ( - <Row className="mt-2 justify-center"> - <Row className="gap-1 text-sm text-gray-400"> - Next free question in {countdown} - </Row> - </Row> - ) : ( - user && - mustWaitForFreeMarketStatus != 'loading' && - !mustWaitForFreeMarketStatus && ( - <Row className="mt-2 justify-center"> - <Row className="gap-1 text-sm text-indigo-400"> - Daily free question - <SparklesIcon className="mt-0.5 h-4 w-4" aria-hidden="true" /> - </Row> - </Row> - ) - )} </nav> ) } diff --git a/web/pages/create.tsx b/web/pages/create.tsx index c032b2d0..53b73c7e 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -93,11 +93,6 @@ export function NewContract(props: { question: string; groupId?: string }) { }, [creator, groupId]) const [ante, _setAnte] = useState(FIXED_ANTE) - const mustWaitForDailyFreeMarketStatus = useHasCreatedContractToday(creator) - const isFree = - mustWaitForDailyFreeMarketStatus != 'loading' && - !mustWaitForDailyFreeMarketStatus - // useEffect(() => { // if (ante === null && creator) { // const initialAnte = creator.balance < 100 ? MINIMUM_ANTE : 100 @@ -138,9 +133,7 @@ export function NewContract(props: { question: string; groupId?: string }) { ante !== undefined && ante !== null && ante >= MINIMUM_ANTE && - (ante <= balance || - (mustWaitForDailyFreeMarketStatus != 'loading' && - !mustWaitForDailyFreeMarketStatus)) && + ante <= balance && // closeTime must be in the future closeTime && closeTime > Date.now() && @@ -181,7 +174,7 @@ export function NewContract(props: { question: string; groupId?: string }) { slug: result.slug, initialProb, selectedGroup: selectedGroup?.id, - isFree, + isFree: false, }) if (result && selectedGroup) { await updateGroup(selectedGroup, { @@ -369,41 +362,26 @@ export function NewContract(props: { question: string; groupId?: string }) { <div className="form-control mb-1 items-start"> <label className="label mb-1 gap-2"> <span>Cost</span> - {mustWaitForDailyFreeMarketStatus != 'loading' && - mustWaitForDailyFreeMarketStatus && ( - <InfoTooltip - text={`Cost to create your question. This amount is used to subsidize betting.`} - /> - )} + <InfoTooltip + text={`Cost to create your question. This amount is used to subsidize betting.`} + /> </label> - {mustWaitForDailyFreeMarketStatus != 'loading' && - !mustWaitForDailyFreeMarketStatus ? ( - <div className="label-text text-primary pl-1"> - <span className={'label-text text-neutral line-through '}> - {formatMoney(ante)} - </span>{' '} - FREE + + <div className="label-text text-neutral pl-1"> + {formatMoney(ante)} + </div> + + {ante > balance && ( + <div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide"> + <span className="mr-2 text-red-500">Insufficient balance</span> + <button + className="btn btn-xs btn-primary" + onClick={() => (window.location.href = '/add-funds')} + > + Get M$ + </button> </div> - ) : ( - mustWaitForDailyFreeMarketStatus != 'loading' && ( - <div className="label-text text-neutral pl-1"> - {formatMoney(ante)} - </div> - ) )} - {mustWaitForDailyFreeMarketStatus != 'loading' && - mustWaitForDailyFreeMarketStatus && - ante > balance && ( - <div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide"> - <span className="mr-2 text-red-500">Insufficient balance</span> - <button - className="btn btn-xs btn-primary" - onClick={() => (window.location.href = '/add-funds')} - > - Get M$ - </button> - </div> - )} </div> <button From 9757ed1d8bf7da1ad0edac34d940815a3e20a5ef Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 23 Jun 2022 13:02:52 -0500 Subject: [PATCH 010/220] lint --- web/components/nav/sidebar.tsx | 8 +------- web/pages/create.tsx | 1 - 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 0f8d59eb..48cdd0ca 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -6,7 +6,6 @@ import { CashIcon, HeartIcon, PresentationChartLineIcon, - SparklesIcon, UserGroupIcon, ChevronDownIcon, TrendingUpIcon, @@ -19,13 +18,8 @@ import { firebaseLogout, User } from 'web/lib/firebase/users' import { ManifoldLogo } from './manifold-logo' import { MenuButton } from './menu' import { ProfileSummary } from './profile-menu' -import { - getUtcFreeMarketResetTime, - useHasCreatedContractToday, -} from 'web/hooks/use-has-created-contract-today' -import { Row } from '../layout/row' import NotificationsIcon from 'web/components/notifications-icon' -import React, { useEffect, useState } from 'react' +import React from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { CreateQuestionButton } from 'web/components/create-question-button' import { useMemberGroups } from 'web/hooks/use-group' diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 53b73c7e..bfca6f5f 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -17,7 +17,6 @@ import { outcomeType, } from 'common/contract' import { formatMoney } from 'common/util/format' -import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-today' import { removeUndefinedProps } from 'common/util/object' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { getGroup, updateGroup } from 'web/lib/firebase/groups' From 00c2012ccf74cc7464612abd94c27c6f872ed3e1 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 23 Jun 2022 12:55:41 -0500 Subject: [PATCH 011/220] Refactor empty avatar component --- web/components/avatar.tsx | 16 +++++++++++++++- web/components/feed/feed-bets.tsx | 6 ++---- web/pages/notifications.tsx | 6 ++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index 5604e5aa..e6506c03 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -1,7 +1,7 @@ import Router from 'next/router' import clsx from 'clsx' import { MouseEvent } from 'react' -import { UserCircleIcon } from '@heroicons/react/solid' +import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid' export function Avatar(props: { username?: string @@ -45,3 +45,17 @@ export function Avatar(props: { /> ) } + +export function EmptyAvatar(props: { size?: number; multi?: boolean }) { + const { size = 8, multi } = props + const insize = size - 3 + const Icon = multi ? UsersIcon : UserIcon + + return ( + <div + className={`flex h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`} + > + <Icon className={`h-${insize} w-${insize} text-gray-500`} aria-hidden /> + </div> + ) +} diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index cae9bef6..06460a91 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -4,7 +4,7 @@ import { Bet } from 'common/bet' import { User } from 'common/user' import { useUser, useUserById } from 'web/hooks/use-user' import { Row } from 'web/components/layout/row' -import { Avatar } from 'web/components/avatar' +import { Avatar, EmptyAvatar } from 'web/components/avatar' import clsx from 'clsx' import { UserIcon, UsersIcon } from '@heroicons/react/solid' import { formatMoney } from 'common/util/format' @@ -50,9 +50,7 @@ export function FeedBet(props: { /> ) : ( <div className="relative px-1"> - <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200"> - <UserIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> - </div> + <EmptyAvatar /> </div> )} <div className={'min-w-0 flex-1 py-1.5'}> diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 0b04c7b0..070caa3e 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -7,7 +7,7 @@ import { notification_source_types, notification_source_update_types, } from 'common/notification' -import { Avatar } from 'web/components/avatar' +import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { Title } from 'web/components/title' @@ -235,9 +235,7 @@ function NotificationGroupItem(props: { /> )} <Row className={'items-center text-gray-500 sm:justify-start'}> - <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200"> - <UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> - </div> + <EmptyAvatar multi /> <div className={'flex-1 overflow-hidden pl-2 sm:flex'}> <div onClick={() => setExpanded(!expanded)} From 970800bd316d011b9545b9066f93fb0dd6f2bdf4 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 23 Jun 2022 14:25:55 -0500 Subject: [PATCH 012/220] add tailwind to recommended vscode extensions --- .gitignore | 2 ++ .vscode/extensions.json | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 10f5d982..b59de328 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ .vercel node_modules yarn-error.log + +firebase-debug.log \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index dd2fdceb..0aaf34ac 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,7 +6,8 @@ "recommendations": [ "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", - "toba.vsfire" + "toba.vsfire", + "bradlc.vscode-tailwindcss" ], // List of extensions recommended by VS Code that should not be recommended for users of this workspace. "unwantedRecommendations": [] From 8cc0cf160a3b31d7070b9b8dbd8de45490ab13f6 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 23 Jun 2022 14:41:42 -0500 Subject: [PATCH 013/220] lint --- web/components/feed/feed-bets.tsx | 2 +- web/pages/notifications.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index 06460a91..ae22b4b8 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -6,7 +6,7 @@ import { useUser, useUserById } from 'web/hooks/use-user' import { Row } from 'web/components/layout/row' import { Avatar, EmptyAvatar } from 'web/components/avatar' import clsx from 'clsx' -import { UserIcon, UsersIcon } from '@heroicons/react/solid' +import { UsersIcon } from '@heroicons/react/solid' import { formatMoney } from 'common/util/format' import { OutcomeLabel } from 'web/components/outcome-label' import { RelativeTimestamp } from 'web/components/relative-timestamp' diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 070caa3e..a3af0a9a 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -25,7 +25,6 @@ import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users' import { LoadingIndicator } from 'web/components/loading-indicator' import clsx from 'clsx' -import { UsersIcon } from '@heroicons/react/solid' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { Linkify } from 'web/components/linkify' import { From b5810481d0d2afb234661caf2ea9acf4a2b378e6 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 23 Jun 2022 15:55:05 -0500 Subject: [PATCH 014/220] Restore category in tags to /create --- web/pages/create.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index bfca6f5f..e4cba4e0 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -167,6 +167,7 @@ export function NewContract(props: { question: string; groupId?: string }) { min, max, groupId: selectedGroup?.id, + tags: category ? [category] : undefined, }) ) track('create market', { From 17ac6c58b24758ae054c138ede839c8eab65ee2b Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 23 Jun 2022 16:09:26 -0500 Subject: [PATCH 015/220] Don't prompt to comment on FR bets, arrow spacing --- .../feed/feed-answer-comment-group.tsx | 17 +---------------- web/components/feed/feed-comments.tsx | 3 +-- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index 30d5f55b..5c3be539 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -88,26 +88,11 @@ export function FeedAnswerCommentGroup(props: { } ) - useEffect(() => { - if ( - mostRecentCommentableBet && - usersMostRecentBetTimeAtLoad !== undefined && - mostRecentCommentableBet.createdTime > usersMostRecentBetTimeAtLoad && - !showReply - ) - scrollAndOpenReplyInput(undefined, answer) - }, [ - answer, - usersMostRecentBetTimeAtLoad, - mostRecentCommentableBet, - scrollAndOpenReplyInput, - showReply, - ]) - useEffect(() => { // Only show one comment input for a bet at a time if ( betsByCurrentUser.length > 1 && + inputRef?.textContent?.length === 0 && betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0] ?.outcome !== answer.number.toString() ) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index adcddd4e..ed02128e 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -530,8 +530,7 @@ export function CommentInputTextArea(props: { {user && !isSubmitting && ( <button className={clsx( - 'btn btn-ghost btn-sm absolute right-2 flex-row pl-2 capitalize', - isReply ? ' bottom-4' : ' bottom-2', + 'btn btn-ghost btn-sm absolute right-2 bottom-2 flex-row pl-2 capitalize', !commentText && 'pointer-events-none text-gray-500' )} onClick={() => { From f0d4e9940c1f16b8c5b94bf541f9ebdd262fd6b8 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 23 Jun 2022 16:49:14 -0500 Subject: [PATCH 016/220] Improve group user search --- web/components/filter-select-users.tsx | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/web/components/filter-select-users.tsx b/web/components/filter-select-users.tsx index a70e3680..93badf20 100644 --- a/web/components/filter-select-users.tsx +++ b/web/components/filter-select-users.tsx @@ -1,12 +1,11 @@ import { UserIcon } from '@heroicons/react/outline' import { useUsers } from 'web/hooks/use-users' import { User } from 'common/user' -import { Fragment, useState } from 'react' +import { Fragment, useMemo, useState } from 'react' import clsx from 'clsx' import { Menu, Transition } from '@headlessui/react' import { Avatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' -import { debounce } from 'lodash' export function FilterSelectUsers(props: { setSelectedUsers: (users: User[]) => void @@ -16,18 +15,20 @@ export function FilterSelectUsers(props: { const { ignoreUserIds, selectedUsers, setSelectedUsers } = props const users = useUsers() const [query, setQuery] = useState('') - - const filteredUsers = - query === '' - ? users - : users.filter((user: User) => { + const [filteredUsers, setFilteredUsers] = useState<User[]>([]) + const beginQuerying = query.length > 2 + useMemo(() => { + if (beginQuerying) + setFilteredUsers( + users.filter((user: User) => { return ( !selectedUsers.map((user) => user.name).includes(user.name) && !ignoreUserIds.includes(user.id) && user.name.toLowerCase().includes(query.toLowerCase()) ) }) - const debouncedQuery = debounce(setQuery, 50) + ) + }, [beginQuerying, users, selectedUsers, ignoreUserIds, query]) return ( <div> @@ -40,7 +41,7 @@ export function FilterSelectUsers(props: { name="user name" id="user name" value={query} - onChange={(e) => debouncedQuery(e.target.value)} + onChange={(e) => setQuery(e.target.value)} className="input input-bordered block w-full pl-10 focus:border-gray-300 " placeholder="Austin Chen" /> @@ -48,13 +49,13 @@ export function FilterSelectUsers(props: { <Menu as="div" className={clsx( - 'relative inline-block w-full text-right', - query !== '' && 'h-36' + 'relative inline-block w-full overflow-y-scroll text-right', + beginQuerying && 'h-36' )} > {({}) => ( <Transition - show={query !== ''} + show={beginQuerying} as={Fragment} enter="transition ease-out duration-100" enterFrom="transform opacity-0 scale-95" From 4f9e303daaf1709ed3d22d0cb4c4a236a1944f67 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 23 Jun 2022 16:46:49 -0700 Subject: [PATCH 017/220] Clean up definition of v2 cloud function URLs (#562) --- common/envs/dev.ts | 9 ++------- common/envs/prod.ts | 22 +++++++--------------- common/envs/theoremone.ts | 10 ++-------- web/lib/api/proxy.ts | 3 +-- web/lib/firebase/api-call.ts | 6 +++--- 5 files changed, 15 insertions(+), 35 deletions(-) diff --git a/common/envs/dev.ts b/common/envs/dev.ts index e20c6e9e..3c062472 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -12,12 +12,7 @@ export const DEV_CONFIG: EnvConfig = { appId: '1:134303100058:web:27f9ea8b83347251f80323', measurementId: 'G-YJC9E37P37', }, - functionEndpoints: { - placebet: 'https://placebet-w3txbmd3ba-uc.a.run.app', - sellshares: 'https://sellshares-w3txbmd3ba-uc.a.run.app', - sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app', - createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app', - creategroup: 'https://creategroup-w3txbmd3ba-uc.a.run.app', - }, + cloudRunId: 'w3txbmd3ba', + cloudRunRegion: 'uc', amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index dbc686aa..f5a0e55e 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -1,16 +1,13 @@ -export type V2CloudFunction = - | 'placebet' - | 'sellbet' - | 'sellshares' - | 'createmarket' - | 'creategroup' - export type EnvConfig = { domain: string firebaseConfig: FirebaseConfig - functionEndpoints: Record<V2CloudFunction, string> amplitudeApiKey?: string + // IDs for v2 cloud functions -- find these by deploying a cloud function and + // examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app + cloudRunId: string + cloudRunRegion: string + // Access controls adminEmails: string[] whitelistEmail?: string // e.g. '@theoremone.co'. If not provided, all emails are whitelisted @@ -48,13 +45,8 @@ export const PROD_CONFIG: EnvConfig = { appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', measurementId: 'G-SSFK1Q138D', }, - functionEndpoints: { - placebet: 'https://placebet-nggbo3neva-uc.a.run.app', - sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app', - sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app', - createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app', - creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app', - }, + cloudRunId: 'nggbo3neva', + cloudRunRegion: 'uc', adminEmails: [ 'akrolsmir@gmail.com', // Austin 'jahooma@gmail.com', // James diff --git a/common/envs/theoremone.ts b/common/envs/theoremone.ts index afc2b8ba..3b7f595b 100644 --- a/common/envs/theoremone.ts +++ b/common/envs/theoremone.ts @@ -12,14 +12,8 @@ export const THEOREMONE_CONFIG: EnvConfig = { appId: '1:698012149198:web:b342af75662831aa84b79f', measurementId: 'G-Y3EZ1WNT6E', }, - // TODO: fill in real endpoints for T1 - functionEndpoints: { - placebet: 'https://placebet-nggbo3neva-uc.a.run.app', - sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app', - sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app', - createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app', - creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app', - }, + cloudRunId: 'nggbo3neva', // TODO: fill in real ID for T1 + cloudRunRegion: 'uc', adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'], whitelistEmail: '@theoremone.co', moneyMoniker: 'T$', diff --git a/web/lib/api/proxy.ts b/web/lib/api/proxy.ts index 6b83ff2b..ec027518 100644 --- a/web/lib/api/proxy.ts +++ b/web/lib/api/proxy.ts @@ -2,7 +2,6 @@ import { NextApiRequest, NextApiResponse } from 'next' import { promisify } from 'util' import { pipeline } from 'stream' import { getFunctionUrl } from 'web/lib/firebase/api-call' -import { V2CloudFunction } from 'common/envs/prod' import fetch, { Headers, Response } from 'node-fetch' function getProxiedRequestHeaders(req: NextApiRequest, whitelist: string[]) { @@ -33,7 +32,7 @@ function getProxiedResponseHeaders(res: Response, whitelist: string[]) { return result } -export const fetchBackend = (req: NextApiRequest, name: V2CloudFunction) => { +export const fetchBackend = (req: NextApiRequest, name: string) => { const url = getFunctionUrl(name) const headers = getProxiedRequestHeaders(req, [ 'Authorization', diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index b6648e35..d46b3afa 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -1,6 +1,5 @@ import { auth } from './users' import { ENV_CONFIG } from 'common/envs/constants' -import { V2CloudFunction } from 'common/envs/prod' export class APIError extends Error { code: number @@ -41,8 +40,9 @@ export async function call(url: string, method: string, params: any) { // app just hit the cloud functions directly -- there's no difference and it's // one less hop -export function getFunctionUrl(name: V2CloudFunction) { - return ENV_CONFIG.functionEndpoints[name] +export function getFunctionUrl(name: string) { + const { cloudRunId, cloudRunRegion } = ENV_CONFIG + return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app` } export function createMarket(params: any) { From 603bec9e884ac79f73272094a0690f32103a860e Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 23 Jun 2022 16:47:03 -0700 Subject: [PATCH 018/220] Ameliorate homepage search spam (#564) * Don't recompute search filters when follows loaded unnecessarily * Don't wait for router to get saved search sort --- web/components/contract-search.tsx | 4 ++-- web/hooks/use-sort-and-query-params.tsx | 12 +++++++++++- web/pages/home.tsx | 4 ++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 4c557648..e75aefb8 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -116,8 +116,8 @@ export function ContractSearch(props: { showCategorySelector, mode, Object.values(additionalFilter ?? {}).join(','), - followedCategories?.join(','), - follows?.join(','), + (followedCategories ?? []).join(','), + (follows ?? []).join(','), ]) const indexName = `${indexPrefix}contracts-${sort}` diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index 493c0dba..b7bfb288 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -20,6 +20,16 @@ export function checkAgainstQuery(query: string, corpus: string) { return queryWords.every((word) => corpus.toLowerCase().includes(word)) } +export function getSavedSort() { + // TODO: this obviously doesn't work with SSR, common sense would suggest + // that we should save things like this in cookies so the server has them + if (typeof window !== 'undefined') { + return localStorage.getItem(MARKETS_SORT) as Sort | null + } else { + return null + } +} + export function useInitialQueryAndSort(options?: { defaultSort: Sort shouldLoadFromStorage?: boolean @@ -45,7 +55,7 @@ export function useInitialQueryAndSort(options?: { if (!sort && shouldLoadFromStorage) { console.log('ready loading from storage ', sort ?? defaultSort) - const localSort = localStorage.getItem(MARKETS_SORT) as Sort + const localSort = getSavedSort() if (localSort) { router.query.s = localSort // Use replace to not break navigating back. diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 14e6cf2b..75eae351 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -5,6 +5,7 @@ import { PlusSmIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' +import { getSavedSort } from 'web/hooks/use-sort-and-query-params' import { ContractSearch } from 'web/components/contract-search' import { Contract } from 'common/contract' import { ContractPageContent } from './[username]/[contractSlug]' @@ -17,7 +18,6 @@ const Home = () => { const [contract, setContract] = useContractPage() const router = useRouter() - useTracking('view home') if (user === null) { @@ -32,7 +32,7 @@ const Home = () => { <ContractSearch querySortOptions={{ shouldLoadFromStorage: true, - defaultSort: '24-hour-vol', + defaultSort: getSavedSort() ?? '24-hour-vol', }} showCategorySelector onContractClick={(c) => { From d8a0cc281db52df8304b6896eaa80afd934e5fa8 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 23 Jun 2022 21:51:46 -0700 Subject: [PATCH 019/220] Greatly extend static page generation timeout (#576) --- web/next.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/web/next.config.js b/web/next.config.js index 80d4ac96..56f643d3 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -2,6 +2,7 @@ const API_DOCS_URL = 'https://docs.manifold.markets/api' /** @type {import('next').NextConfig} */ module.exports = { + staticPageGenerationTimeout: 600, // e.g. stats page reactStrictMode: true, experimental: { externalDir: true, From db3c65a974f3e75b0f7a0a05b8d61ee3878ae456 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 23 Jun 2022 22:09:54 -0700 Subject: [PATCH 020/220] Bump Cloud Functions Node version from 12 -> 16 (#563) --- firebase.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase.json b/firebase.json index 8da7d8f3..de1e19b7 100644 --- a/firebase.json +++ b/firebase.json @@ -1,7 +1,7 @@ { "functions": { "predeploy": "npm --prefix \"$RESOURCE_DIR\" run build", - "runtime": "nodejs12", + "runtime": "nodejs16", "source": "functions" }, "firestore": { From b4e09e37b1d8afacddbc5007014192979ca113c5 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 24 Jun 2022 00:18:08 -0700 Subject: [PATCH 021/220] Bump memory on all v2 functions to 2GB (#577) * Bump memory on all v2 functions to 2GB * Also give the v2 functions 1 vCPU * Also explicitly specify concurrency --- functions/src/api.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/functions/src/api.ts b/functions/src/api.ts index abbf952b..f7efab5a 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -110,6 +110,9 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => { const DEFAULT_OPTS: HttpsOptions = { minInstances: 1, + concurrency: 100, + memory: '2GiB', + cpu: 1, cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], } From ebc4bd6bcf1125e00fec285e469ac2acc068b016 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Fri, 24 Jun 2022 18:14:20 +0100 Subject: [PATCH 022/220] [PortfolioGraph] Shows a graph of the portfolio value over time (#570) * [Portfolio Graph] Shows a graph of the portfolio value over time * [PortfolioGraph] Fix some nits. * [PortfolioGraph] Comment out portfolio-value-section Hides the component completely for now, so we can land today. My plan would be to land today, wait for the history to build up, and then revert this commit. As opposed to leaving the PR idle for a while, and then have to deal with conflicts. * [PortfolioGraph] Rm duplicate firestore rule --- .../portfolio/portfolio-value-graph.tsx | 83 +++++++++++++++++++ .../portfolio/portfolio-value-section.tsx | 47 +++++++++++ web/components/user-page.tsx | 14 +++- web/lib/firebase/users.ts | 18 +++- web/lib/util/time.ts | 10 +++ web/pages/leaderboards.tsx | 10 +-- 6 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 web/components/portfolio/portfolio-value-graph.tsx create mode 100644 web/components/portfolio/portfolio-value-section.tsx diff --git a/web/components/portfolio/portfolio-value-graph.tsx b/web/components/portfolio/portfolio-value-graph.tsx new file mode 100644 index 00000000..558fc5f6 --- /dev/null +++ b/web/components/portfolio/portfolio-value-graph.tsx @@ -0,0 +1,83 @@ +import { ResponsiveLine } from '@nivo/line' +import { PortfolioMetrics } from 'common/user' +import { formatMoney } from 'common/util/format' +import { DAY_MS } from 'common/util/time' +import { last } from 'lodash' +import { memo } from 'react' +import { useWindowSize } from 'web/hooks/use-window-size' +import { formatTime } from 'web/lib/util/time' + +export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { + portfolioHistory: PortfolioMetrics[] + height?: number + period?: string +}) { + const { portfolioHistory, height, period } = props + + const { width } = useWindowSize() + + const portfolioHistoryFiltered = portfolioHistory.filter((p) => { + switch (period) { + case 'daily': + return p.timestamp > Date.now() - 1 * DAY_MS + case 'weekly': + return p.timestamp > Date.now() - 7 * DAY_MS + case 'monthly': + return p.timestamp > Date.now() - 30 * DAY_MS + case 'allTime': + return true + default: + return true + } + }) + + const points = portfolioHistoryFiltered.map((p) => { + return { + x: new Date(p.timestamp), + y: p.balance + p.investmentValue, + } + }) + const data = [{ id: 'Value', data: points, color: '#11b981' }] + const numXTickValues = !width || width < 800 ? 2 : 5 + const numYTickValues = 4 + const endDate = last(points)?.x + const includeTime = period === 'daily' + return ( + <div + className="w-full overflow-hidden" + style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }} + > + <ResponsiveLine + data={data} + margin={{ top: 20, right: 28, bottom: 22, left: 60 }} + xScale={{ + type: 'time', + min: points[0].x, + max: endDate, + }} + yScale={{ + type: 'linear', + stacked: false, + min: Math.min(...points.map((p) => p.y)), + }} + gridYValues={numYTickValues} + curve="monotoneX" + colors={{ datum: 'color' }} + axisBottom={{ + tickValues: numXTickValues, + format: (time) => formatTime(+time, includeTime), + }} + pointBorderColor="#fff" + pointSize={points.length > 100 ? 0 : 6} + axisLeft={{ + tickValues: numYTickValues, + format: (value) => formatMoney(value), + }} + enableGridX={!!width && width >= 800} + enableGridY={true} + enableSlices="x" + animate={false} + ></ResponsiveLine> + </div> + ) +}) diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx new file mode 100644 index 00000000..a992e87e --- /dev/null +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -0,0 +1,47 @@ +import { PortfolioMetrics } from 'common/user' +import { formatMoney } from 'common/util/format' +import { last } from 'lodash' +import { memo, useState } from 'react' +import { Period } from 'web/lib/firebase/users' +import { Col } from '../layout/col' +import { Row } from '../layout/row' +import { PortfolioValueGraph } from './portfolio-value-graph' + +export const PortfolioValueSection = memo( + function PortfolioValueSection(props: { + portfolioHistory: PortfolioMetrics[] + }) { + const { portfolioHistory } = props + const lastPortfolioMetrics = last(portfolioHistory) + const [portfolioPeriod] = useState<Period>('allTime') + + if (portfolioHistory.length === 0 || !lastPortfolioMetrics) { + return <div> No portfolio history data yet </div> + } + + return ( + <div> + <Row className="gap-8"> + <div className="mb-4 w-full"> + <Col> + <div className="text-sm text-gray-500">Portfolio value</div> + <div className="text-lg"> + {formatMoney( + lastPortfolioMetrics.balance + + lastPortfolioMetrics.investmentValue + )} + </div> + </Col> + </div> + { + //TODO: enable day/week/monthly as data becomes available + } + </Row> + <PortfolioValueGraph + portfolioHistory={portfolioHistory} + period={portfolioPeriod} + /> + </div> + ) + } +) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index cd896c59..2019a9de 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -6,7 +6,12 @@ import { LinkIcon } from '@heroicons/react/solid' import { PencilIcon } from '@heroicons/react/outline' import Confetti from 'react-confetti' -import { follow, unfollow, User } from 'web/lib/firebase/users' +import { + follow, + unfollow, + User, + getPortfolioHistory, +} from 'web/lib/firebase/users' import { CreatorContractsList } from './contract/contracts-list' import { SEO } from './SEO' import { Page } from './page' @@ -30,6 +35,7 @@ import { getUserBets } from 'web/lib/firebase/bets' import { FollowersButton, FollowingButton } from './following-button' import { useFollows } from 'web/hooks/use-follows' import { FollowButton } from './follow-button' +import { PortfolioMetrics } from 'common/user' export function UserLink(props: { name: string @@ -67,6 +73,7 @@ export function UserPage(props: { 'loading' ) const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading') + const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([]) const [commentsByContract, setCommentsByContract] = useState< Map<Contract, Comment[]> | 'loading' >('loading') @@ -83,6 +90,7 @@ export function UserPage(props: { getUsersComments(user.id).then(setUsersComments) listContracts(user.id).then(setUsersContracts) getUserBets(user.id, { includeRedemptions: false }).then(setUsersBets) + getPortfolioHistory(user.id).then(setUsersPortfolioHistory) }, [user]) // TODO: display comments on groups @@ -243,6 +251,7 @@ export function UserPage(props: { </Col> <Spacer h={10} /> + {usersContracts !== 'loading' && commentsByContract != 'loading' ? ( <Tabs className={'pb-2 pt-1 '} @@ -284,6 +293,9 @@ export function UserPage(props: { title: 'Bets', content: ( <div> + { + // TODO: add portfolio-value-section here + } <BetsList user={user} hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 97e30aa5..a3bba98a 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -24,7 +24,7 @@ import { import { range, throttle, zip } from 'lodash' import { app } from './init' -import { PrivateUser, User } from 'common/user' +import { PortfolioMetrics, PrivateUser, User } from 'common/user' import { createUser } from './fn-call' import { getValue, getValues, listenForValue, listenForValues } from './utils' import { DAY_MS } from 'common/util/time' @@ -35,7 +35,7 @@ import { filterDefined } from 'common/util/array' export type { User } -export type LeaderboardPeriod = 'daily' | 'weekly' | 'monthly' | 'allTime' +export type Period = 'daily' | 'weekly' | 'monthly' | 'allTime' const db = getFirestore(app) export const auth = getAuth(app) @@ -180,7 +180,7 @@ export function listenForPrivateUsers( listenForValues(q, setUsers) } -export function getTopTraders(period: LeaderboardPeriod) { +export function getTopTraders(period: Period) { const topTraders = query( collection(db, 'users'), orderBy('profitCached.' + period, 'desc'), @@ -190,7 +190,7 @@ export function getTopTraders(period: LeaderboardPeriod) { return getValues(topTraders) } -export function getTopCreators(period: LeaderboardPeriod) { +export function getTopCreators(period: Period) { const topCreators = query( collection(db, 'users'), orderBy('creatorVolumeCached.' + period, 'desc'), @@ -270,6 +270,16 @@ export async function unfollow(userId: string, unfollowedUserId: string) { await deleteDoc(followDoc) } +export async function getPortfolioHistory(userId: string) { + return getValues<PortfolioMetrics>( + query( + collectionGroup(db, 'portfolioHistory'), + where('userId', '==', userId), + orderBy('timestamp', 'asc') + ) + ) +} + export function listenForFollows( userId: string, setFollowIds: (followIds: string[]) => void diff --git a/web/lib/util/time.ts b/web/lib/util/time.ts index a5844b34..4bd76b91 100644 --- a/web/lib/util/time.ts +++ b/web/lib/util/time.ts @@ -5,3 +5,13 @@ dayjs.extend(relativeTime) export function fromNow(time: number) { return dayjs(time).fromNow() } + +export function formatTime(time: number, includeTime: boolean) { + const d = dayjs(time) + + if (d.isSame(Date.now(), 'day')) return d.format('ha') + + if (includeTime) return dayjs(time).format('MMM D, ha') + + return dayjs(time).format('MMM D') +} diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index ed05413a..7e141e72 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -4,8 +4,8 @@ import { Page } from 'web/components/page' import { getTopCreators, getTopTraders, - LeaderboardPeriod, getTopFollowed, + Period, User, } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' @@ -20,7 +20,7 @@ export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz() { return queryLeaderboardUsers('allTime') } -const queryLeaderboardUsers = async (period: LeaderboardPeriod) => { +const queryLeaderboardUsers = async (period: Period) => { const [topTraders, topCreators, topFollowed] = await Promise.all([ getTopTraders(period).catch(() => {}), getTopCreators(period).catch(() => {}), @@ -50,7 +50,7 @@ export default function Leaderboards(props: { const [topTradersState, setTopTraders] = useState(props.topTraders) const [topCreatorsState, setTopCreators] = useState(props.topCreators) const [isLoading, setLoading] = useState(false) - const [period, setPeriod] = useState<LeaderboardPeriod>('allTime') + const [period, setPeriod] = useState<Period>('allTime') useEffect(() => { setLoading(true) @@ -61,7 +61,7 @@ export default function Leaderboards(props: { }) }, [period]) - const LeaderboardWithPeriod = (period: LeaderboardPeriod) => { + const LeaderboardWithPeriod = (period: Period) => { return ( <> <Col className="mx-4 items-center gap-10 lg:flex-row"> @@ -127,7 +127,7 @@ export default function Leaderboards(props: { defaultIndex={0} onClick={(title, index) => { const period = ['allTime', 'monthly', 'weekly', 'daily'][index] - setPeriod(period as LeaderboardPeriod) + setPeriod(period as Period) }} tabs={[ { From 8ced159d9af90a81090c14238ccfcd9a59cb7028 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 24 Jun 2022 12:16:37 -0500 Subject: [PATCH 023/220] Various group & mobile ux improvements --- .../groups/{discussion.tsx => group-chat.tsx} | 38 +++++++++++++++---- web/hooks/use-comments.ts | 10 +++++ web/lib/firebase/groups.ts | 2 +- web/pages/group/[...slugs]/index.tsx | 24 ++++++------ 4 files changed, 52 insertions(+), 22 deletions(-) rename web/components/groups/{discussion.tsx => group-chat.tsx} (81%) diff --git a/web/components/groups/discussion.tsx b/web/components/groups/group-chat.tsx similarity index 81% rename from web/components/groups/discussion.tsx rename to web/components/groups/group-chat.tsx index a4a83745..5dedbc8f 100644 --- a/web/components/groups/discussion.tsx +++ b/web/components/groups/group-chat.tsx @@ -1,7 +1,7 @@ import { Row } from 'web/components/layout/row' import { Col } from 'web/components/layout/col' import { User } from 'common/user' -import React, { useEffect, memo, useState } from 'react' +import React, { useEffect, memo, useState, useMemo } from 'react' import { Avatar } from 'web/components/avatar' import { Group } from 'common/group' import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments' @@ -19,7 +19,7 @@ import { UserLink } from 'web/components/user-page' import { groupPath } from 'web/lib/firebase/groups' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' -export function Discussion(props: { +export function GroupChat(props: { messages: Comment[] user: User | null | undefined group: Group @@ -34,8 +34,30 @@ export function Discussion(props: { useState<HTMLDivElement | null>(null) const [replyToUsername, setReplyToUsername] = useState('') const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) + const [groupedMessages, setGroupedMessages] = useState<Comment[]>([]) const router = useRouter() + useMemo(() => { + // Group messages with createdTime within 2 minutes of each other. + const tempMessages = [] + for (let i = 0; i < messages.length; i++) { + const message = messages[i] + if (i === 0) tempMessages.push({ ...message }) + else { + const prevMessage = messages[i - 1] + const diff = message.createdTime - prevMessage.createdTime + const creatorsMatch = message.userId === prevMessage.userId + if (diff < 2 * 60 * 1000 && creatorsMatch) { + tempMessages[tempMessages.length - 1].text += `\n${message.text}` + } else { + tempMessages.push({ ...message }) + } + } + } + + setGroupedMessages(tempMessages) + }, [messages]) + useEffect(() => { scrollToMessageRef?.scrollIntoView() }, [scrollToMessageRef]) @@ -78,7 +100,7 @@ export function Discussion(props: { } ref={setScrollToBottomRef} > - {messages.map((message) => ( + {groupedMessages.map((message) => ( <GroupMessage user={user} key={message.id} @@ -142,8 +164,8 @@ const GroupMessage = memo(function GroupMessage_(props: { <Col ref={setRef} className={clsx( - isCreatorsComment ? 'mr-2 self-end' : ' ml-2', - 'w-fit max-w-md gap-1 space-x-3 rounded-md bg-white p-2 p-2 px-4 text-sm text-gray-500 transition-all duration-1000', + isCreatorsComment ? 'mr-2 self-end' : '', + 'w-fit max-w-sm gap-1 space-x-3 rounded-md bg-white p-1 text-sm text-gray-500 transition-colors duration-1000 sm:max-w-md sm:p-3 sm:leading-[1.3rem]', highlight ? `-m-1 bg-indigo-500/[0.2] p-2` : '' )} > @@ -151,8 +173,8 @@ const GroupMessage = memo(function GroupMessage_(props: { {!isCreatorsComment && ( <Col> <Avatar - className={'mx-2 ml-0'} - size={'sm'} + className={'mx-2 ml-2.5'} + size={'xs'} username={userUsername} avatarUrl={userAvatarUrl} /> @@ -161,7 +183,7 @@ const GroupMessage = memo(function GroupMessage_(props: { {!isCreatorsComment ? ( <UserLink username={userUsername} name={userName} /> ) : ( - <span>{'You'}</span> + <span className={'ml-2.5'}>{'You'}</span> )} <CopyLinkDateTimeComponent prefix={'group'} diff --git a/web/hooks/use-comments.ts b/web/hooks/use-comments.ts index d83a96eb..7d644444 100644 --- a/web/hooks/use-comments.ts +++ b/web/hooks/use-comments.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Comment, listenForCommentsOnContract, + listenForCommentsOnGroup, listenForRecentComments, } from 'web/lib/firebase/comments' @@ -14,6 +15,15 @@ export const useComments = (contractId: string) => { return comments } +export const useCommentsOnGroup = (groupId: string | undefined) => { + const [comments, setComments] = useState<Comment[] | undefined>() + + useEffect(() => { + if (groupId) return listenForCommentsOnGroup(groupId, setComments) + }, [groupId]) + + return comments +} export const useRecentComments = () => { const [recentComments, setRecentComments] = useState<Comment[] | undefined>() diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 09181046..40471eb4 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -17,7 +17,7 @@ const groupCollection = collection(db, 'groups') export function groupPath( groupSlug: string, - subpath?: 'edit' | 'questions' | 'details' | 'discussion' + subpath?: 'edit' | 'questions' | 'details' | 'chat' ) { return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 4899f57e..b38de6d5 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,7 +1,6 @@ import { take, sortBy, debounce } from 'lodash' import { Group } from 'common/group' -import { Comment } from 'common/comment' import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { listAllBets } from 'web/lib/firebase/bets' @@ -32,14 +31,14 @@ import { Tabs } from 'web/components/layout/tabs' import { ContractsGrid } from 'web/components/contract/contracts-list' import { CreateQuestionButton } from 'web/components/create-question-button' import React, { useEffect, useState } from 'react' -import { Discussion } from 'web/components/groups/discussion' -import { listenForCommentsOnGroup } from 'web/lib/firebase/comments' +import { GroupChat } from 'web/components/groups/group-chat' import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' import { PlusIcon } from '@heroicons/react/outline' import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' +import { useCommentsOnGroup } from 'web/hooks/use-comments' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -92,7 +91,7 @@ async function toTopUsers(userScores: { [userId: string]: number }) { export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } -const groupSubpages = [undefined, 'discussion', 'questions', 'details'] as const +const groupSubpages = [undefined, 'chat', 'questions', 'details'] as const export default function GroupPage(props: { group: Group | null @@ -115,14 +114,11 @@ export default function GroupPage(props: { const router = useRouter() const { slugs } = router.query as { slugs: string[] } - const page = (slugs?.[1] ?? 'discussion') as typeof groupSubpages[number] + const page = (slugs?.[1] ?? 'chat') as typeof groupSubpages[number] const group = useGroup(props.group?.id) ?? props.group - const [messages, setMessages] = useState<Comment[] | undefined>(undefined) const [contracts, setContracts] = useState<Contract[] | undefined>(undefined) - useEffect(() => { - if (group) listenForCommentsOnGroup(group.id, setMessages) - }, [group]) + const messages = useCommentsOnGroup(group?.id) useEffect(() => { if (group) @@ -189,7 +185,9 @@ export default function GroupPage(props: { <Row className={' items-center justify-between gap-4 '}> <div className={'mb-1'}> <Title className={'line-clamp-2'} text={group.name} /> - <span className={'text-gray-700'}>{group.about}</span> + <span className={'hidden text-gray-700 sm:block'}> + {group.about} + </span> </div> {isMember && ( <CreateQuestionButton @@ -209,13 +207,13 @@ export default function GroupPage(props: { defaultIndex={page === 'details' ? 2 : page === 'questions' ? 1 : 0} tabs={[ { - title: 'Discussion', + title: 'Chat', content: messages ? ( - <Discussion messages={messages} user={user} group={group} /> + <GroupChat messages={messages} user={user} group={group} /> ) : ( <LoadingIndicator /> ), - href: groupPath(group.slug, 'discussion'), + href: groupPath(group.slug, 'chat'), }, { title: 'Questions', From 8d7bf6fb64dccbc15aa18d0c998fb55550ab3cf8 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 24 Jun 2022 12:08:06 -0500 Subject: [PATCH 024/220] Apply tag and creatorId filters to contract firestore search --- web/components/contract-search.tsx | 2 +- web/pages/contract-search-firestore.tsx | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index e75aefb8..25688d4a 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -123,7 +123,7 @@ export function ContractSearch(props: { const indexName = `${indexPrefix}contracts-${sort}` if (IS_PRIVATE_MANIFOLD) { - return <ContractSearchFirestore querySortOptions={querySortOptions} /> + return <ContractSearchFirestore querySortOptions={querySortOptions} additionalFilter={additionalFilter} /> } return ( diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 00d8fe49..c9a7a666 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -14,9 +14,13 @@ export default function ContractSearchFirestore(props: { defaultSort: Sort shouldLoadFromStorage?: boolean } + additionalFilter?: { + creatorId?: string + tag?: string + } }) { const contracts = useContracts() - const { querySortOptions } = props + const { querySortOptions, additionalFilter } = props const { initialSort, initialQuery } = useInitialQueryAndSort(querySortOptions) const [sort, setSort] = useState(initialSort || 'newest') @@ -62,6 +66,20 @@ export default function ContractSearchFirestore(props: { matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) } + if (additionalFilter) { + const { creatorId, tag } = additionalFilter + + if (creatorId) { + matches = matches.filter((c) => c.creatorId === creatorId) + } + + if (tag) { + matches = matches.filter((c) => + c.lowercaseTags.includes(tag.toLowerCase()) + ) + } + } + const showTime = ['close-date', 'closed'].includes(sort) ? 'close-date' : sort === 'resolve-date' From e7abe709b32b4dd9e2b578dc0612d802445b68df Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Fri, 24 Jun 2022 18:24:20 +0100 Subject: [PATCH 025/220] [Leaderboard] Show daily 'topBettor' leaderboard (#579) --- web/pages/leaderboards.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 7e141e72..44c0a65b 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -67,7 +67,7 @@ export default function Leaderboards(props: { <Col className="mx-4 items-center gap-10 lg:flex-row"> {!isLoading ? ( <> - {period === 'allTime' ? ( //TODO: show other periods once they're available + {period === 'allTime' || period === 'daily' ? ( //TODO: show other periods once they're available <Leaderboard title="🏅 Top bettors" users={topTradersState} From b7dbcaaadf4e7a9fdd5e528324c9396caac2842d Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 24 Jun 2022 12:27:01 -0500 Subject: [PATCH 026/220] Run prettier --- web/components/contract-search.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 25688d4a..fac02d74 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -123,7 +123,12 @@ export function ContractSearch(props: { const indexName = `${indexPrefix}contracts-${sort}` if (IS_PRIVATE_MANIFOLD) { - return <ContractSearchFirestore querySortOptions={querySortOptions} additionalFilter={additionalFilter} /> + return ( + <ContractSearchFirestore + querySortOptions={querySortOptions} + additionalFilter={additionalFilter} + /> + ) } return ( From 969cdcaa168450edd668c8d632e60f24d1239844 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 24 Jun 2022 12:32:59 -0500 Subject: [PATCH 027/220] Search group contracts --- web/pages/group/[...slugs]/index.tsx | 33 +++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index b38de6d5..caa08314 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -118,7 +118,20 @@ export default function GroupPage(props: { const group = useGroup(props.group?.id) ?? props.group const [contracts, setContracts] = useState<Contract[] | undefined>(undefined) + const [query, setQuery] = useState('') + const messages = useCommentsOnGroup(group?.id) + const debouncedQuery = debounce(setQuery, 50) + const filteredContracts = + query != '' && contracts + ? contracts.filter( + (c) => + checkAgainstQuery(query, c.question) || + checkAgainstQuery(query, c.description || '') || + checkAgainstQuery(query, c.creatorName) || + checkAgainstQuery(query, c.creatorUsername) + ) + : [] useEffect(() => { if (group) @@ -218,14 +231,22 @@ export default function GroupPage(props: { { title: 'Questions', content: ( - <div className={'mt-2'}> + <div className={'mt-2 px-1'}> {contracts ? ( contracts.length > 0 ? ( - <ContractsGrid - contracts={contracts} - hasMore={false} - loadMore={() => {}} - /> + <> + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Search the group's questions" + className="input input-bordered mb-4 w-full" + /> + <ContractsGrid + contracts={query != '' ? filteredContracts : contracts} + hasMore={false} + loadMore={() => {}} + /> + </> ) : ( <div className="p-2 text-gray-500"> No questions yet. 🦗... Why not add one? From f224fc2e288f9880c14c408b95b2c8a4f1ecf883 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 24 Jun 2022 16:02:05 -0500 Subject: [PATCH 028/220] Clean up Group Sidebar by moving into Details tab --- web/pages/group/[...slugs]/index.tsx | 102 +++++++++++++-------------- 1 file changed, 50 insertions(+), 52 deletions(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index caa08314..d201842d 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -148,20 +148,11 @@ export default function GroupPage(props: { const rightSidebar = ( <Col className="mt-6 hidden xl:block"> - <GroupOverview - group={group} - creator={creator} - isCreator={!!isCreator} - user={user} - /> - <YourPerformance - traderScores={traderScores} - creatorScores={creatorScores} - user={user} - /> + <JoinOrCreateButton group={group} user={user} isMember={!!isMember} /> + <Spacer h={6} /> {contracts && ( <div className={'mt-2'}> - <div className={'my-2 text-lg text-indigo-700'}>Recent Questions</div> + <div className={'my-2 text-gray-500'}>Recent Questions</div> <ContractsGrid contracts={contracts .sort((a, b) => b.createdTime - a.createdTime) @@ -175,15 +166,24 @@ export default function GroupPage(props: { </Col> ) - const leaderboardsTab = ( - <Col className="mt-4 gap-8 px-4 md:flex-row"> - <GroupLeaderboards - traderScores={traderScores} - creatorScores={creatorScores} - topTraders={topTraders} - topCreators={topCreators} + const detailsTab = ( + <Col> + <GroupOverview + group={group} + creator={creator} + isCreator={!!isCreator} user={user} /> + <Spacer h={8} /> + <Col className="mt-4 gap-8 px-4 md:flex-row"> + <GroupLeaderboards + traderScores={traderScores} + creatorScores={creatorScores} + topTraders={topTraders} + topCreators={topCreators} + user={user} + /> + </Col> </Col> ) return ( @@ -194,27 +194,24 @@ export default function GroupPage(props: { url={groupPath(group.slug)} /> - <div className="px-3 lg:px-1"> - <Row className={' items-center justify-between gap-4 '}> + <Col className="px-3 lg:px-1"> + <Row className={'items-center justify-between gap-4'}> <div className={'mb-1'}> <Title className={'line-clamp-2'} text={group.name} /> - <span className={'hidden text-gray-700 sm:block'}> - {group.about} - </span> + <Linkify text={group.about} /> </div> - {isMember && ( - <CreateQuestionButton + <div className="hidden sm:block xl:hidden"> + <JoinOrCreateButton + group={group} user={user} - overrideText={'Add a new question'} - className={'w-48 flex-shrink-0'} - query={`?groupId=${group.id}`} + isMember={!!isMember} /> - )} - {!isMember && group.anyoneCanJoin && ( - <JoinGroupButton group={group} user={user} /> - )} + </div> </Row> - </div> + <div className="block sm:hidden"> + <JoinOrCreateButton group={group} user={user} isMember={!!isMember} /> + </div> + </Col> <Tabs defaultIndex={page === 'details' ? 2 : page === 'questions' ? 1 : 0} @@ -262,24 +259,7 @@ export default function GroupPage(props: { }, { title: 'Details', - content: ( - <> - <div className={'xl:hidden'}> - <GroupOverview - group={group} - creator={creator} - isCreator={!!isCreator} - user={user} - /> - <YourPerformance - traderScores={traderScores} - creatorScores={creatorScores} - user={user} - /> - </div> - {leaderboardsTab} - </> - ), + content: detailsTab, href: groupPath(group.slug, 'details'), }, ]} @@ -288,6 +268,24 @@ export default function GroupPage(props: { ) } +function JoinOrCreateButton(props: { + group: Group + user: User | null | undefined + isMember: boolean +}) { + const { group, user, isMember } = props + return isMember ? ( + <CreateQuestionButton + user={user} + overrideText={'Add a new question'} + className={'w-48 flex-shrink-0'} + query={`?groupId=${group.id}`} + /> + ) : group.anyoneCanJoin ? ( + <JoinGroupButton group={group} user={user} /> + ) : null +} + function GroupOverview(props: { group: Group creator: User From 83573610386d149951dee7b69381e0ed4667f407 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 24 Jun 2022 18:06:20 -0500 Subject: [PATCH 029/220] Remove unused function --- web/pages/group/[...slugs]/index.tsx | 36 ---------------------------- 1 file changed, 36 deletions(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index d201842d..f3e66c8d 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -381,42 +381,6 @@ export function GroupMembersList(props: { group: Group }) { ) } -function YourPerformance(props: { - traderScores: { [userId: string]: number } - creatorScores: { [userId: string]: number } - - user: User | null | undefined -}) { - const { traderScores, creatorScores, user } = props - - const yourTraderScore = user ? traderScores[user.id] : undefined - const yourCreatorScore = user ? creatorScores[user.id] : undefined - - return user ? ( - <Col> - <div className="rounded bg-indigo-500 px-4 py-3 text-sm text-white"> - Your performance - </div> - <div className="bg-white p-2"> - <table className="table-compact table w-full text-gray-500"> - <tbody> - <tr> - <td>Total profit</td> - <td>{formatMoney(yourTraderScore ?? 0)}</td> - </tr> - {yourCreatorScore && ( - <tr> - <td>Total created pool</td> - <td>{formatMoney(yourCreatorScore)}</td> - </tr> - )} - </tbody> - </table> - </div> - </Col> - ) : null -} - function GroupLeaderboards(props: { traderScores: { [userId: string]: number } creatorScores: { [userId: string]: number } From da81035e5850801bb49678d236bbb1b0daa22ff4 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 24 Jun 2022 18:38:39 -0500 Subject: [PATCH 030/220] Group leaderboards show members only by default --- web/components/widgets/short-toggle.tsx | 38 ++++++ web/hooks/use-group.ts | 17 ++- web/pages/group/[...slugs]/index.tsx | 154 ++++++++++++++++-------- 3 files changed, 148 insertions(+), 61 deletions(-) create mode 100644 web/components/widgets/short-toggle.tsx diff --git a/web/components/widgets/short-toggle.tsx b/web/components/widgets/short-toggle.tsx new file mode 100644 index 00000000..3c307fda --- /dev/null +++ b/web/components/widgets/short-toggle.tsx @@ -0,0 +1,38 @@ +/* This example requires Tailwind CSS v2.0+ */ +import { Switch } from '@headlessui/react' +import clsx from 'clsx' + +export default function ShortToggle(props: { + enabled: boolean + setEnabled: (enabled: boolean) => void +}) { + const { enabled, setEnabled } = props + + return ( + <Switch + checked={enabled} + onChange={setEnabled} + className="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" + > + <span className="sr-only">Use setting</span> + <span + aria-hidden="true" + className="pointer-events-none absolute h-full w-full rounded-md bg-white" + /> + <span + aria-hidden="true" + className={clsx( + enabled ? 'bg-indigo-600' : 'bg-gray-200', + 'pointer-events-none absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out' + )} + /> + <span + aria-hidden="true" + className={clsx( + enabled ? 'translate-x-5' : 'translate-x-0', + 'pointer-events-none absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow ring-0 transition-transform duration-200 ease-in-out' + )} + /> + </Switch> + ) +} diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index de260f2f..f73fd04e 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -65,19 +65,18 @@ export const useMemberGroupIds = (user: User | null | undefined) => { export function useMembers(group: Group) { const [members, setMembers] = useState<User[]>([]) useEffect(() => { - const { memberIds, creatorId } = group - if (memberIds.length > 1) - // get users via their user ids: - Promise.all( - memberIds.filter((mId) => mId !== creatorId).map(getUser) - ).then((users) => { - const members = users.filter((user) => user) - setMembers(members) - }) + const { memberIds } = group + if (memberIds.length > 0) { + listMembers(group).then((members) => setMembers(members)) + } }, [group]) return members } +export async function listMembers(group: Group) { + return await Promise.all(group.memberIds.map(getUser)) +} + export const useGroupsWithContract = (contractId: string | undefined) => { const [groups, setGroups] = useState<Group[] | null | undefined>() diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index f3e66c8d..593700f7 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -17,7 +17,7 @@ import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Spacer } from 'web/components/layout/spacer' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { useGroup, useMembers } from 'web/hooks/use-group' +import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' import { useRouter } from 'next/router' import { scoreCreators, scoreTraders } from 'common/scoring' import { Leaderboard } from 'web/components/leaderboard' @@ -39,12 +39,14 @@ import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' import { useCommentsOnGroup } from 'web/hooks/use-comments' +import ShortToggle from 'web/components/widgets/short-toggle' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { const { slugs } = props.params const group = await getGroupBySlug(slugs[0]) + const members = group ? await listMembers(group) : [] const creatorPromise = group ? getUser(group.creatorId) : null const contracts = group ? await getGroupContracts(group).catch((_) => []) : [] @@ -65,6 +67,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { return { props: { group, + members, creator, traderScores, topTraders, @@ -95,6 +98,7 @@ const groupSubpages = [undefined, 'chat', 'questions', 'details'] as const export default function GroupPage(props: { group: Group | null + members: User[] creator: User traderScores: { [userId: string]: number } topTraders: User[] @@ -103,14 +107,21 @@ export default function GroupPage(props: { }) { props = usePropz(props, getStaticPropz) ?? { group: null, + members: [], creator: null, traderScores: {}, topTraders: [], creatorScores: {}, topCreators: [], } - const { creator, traderScores, topTraders, creatorScores, topCreators } = - props + const { + creator, + members, + traderScores, + topTraders, + creatorScores, + topCreators, + } = props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -175,15 +186,15 @@ export default function GroupPage(props: { user={user} /> <Spacer h={8} /> - <Col className="mt-4 gap-8 px-4 md:flex-row"> - <GroupLeaderboards - traderScores={traderScores} - creatorScores={creatorScores} - topTraders={topTraders} - topCreators={topCreators} - user={user} - /> - </Col> + + <GroupLeaderboards + traderScores={traderScores} + creatorScores={creatorScores} + topTraders={topTraders} + topCreators={topCreators} + members={members} + user={user} + /> </Col> ) return ( @@ -312,7 +323,7 @@ function GroupOverview(props: { return ( <Col> <Row className="items-center justify-end rounded-t bg-indigo-500 px-4 py-3 text-sm text-white"> - <Row className="flex-1 justify-start">About group</Row> + <Row className="flex-1 justify-start">About {group.name}</Row> {isCreator && <EditGroupButton className={'ml-1'} group={group} />} </Row> <Col className="gap-2 rounded-b bg-white p-4"> @@ -324,7 +335,6 @@ function GroupOverview(props: { username={creator.username} /> </Row> - <GroupMembersList group={group} /> <Row className={'items-center gap-1'}> <span className={'text-gray-500'}>Membership</span> {user && user.id === creator.id ? ( @@ -343,14 +353,6 @@ function GroupOverview(props: { </span> )} </Row> - {about && ( - <> - <Spacer h={2} /> - <div className="text-gray-500"> - <Linkify text={about} /> - </div> - </> - )} </Col> </Col> ) @@ -381,46 +383,94 @@ export function GroupMembersList(props: { group: Group }) { ) } +function SortedLeaderboard(props: { + users: User[] + scoreFunction: (user: User) => number + title: string + header: string +}) { + const { users, scoreFunction, title, header } = props + const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a)) + return ( + <Leaderboard + className="max-w-xl" + users={sortedUsers} + title={title} + columns={[ + { header, renderCell: (user) => formatMoney(scoreFunction(user)) }, + ]} + /> + ) +} + function GroupLeaderboards(props: { traderScores: { [userId: string]: number } creatorScores: { [userId: string]: number } topTraders: User[] topCreators: User[] + members: User[] user: User | null | undefined }) { - const { traderScores, creatorScores, topTraders, topCreators } = props - - const topTraderScores = topTraders.map((user) => traderScores[user.id]) - const topCreatorScores = topCreators.map((user) => creatorScores[user.id]) + const { traderScores, creatorScores, members, topTraders, topCreators } = + props + const [includeOutsiders, setIncludeOutsiders] = useState(false) + // Consider hiding M$0 return ( - <> - <Leaderboard - className="max-w-xl" - title="🏅 Top bettors" - users={topTraders} - columns={[ - { - header: 'Profit', - renderCell: (user) => - formatMoney(topTraderScores[topTraders.indexOf(user)]), - }, - ]} - /> + <Col> + <Row className="items-center justify-end gap-4 text-gray-500"> + Include all users + <ShortToggle + enabled={includeOutsiders} + setEnabled={setIncludeOutsiders} + /> + </Row> - <Leaderboard - className="max-w-xl" - title="🏅 Top creators" - users={topCreators} - columns={[ - { - header: 'Market volume', - renderCell: (user) => - formatMoney(topCreatorScores[topCreators.indexOf(user)]), - }, - ]} - /> - </> + <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> + {!includeOutsiders ? ( + <> + <SortedLeaderboard + users={members} + scoreFunction={(user) => traderScores[user.id] ?? 0} + title="🏅 Top bettors" + header="Profit" + /> + <SortedLeaderboard + users={members} + scoreFunction={(user) => creatorScores[user.id] ?? 0} + title="🏅 Top creators" + header="Market volume" + /> + </> + ) : ( + <> + <Leaderboard + className="max-w-xl" + title="🏅 Top bettors" + users={topTraders} + columns={[ + { + header: 'Profit', + renderCell: (user) => formatMoney(traderScores[user.id] ?? 0), + }, + ]} + /> + <Leaderboard + className="max-w-xl" + title="🏅 Top creators" + users={topCreators} + columns={[ + { + header: 'Market volume', + renderCell: (user) => + formatMoney(creatorScores[user.id] ?? 0), + }, + ]} + /> + </> + )} + </div> + </Col> ) } From 3123021d9445d9e2e6545773fed74125b71de723 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 24 Jun 2022 18:41:02 -0500 Subject: [PATCH 031/220] Rename "Details" to "About" --- web/lib/firebase/groups.ts | 2 +- web/pages/group/[...slugs]/index.tsx | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 40471eb4..1438dd4c 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -17,7 +17,7 @@ const groupCollection = collection(db, 'groups') export function groupPath( groupSlug: string, - subpath?: 'edit' | 'questions' | 'details' | 'chat' + subpath?: 'edit' | 'questions' | 'about' | 'chat' ) { return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 593700f7..b2b758a1 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -94,7 +94,7 @@ async function toTopUsers(userScores: { [userId: string]: number }) { export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } -const groupSubpages = [undefined, 'chat', 'questions', 'details'] as const +const groupSubpages = [undefined, 'chat', 'questions', 'about'] as const export default function GroupPage(props: { group: Group | null @@ -177,7 +177,7 @@ export default function GroupPage(props: { </Col> ) - const detailsTab = ( + const aboutTab = ( <Col> <GroupOverview group={group} @@ -225,7 +225,7 @@ export default function GroupPage(props: { </Col> <Tabs - defaultIndex={page === 'details' ? 2 : page === 'questions' ? 1 : 0} + defaultIndex={page === 'about' ? 2 : page === 'questions' ? 1 : 0} tabs={[ { title: 'Chat', @@ -269,9 +269,9 @@ export default function GroupPage(props: { href: groupPath(group.slug, 'questions'), }, { - title: 'Details', - content: detailsTab, - href: groupPath(group.slug, 'details'), + title: 'About', + content: aboutTab, + href: groupPath(group.slug, 'about'), }, ]} /> From 4edad9f19b28fa08f5cffa3306b7249f807bf40e Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 24 Jun 2022 22:41:30 -0700 Subject: [PATCH 032/220] Use Typescript project references, improve functions build/deploy (#575) * More liberal .gitignores on TS output directories * Use project references for Typescript projects * Use /dist dir for Cloud Functions deployment payload * Disable `next build` typechecking * Fiddle with GitHub tsc jobs --- .github/workflows/check.yml | 4 ++-- common/.gitignore | 5 ++--- common/tsconfig.json | 2 ++ firebase.json | 4 ++-- functions/.gitignore | 6 ++++-- functions/package.json | 5 +++-- functions/tsconfig.json | 8 +++++++- web/next.config.js | 3 +++ web/tsconfig.json | 8 +++++++- 9 files changed, 32 insertions(+), 13 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index dcf81c44..49e988de 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -48,8 +48,8 @@ jobs: - name: Run Typescript checker on web client if: ${{ success() || failure() }} working-directory: web - run: tsc --pretty --project tsconfig.json --noEmit + run: tsc -b -v --pretty - name: Run Typescript checker on cloud functions if: ${{ success() || failure() }} working-directory: functions - run: tsc --pretty --project tsconfig.json --noEmit + run: tsc -b -v --pretty diff --git a/common/.gitignore b/common/.gitignore index e0ba0181..11320851 100644 --- a/common/.gitignore +++ b/common/.gitignore @@ -1,6 +1,5 @@ # Compiled JavaScript files -lib/**/*.js -lib/**/*.js.map +lib/ # TypeScript v1 declaration files typings/ @@ -10,4 +9,4 @@ node_modules/ package-lock.json ui-debug.log -firebase-debug.log \ No newline at end of file +firebase-debug.log diff --git a/common/tsconfig.json b/common/tsconfig.json index 158a5218..62a5c745 100644 --- a/common/tsconfig.json +++ b/common/tsconfig.json @@ -1,6 +1,8 @@ { "compilerOptions": { "baseUrl": "../", + "composite": true, + "module": "commonjs", "moduleResolution": "node", "noImplicitReturns": true, "outDir": "lib", diff --git a/firebase.json b/firebase.json index de1e19b7..25f9b61f 100644 --- a/firebase.json +++ b/firebase.json @@ -1,8 +1,8 @@ { "functions": { - "predeploy": "npm --prefix \"$RESOURCE_DIR\" run build", + "predeploy": "cd functions && yarn build", "runtime": "nodejs16", - "source": "functions" + "source": "functions/dist" }, "firestore": { "rules": "firestore.rules", diff --git a/functions/.gitignore b/functions/.gitignore index 7aeaedd4..2aeae30c 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -2,9 +2,11 @@ .env* .runtimeconfig.json +# GCP deployment artifact +dist/ + # Compiled JavaScript files -lib/**/*.js -lib/**/*.js.map +lib/ # TypeScript v1 declaration files typings/ diff --git a/functions/package.json b/functions/package.json index 7b5c30b0..aca3dcf4 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,8 @@ "firestore": "dev-mantic-markets.appspot.com" }, "scripts": { - "build": "tsc", + "build": "yarn compile && rm -r dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist", + "compile": "tsc -b", "watch": "tsc -w", "shell": "yarn build && firebase functions:shell", "start": "yarn shell", @@ -18,7 +19,7 @@ "db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/", "verify": "(cd .. && yarn verify)" }, - "main": "lib/functions/src/index.js", + "main": "functions/src/index.js", "dependencies": { "@amplitude/node": "1.10.0", "fetch": "1.1.0", diff --git a/functions/tsconfig.json b/functions/tsconfig.json index e183bb44..9496b9cb 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "baseUrl": "../", + "composite": true, "module": "commonjs", "noImplicitReturns": true, "outDir": "lib", @@ -8,6 +9,11 @@ "strict": true, "target": "es2017" }, + "references": [ + { + "path": "../common" + } + ], "compileOnSave": true, - "include": ["src", "../common/**/*.ts"] + "include": ["src"] } diff --git a/web/next.config.js b/web/next.config.js index 56f643d3..28a009ad 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -4,6 +4,9 @@ const API_DOCS_URL = 'https://docs.manifold.markets/api' module.exports = { staticPageGenerationTimeout: 600, // e.g. stats page reactStrictMode: true, + typescript: { + ignoreBuildErrors: true, + }, experimental: { externalDir: true, optimizeCss: true, diff --git a/web/tsconfig.json b/web/tsconfig.json index 96cf1311..7fbb2472 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "es5", + "composite": true, "baseUrl": "../", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, @@ -16,10 +17,15 @@ "jsx": "preserve", "incremental": true }, + "references": [ + { + "path": "../common" + } + ], "watchOptions": { "excludeDirectories": [".next"] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "../common/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] } From b7cbd2a431463cd8352e684bc28d9857e36c5eb7 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 24 Jun 2022 22:44:41 -0700 Subject: [PATCH 033/220] More robust functions deploy script --- functions/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/package.json b/functions/package.json index aca3dcf4..45ddcac2 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,7 @@ "firestore": "dev-mantic-markets.appspot.com" }, "scripts": { - "build": "yarn compile && rm -r dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist", + "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist", "compile": "tsc -b", "watch": "tsc -w", "shell": "yarn build && firebase functions:shell", From 5e768aa57c04f7410eff70faee7b85148d7acf2e Mon Sep 17 00:00:00 2001 From: Ben Congdon <ben@congdon.dev> Date: Sat, 25 Jun 2022 16:18:49 -0700 Subject: [PATCH 034/220] Prevent duplicate Free Response answers (#581) * Prevent duplicate Free Response answers * Address review comments --- .../answers/create-answer-panel.tsx | 69 ++++++++++++++++++- web/package.json | 4 +- yarn.lock | 15 ++-- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 2a089f50..6eeadf97 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx' import { useState } from 'react' import Textarea from 'react-expanding-textarea' +import { findBestMatch } from 'string-similarity' import { FreeResponseContract } from 'common/contract' import { BuyAmountInput } from '../amount-input' @@ -23,6 +24,7 @@ import { firebaseLogin } from 'web/lib/firebase/users' import { Bet } from 'common/bet' import { MAX_ANSWER_LENGTH } from 'common/answer' import { withTracking } from 'web/lib/service/analytics' +import { lowerCase } from 'lodash' export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const { contract } = props @@ -30,9 +32,15 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const [text, setText] = useState('') const [betAmount, setBetAmount] = useState<number | undefined>(10) const [amountError, setAmountError] = useState<string | undefined>() + const [answerError, setAnswerError] = useState<string | undefined>() + const [possibleDuplicateAnswer, setPossibleDuplicateAnswer] = useState< + string | undefined + >() const [isSubmitting, setIsSubmitting] = useState(false) + const { answers } = contract - const canSubmit = text && betAmount && !amountError && !isSubmitting + const canSubmit = + text && betAmount && !amountError && !isSubmitting && !answerError const submitAnswer = async () => { if (canSubmit) { @@ -54,6 +62,36 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { } } + const changeAnswer = (text: string) => { + setText(text) + const existingAnswer = answers.find( + (a) => lowerCase(a.text) === lowerCase(text) + ) + + if (existingAnswer) { + setAnswerError( + existingAnswer + ? `"${existingAnswer.text}" already exists as an answer` + : '' + ) + return + } else { + setAnswerError('') + } + + if (answers.length && text) { + const matches = findBestMatch( + lowerCase(text), + answers.map((a) => lowerCase(a.text)) + ) + setPossibleDuplicateAnswer( + matches.bestMatch.rating > 0.8 + ? answers[matches.bestMatchIndex].text + : '' + ) + } + } + const resultProb = getDpmOutcomeProbabilityAfterBet( contract.totalShares, 'new', @@ -79,12 +117,21 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { <div className="mb-1">Add your answer</div> <Textarea value={text} - onChange={(e) => setText(e.target.value)} + onChange={(e) => changeAnswer(e.target.value)} className="textarea textarea-bordered w-full resize-none" placeholder="Type your answer..." rows={1} maxLength={MAX_ANSWER_LENGTH} /> + {answerError ? ( + <AnswerError key={1} level="error" text={answerError} /> + ) : possibleDuplicateAnswer ? ( + <AnswerError + key={2} + level="warning" + text={`Did you mean to bet on "${possibleDuplicateAnswer}"?`} + /> + ) : undefined} <div /> <Col className={clsx( @@ -163,3 +210,21 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { </Col> ) } + +type answerErrorLevel = 'warning' | 'error' + +const AnswerError = (props: { text: string; level: answerErrorLevel }) => { + const { text, level } = props + const colorClass = + { + error: 'text-red-500', + warning: 'text-orange-500', + }[level] ?? '' + return ( + <div + className={`${colorClass} mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide`} + > + {text} + </div> + ) +} diff --git a/web/package.json b/web/package.json index 5b05a292..cde76121 100644 --- a/web/package.json +++ b/web/package.json @@ -41,7 +41,8 @@ "react-expanding-textarea": "2.3.5", "react-hot-toast": "2.2.0", "react-instantsearch-hooks-web": "6.24.1", - "react-query": "3.39.0" + "react-query": "3.39.0", + "string-similarity": "^4.0.4" }, "devDependencies": { "@tailwindcss/forms": "0.4.0", @@ -50,6 +51,7 @@ "@types/lodash": "4.14.178", "@types/node": "16.11.11", "@types/react": "17.0.43", + "@types/string-similarity": "^4.0.0", "autoprefixer": "10.2.6", "concurrently": "6.5.1", "critters": "0.0.16", diff --git a/yarn.lock b/yarn.lock index 83742947..15cd3c51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3148,6 +3148,11 @@ dependencies: "@types/node" "*" +"@types/string-similarity@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/string-similarity/-/string-similarity-4.0.0.tgz#8cc03d5d1baad2b74530fe6c7d849d5768d391ad" + integrity sha512-dMS4S07fbtY1AILG/RhuwmptmzK1Ql8scmAebOTJ/8iBtK/KI17NwGwKzu1uipjj8Kk+3mfPxum56kKZE93mzQ== + "@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" @@ -8026,11 +8031,6 @@ nanoid@^3.1.23, nanoid@^3.1.30, nanoid@^3.3.4: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== -nanoid@^3.3.4: - version "3.3.4" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" - integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== - natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -10320,6 +10320,11 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== +string-similarity@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b" + integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ== + string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" From fa86f5e89a1b1a6478a29d94dc93152eac273edc Mon Sep 17 00:00:00 2001 From: Justin <21313833+wasabipesto@users.noreply.github.com> Date: Sat, 25 Jun 2022 19:28:01 -0400 Subject: [PATCH 035/220] Add Users API endpoint (#547) * add users endpoint to API * docs, url * tweak docs --- docs/docs/api.md | 59 +++++++++++++++++++++++++++++++++++++ web/pages/api/v0/_types.ts | 60 ++++++++++++++++++++++++++++++++++++++ web/pages/api/v0/users.ts | 17 +++++++++++ 3 files changed, 136 insertions(+) create mode 100644 web/pages/api/v0/users.ts diff --git a/docs/docs/api.md b/docs/docs/api.md index b7420b52..ffdaa65f 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -398,6 +398,65 @@ Requires no authorization. ``` - Response type: A `FullMarket` ; same as above. +### `GET /v0/users` + +Lists all users. + +Requires no authorization. + +- Example request + ``` + https://manifold.markets/api/v0/users + ``` +- Example response + ```json + [ + { + "id":"igi2zGXsfxYPgB0DJTXVJVmwCOr2", + "createdTime":1639011767273, + "name":"Austin", + "username":"Austin", + "url":"https://manifold.markets/Austin", + "avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c", + "bio":"I build Manifold! Always happy to chat; reach out on Discord or find a time on https://calendly.com/austinchen/manifold!", + "bannerUrl":"https://images.unsplash.com/photo-1501523460185-2aa5d2a0f981?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1531&q=80", + "website":"https://blog.austn.io", + "twitterHandle":"akrolsmir", + "discordHandle":"akrolsmir#4125", + "balance":9122.607163564959, + "totalDeposits":10339.004780544328, + "totalPnLCached":9376.601262721899, + "creatorVolumeCached":76078.46984199001 + } + ``` +- Response type: Array of `LiteUser` + + ```tsx + // Basic information about a user + type LiteUser = { + id: string // user's unique id + createdTime: number + + name: string // display name, may contain spaces + username: string // username, used in urls + url: string // link to user's profile + avatarUrl?: string + + bio?: string + bannerUrl?: string + website?: string + twitterHandle?: string + discordHandle?: string + + // Note: the following are here for convenience only and may be removed in the future. + balance: number + totalDeposits: number + totalPnLCached: number + creatorVolumeCached: number + } + ``` + + ### `POST /v0/bet` Places a new bet on behalf of the authorized user. diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index d3a74053..f9a66fa2 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -3,7 +3,9 @@ import { Answer } from 'common/answer' import { getOutcomeProbability, getProbability } from 'common/calculate' import { Comment } from 'common/comment' import { Contract } from 'common/contract' +import { User } from 'common/user' import { removeUndefinedProps } from 'common/util/object' +import { ENV_CONFIG } from 'common/envs/constants' export type LiteMarket = { // Unique identifer for this market @@ -143,3 +145,61 @@ function augmentAnswerWithProbability( probability, } } + +export type LiteUser = { + id: string + createdTime: number + + name: string + username: string + url: string + avatarUrl?: string + + bio?: string + bannerUrl?: string + website?: string + twitterHandle?: string + discordHandle?: string + + balance: number + totalDeposits: number + totalPnLCached: number + creatorVolumeCached: number +} + +export function toLiteUser(user: User): LiteUser { + const { + id, + createdTime, + name, + username, + avatarUrl, + bio, + bannerUrl, + website, + twitterHandle, + discordHandle, + balance, + totalDeposits, + totalPnLCached, + creatorVolumeCached, + } = user + + return removeUndefinedProps({ + id, + createdTime, + name, + username, + url: `https://${ENV_CONFIG.domain}/${username}`, + avatarUrl, + bio, + bannerUrl, + website, + twitterHandle, + discordHandle, + balance, + totalDeposits, + totalPnLCached, + creatorVolumeCached, + }) +} diff --git a/web/pages/api/v0/users.ts b/web/pages/api/v0/users.ts new file mode 100644 index 00000000..8c62b601 --- /dev/null +++ b/web/pages/api/v0/users.ts @@ -0,0 +1,17 @@ +// Next.js API route support: https://vercel.com/docs/concepts/functions/serverless-functions +import type { NextApiRequest, NextApiResponse } from 'next' +import { listAllUsers } from 'web/lib/firebase/users' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { toLiteUser } from './_types' + +type Data = any[] + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<Data> +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + const users = await listAllUsers() + res.setHeader('Cache-Control', 'max-age=0') + res.status(200).json(users.map(toLiteUser)) +} From 11f6a57c543858e34a5fc88dcc9148c4d44ad84f Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 25 Jun 2022 18:20:10 -0700 Subject: [PATCH 036/220] Fix types on API LiteUser --- web/pages/api/v0/_types.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index f9a66fa2..e0012c2b 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -163,8 +163,20 @@ export type LiteUser = { balance: number totalDeposits: number - totalPnLCached: number - creatorVolumeCached: number + + profitCached: { + daily: number + weekly: number + monthly: number + allTime: number + } + + creatorVolumeCached: { + daily: number + weekly: number + monthly: number + allTime: number + } } export function toLiteUser(user: User): LiteUser { @@ -181,7 +193,7 @@ export function toLiteUser(user: User): LiteUser { discordHandle, balance, totalDeposits, - totalPnLCached, + profitCached, creatorVolumeCached, } = user @@ -199,7 +211,7 @@ export function toLiteUser(user: User): LiteUser { discordHandle, balance, totalDeposits, - totalPnLCached, + profitCached, creatorVolumeCached, }) } From 2e5d852a77aa2600e273193091c1733dcfd5747d Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 25 Jun 2022 18:20:54 -0700 Subject: [PATCH 037/220] Fix lint --- web/pages/group/[...slugs]/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index b2b758a1..a3b99128 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -304,7 +304,6 @@ function GroupOverview(props: { isCreator: boolean }) { const { group, creator, isCreator, user } = props - const { about } = group const anyoneCanJoinChoices: { [key: string]: string } = { Closed: 'false', Open: 'true', From 0067bee94be823864d59962126a7cccdeed62032 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sun, 26 Jun 2022 14:42:42 -0700 Subject: [PATCH 038/220] Compute stats in Firebase instead of Vercel (#584) * Add stats updating cloud function * Read stats from database on client instead of computing them * Improve logging for stats updater * Tidying up --- common/stats.ts | 23 ++ firestore.rules | 6 +- functions/src/index.ts | 1 + functions/src/scripts/update-stats.ts | 15 ++ functions/src/update-stats.ts | 316 ++++++++++++++++++++++++++ web/lib/firebase/bets.ts | 23 +- web/lib/firebase/comments.ts | 28 --- web/lib/firebase/contracts.ts | 31 +-- web/lib/firebase/stats.ts | 15 ++ web/lib/firebase/users.ts | 27 +-- web/pages/embed/analytics.tsx | 23 +- web/pages/stats.tsx | 269 +--------------------- 12 files changed, 402 insertions(+), 375 deletions(-) create mode 100644 common/stats.ts create mode 100644 functions/src/scripts/update-stats.ts create mode 100644 functions/src/update-stats.ts create mode 100644 web/lib/firebase/stats.ts diff --git a/common/stats.ts b/common/stats.ts new file mode 100644 index 00000000..152a6eae --- /dev/null +++ b/common/stats.ts @@ -0,0 +1,23 @@ +export type Stats = { + startDate: number + dailyActiveUsers: number[] + weeklyActiveUsers: number[] + monthlyActiveUsers: number[] + dailyBetCounts: number[] + dailyContractCounts: number[] + dailyCommentCounts: number[] + dailySignups: number[] + weekOnWeekRetention: number[] + monthlyRetention: number[] + weeklyActivationRate: number[] + topTenthActions: { + daily: number[] + weekly: number[] + monthly: number[] + } + manaBet: { + daily: number[] + weekly: number[] + monthly: number[] + } +} diff --git a/firestore.rules b/firestore.rules index 7f02a43d..176cc71e 100644 --- a/firestore.rules +++ b/firestore.rules @@ -12,13 +12,17 @@ service cloud.firestore { || request.auth.uid == 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // Manifold } + match /stats/stats { + allow read; + } + match /users/{userId} { allow read; allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']); } - + match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { allow read; } diff --git a/functions/src/index.ts b/functions/src/index.ts index 0a538ff8..dcd50e66 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -15,6 +15,7 @@ export * from './on-create-comment' export * from './on-view' export * from './unsubscribe' export * from './update-metrics' +export * from './update-stats' export * from './backup-db' export * from './change-user-info' export * from './market-close-notifications' diff --git a/functions/src/scripts/update-stats.ts b/functions/src/scripts/update-stats.ts new file mode 100644 index 00000000..105230ba --- /dev/null +++ b/functions/src/scripts/update-stats.ts @@ -0,0 +1,15 @@ +import { initAdmin } from './script-init' +initAdmin() + +import { log, logMemory } from '../utils' +import { updateStatsCore } from '../update-stats' + +async function updateStats() { + logMemory() + log('Updating stats...') + await updateStatsCore() +} + +if (require.main === module) { + updateStats().then(() => process.exit()) +} diff --git a/functions/src/update-stats.ts b/functions/src/update-stats.ts new file mode 100644 index 00000000..f99458ef --- /dev/null +++ b/functions/src/update-stats.ts @@ -0,0 +1,316 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { concat, countBy, sortBy, range, zip, uniq, sum, sumBy } from 'lodash' +import { getValues, log, logMemory } from './utils' +import { Bet } from '../../common/bet' +import { Contract } from '../../common/contract' +import { Comment } from '../../common/comment' +import { User } from '../../common/user' +import { DAY_MS } from '../../common/util/time' +import { average } from '../../common/util/math' + +const firestore = admin.firestore() + +const numberOfDays = 90 + +const getBetsQuery = (startTime: number, endTime: number) => + firestore + .collectionGroup('bets') + .where('createdTime', '>=', startTime) + .where('createdTime', '<', endTime) + .orderBy('createdTime', 'asc') + +export async function getDailyBets(startTime: number, numberOfDays: number) { + const query = getBetsQuery(startTime, startTime + DAY_MS * numberOfDays) + const bets = await getValues<Bet>(query) + + const betsByDay = range(0, numberOfDays).map(() => [] as Bet[]) + for (const bet of bets) { + const dayIndex = Math.floor((bet.createdTime - startTime) / DAY_MS) + betsByDay[dayIndex].push(bet) + } + + return betsByDay +} + +const getCommentsQuery = (startTime: number, endTime: number) => + firestore + .collectionGroup('comments') + .where('createdTime', '>=', startTime) + .where('createdTime', '<', endTime) + .orderBy('createdTime', 'asc') + +export async function getDailyComments( + startTime: number, + numberOfDays: number +) { + const query = getCommentsQuery(startTime, startTime + DAY_MS * numberOfDays) + const comments = await getValues<Comment>(query) + + const commentsByDay = range(0, numberOfDays).map(() => [] as Comment[]) + for (const comment of comments) { + const dayIndex = Math.floor((comment.createdTime - startTime) / DAY_MS) + commentsByDay[dayIndex].push(comment) + } + + return commentsByDay +} + +const getContractsQuery = (startTime: number, endTime: number) => + firestore + .collection('contracts') + .where('createdTime', '>=', startTime) + .where('createdTime', '<', endTime) + .orderBy('createdTime', 'asc') + +export async function getDailyContracts( + startTime: number, + numberOfDays: number +) { + const query = getContractsQuery(startTime, startTime + DAY_MS * numberOfDays) + const contracts = await getValues<Contract>(query) + + const contractsByDay = range(0, numberOfDays).map(() => [] as Contract[]) + for (const contract of contracts) { + const dayIndex = Math.floor((contract.createdTime - startTime) / DAY_MS) + contractsByDay[dayIndex].push(contract) + } + + return contractsByDay +} + +const getUsersQuery = (startTime: number, endTime: number) => + firestore + .collection('users') + .where('createdTime', '>=', startTime) + .where('createdTime', '<', endTime) + .orderBy('createdTime', 'asc') + +export async function getDailyNewUsers( + startTime: number, + numberOfDays: number +) { + const query = getUsersQuery(startTime, startTime + DAY_MS * numberOfDays) + const users = await getValues<User>(query) + + const usersByDay = range(0, numberOfDays).map(() => [] as User[]) + for (const user of users) { + const dayIndex = Math.floor((user.createdTime - startTime) / DAY_MS) + usersByDay[dayIndex].push(user) + } + + return usersByDay +} + +export const updateStatsCore = async () => { + const today = Date.now() + const startDate = today - numberOfDays * DAY_MS + + log('Fetching data for stats update...') + const [dailyBets, dailyContracts, dailyComments, dailyNewUsers] = + await Promise.all([ + getDailyBets(startDate.valueOf(), numberOfDays), + getDailyContracts(startDate.valueOf(), numberOfDays), + getDailyComments(startDate.valueOf(), numberOfDays), + getDailyNewUsers(startDate.valueOf(), numberOfDays), + ]) + logMemory() + + const dailyBetCounts = dailyBets.map((bets) => bets.length) + const dailyContractCounts = dailyContracts.map( + (contracts) => contracts.length + ) + const dailyCommentCounts = dailyComments.map((comments) => comments.length) + + const dailyUserIds = zip(dailyContracts, dailyBets, dailyComments).map( + ([contracts, bets, comments]) => { + const creatorIds = (contracts ?? []).map((c) => c.creatorId) + const betUserIds = (bets ?? []).map((bet) => bet.userId) + const commentUserIds = (comments ?? []).map((comment) => comment.userId) + return uniq([...creatorIds, ...betUserIds, ...commentUserIds]) + } + ) + log( + `Fetched ${sum(dailyBetCounts)} bets, ${sum( + dailyContractCounts + )} contracts, ${sum(dailyComments)} comments, from ${sum( + dailyNewUsers + )} unique users.` + ) + + const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length) + + const weeklyActiveUsers = dailyUserIds.map((_, i) => { + const start = Math.max(0, i - 6) + const end = i + const uniques = new Set<string>() + for (let j = start; j <= end; j++) + dailyUserIds[j].forEach((userId) => uniques.add(userId)) + return uniques.size + }) + + const monthlyActiveUsers = dailyUserIds.map((_, i) => { + const start = Math.max(0, i - 29) + const end = i + const uniques = new Set<string>() + for (let j = start; j <= end; j++) + dailyUserIds[j].forEach((userId) => uniques.add(userId)) + return uniques.size + }) + + const weekOnWeekRetention = dailyUserIds.map((_userId, i) => { + const twoWeeksAgo = { + start: Math.max(0, i - 13), + end: Math.max(0, i - 7), + } + const lastWeek = { + start: Math.max(0, i - 6), + end: i, + } + + const activeTwoWeeksAgo = new Set<string>() + for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) { + dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId)) + } + const activeLastWeek = new Set<string>() + for (let j = lastWeek.start; j <= lastWeek.end; j++) { + dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId)) + } + const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) => + activeLastWeek.has(userId) ? 1 : 0 + ) + const retainedFrac = retainedCount / activeTwoWeeksAgo.size + return Math.round(retainedFrac * 100 * 100) / 100 + }) + + const monthlyRetention = dailyUserIds.map((_userId, i) => { + const twoMonthsAgo = { + start: Math.max(0, i - 60), + end: Math.max(0, i - 30), + } + const lastMonth = { + start: Math.max(0, i - 30), + end: i, + } + + const activeTwoMonthsAgo = new Set<string>() + for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) { + dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId)) + } + const activeLastMonth = new Set<string>() + for (let j = lastMonth.start; j <= lastMonth.end; j++) { + dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId)) + } + const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) => + activeLastMonth.has(userId) ? 1 : 0 + ) + const retainedFrac = retainedCount / activeTwoMonthsAgo.size + return Math.round(retainedFrac * 100 * 100) / 100 + }) + + const firstBetDict: { [userId: string]: number } = {} + for (let i = 0; i < dailyBets.length; i++) { + const bets = dailyBets[i] + for (const bet of bets) { + if (bet.userId in firstBetDict) continue + firstBetDict[bet.userId] = i + } + } + const weeklyActivationRate = dailyNewUsers.map((_, i) => { + const start = Math.max(0, i - 6) + const end = i + let activatedCount = 0 + let newUsers = 0 + for (let j = start; j <= end; j++) { + const userIds = dailyNewUsers[j].map((user) => user.id) + newUsers += userIds.length + for (const userId of userIds) { + const dayIndex = firstBetDict[userId] + if (dayIndex !== undefined && dayIndex <= end) { + activatedCount++ + } + } + } + const frac = activatedCount / (newUsers || 1) + return Math.round(frac * 100 * 100) / 100 + }) + const dailySignups = dailyNewUsers.map((users) => users.length) + + const dailyTopTenthActions = zip( + dailyContracts, + dailyBets, + dailyComments + ).map(([contracts, bets, comments]) => { + const userIds = concat( + contracts?.map((c) => c.creatorId) ?? [], + bets?.map((b) => b.userId) ?? [], + comments?.map((c) => c.userId) ?? [] + ) + const counts = Object.values(countBy(userIds)) + const sortedCounts = sortBy(counts, (count) => count).reverse() + if (sortedCounts.length === 0) return 0 + const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)] + return tenthPercentile + }) + const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => { + const start = Math.max(0, i - 6) + const end = i + return average(dailyTopTenthActions.slice(start, end)) + }) + const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => { + const start = Math.max(0, i - 29) + const end = i + return average(dailyTopTenthActions.slice(start, end)) + }) + + // Total mana divided by 100. + const dailyManaBet = dailyBets.map((bets) => { + return Math.round(sumBy(bets, (bet) => bet.amount) / 100) + }) + const weeklyManaBet = dailyManaBet.map((_, i) => { + const start = Math.max(0, i - 6) + const end = i + const total = sum(dailyManaBet.slice(start, end)) + if (end - start < 7) return (total * 7) / (end - start) + return total + }) + const monthlyManaBet = dailyManaBet.map((_, i) => { + const start = Math.max(0, i - 29) + const end = i + const total = sum(dailyManaBet.slice(start, end)) + const range = end - start + 1 + if (range < 30) return (total * 30) / range + return total + }) + + const statsData = { + startDate: startDate.valueOf(), + dailyActiveUsers, + weeklyActiveUsers, + monthlyActiveUsers, + dailyBetCounts, + dailyContractCounts, + dailyCommentCounts, + dailySignups, + weekOnWeekRetention, + weeklyActivationRate, + monthlyRetention, + topTenthActions: { + daily: dailyTopTenthActions, + weekly: weeklyTopTenthActions, + monthly: monthlyTopTenthActions, + }, + manaBet: { + daily: dailyManaBet, + weekly: weeklyManaBet, + monthly: monthlyManaBet, + }, + } + log('Computed stats: ', statsData) + await firestore.doc('stats/stats').set(statsData) +} + +export const updateStats = functions + .runWith({ memory: '1GB', timeoutSeconds: 540 }) + .pubsub.schedule('every 60 minutes') + .onRun(updateStatsCore) diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 5311cee4..c442ff73 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -5,7 +5,7 @@ import { where, orderBy, } from 'firebase/firestore' -import { range, uniq } from 'lodash' +import { uniq } from 'lodash' import { db } from './init' import { Bet } from 'common/bet' @@ -136,24 +136,3 @@ export function withoutAnteBets(contract: Contract, bets?: Bet[]) { return bets?.filter((bet) => !bet.isAnte) ?? [] } - -const getBetsQuery = (startTime: number, endTime: number) => - query( - collectionGroup(db, 'bets'), - where('createdTime', '>=', startTime), - where('createdTime', '<', endTime), - orderBy('createdTime', 'asc') - ) - -export async function getDailyBets(startTime: number, numberOfDays: number) { - const query = getBetsQuery(startTime, startTime + DAY_IN_MS * numberOfDays) - const bets = await getValues<Bet>(query) - - const betsByDay = range(0, numberOfDays).map(() => [] as Bet[]) - for (const bet of bets) { - const dayIndex = Math.floor((bet.createdTime - startTime) / DAY_IN_MS) - betsByDay[dayIndex].push(bet) - } - - return betsByDay -} diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index 9d418507..3093f764 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -7,7 +7,6 @@ import { setDoc, where, } from 'firebase/firestore' -import { range } from 'lodash' import { getValues, listenForValues } from './utils' import { db } from './init' @@ -136,33 +135,6 @@ export function listenForRecentComments( return listenForValues<Comment>(recentCommentsQuery, setComments) } -const getCommentsQuery = (startTime: number, endTime: number) => - query( - collectionGroup(db, 'comments'), - where('createdTime', '>=', startTime), - where('createdTime', '<', endTime), - orderBy('createdTime', 'asc') - ) - -export async function getDailyComments( - startTime: number, - numberOfDays: number -) { - const query = getCommentsQuery( - startTime, - startTime + DAY_IN_MS * numberOfDays - ) - const comments = await getValues<Comment>(query) - - const commentsByDay = range(0, numberOfDays).map(() => [] as Comment[]) - for (const comment of comments) { - const dayIndex = Math.floor((comment.createdTime - startTime) / DAY_IN_MS) - commentsByDay[dayIndex].push(comment) - } - - return commentsByDay -} - const getUsersCommentsQuery = (userId: string) => query( collectionGroup(db, 'comments'), diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 184135f0..f177d841 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -14,7 +14,7 @@ import { limit, startAfter, } from 'firebase/firestore' -import { range, sortBy, sum } from 'lodash' +import { sortBy, sum } from 'lodash' import { app } from './init' import { getValues, listenForValue, listenForValues } from './utils' @@ -303,35 +303,6 @@ export async function getClosingSoonContracts() { ) } -const getContractsQuery = (startTime: number, endTime: number) => - query( - collection(db, 'contracts'), - where('createdTime', '>=', startTime), - where('createdTime', '<', endTime), - orderBy('createdTime', 'asc') - ) - -const DAY_IN_MS = 24 * 60 * 60 * 1000 - -export async function getDailyContracts( - startTime: number, - numberOfDays: number -) { - const query = getContractsQuery( - startTime, - startTime + DAY_IN_MS * numberOfDays - ) - const contracts = await getValues<Contract>(query) - - const contractsByDay = range(0, numberOfDays).map(() => [] as Contract[]) - for (const contract of contracts) { - const dayIndex = Math.floor((contract.createdTime - startTime) / DAY_IN_MS) - contractsByDay[dayIndex].push(contract) - } - - return contractsByDay -} - export async function getRecentBetsAndComments(contract: Contract) { const contractDoc = doc(db, 'contracts', contract.id) diff --git a/web/lib/firebase/stats.ts b/web/lib/firebase/stats.ts new file mode 100644 index 00000000..994645ca --- /dev/null +++ b/web/lib/firebase/stats.ts @@ -0,0 +1,15 @@ +import { + CollectionReference, + doc, + collection, + getDoc, +} from 'firebase/firestore' +import { db } from 'web/lib/firebase/init' +import { Stats } from 'common/stats' + +const statsCollection = collection(db, 'stats') as CollectionReference<Stats> +const statsDoc = doc(statsCollection, 'stats') + +export const getStats = async () => { + return (await getDoc(statsDoc)).data() +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index a3bba98a..e9fcbb93 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -21,13 +21,12 @@ import { GoogleAuthProvider, signInWithPopup, } from 'firebase/auth' -import { range, throttle, zip } from 'lodash' +import { throttle, zip } from 'lodash' import { app } from './init' import { PortfolioMetrics, PrivateUser, User } from 'common/user' import { createUser } from './fn-call' import { getValue, getValues, listenForValue, listenForValues } from './utils' -import { DAY_MS } from 'common/util/time' import { feed } from 'common/feed' import { CATEGORY_LIST } from 'common/categories' import { safeLocalStorage } from '../util/local' @@ -214,30 +213,6 @@ export function getUsers() { return getValues<User>(collection(db, 'users')) } -const getUsersQuery = (startTime: number, endTime: number) => - query( - collection(db, 'users'), - where('createdTime', '>=', startTime), - where('createdTime', '<', endTime), - orderBy('createdTime', 'asc') - ) - -export async function getDailyNewUsers( - startTime: number, - numberOfDays: number -) { - const query = getUsersQuery(startTime, startTime + DAY_MS * numberOfDays) - const users = await getValues<User>(query) - - const usersByDay = range(0, numberOfDays).map(() => [] as User[]) - for (const user of users) { - const dayIndex = Math.floor((user.createdTime - startTime) / DAY_MS) - usersByDay[dayIndex].push(user) - } - - return usersByDay -} - export async function getUserFeed(userId: string) { const feedDoc = doc(db, 'private-users', userId, 'cache', 'feed') const userFeed = await getValue<{ diff --git a/web/pages/embed/analytics.tsx b/web/pages/embed/analytics.tsx index be850fdf..68101d7c 100644 --- a/web/pages/embed/analytics.tsx +++ b/web/pages/embed/analytics.tsx @@ -1,18 +1,21 @@ +import { useState, useEffect } from 'react' import { Col } from 'web/components/layout/col' import { Spacer } from 'web/components/layout/spacer' -import { fromPropz } from 'web/hooks/use-propz' -import Analytics, { - CustomAnalytics, - FirebaseAnalytics, - getStaticPropz, -} from '../stats' +import { CustomAnalytics, FirebaseAnalytics } from '../stats' +import { getStats } from 'web/lib/firebase/stats' +import { Stats } from 'common/stats' -export const getStaticProps = fromPropz(getStaticPropz) - -export default function AnalyticsEmbed(props: Parameters<typeof Analytics>[0]) { +export default function AnalyticsEmbed() { + const [stats, setStats] = useState<Stats | undefined>(undefined) + useEffect(() => { + getStats().then(setStats) + }, []) + if (stats == null) { + return <></> + } return ( <Col className="w-full bg-white px-2"> - <CustomAnalytics {...props} /> + <CustomAnalytics {...stats} /> <Spacer h={8} /> <FirebaseAnalytics /> </Col> diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index 401749b6..c81bc3ff 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -1,5 +1,5 @@ import dayjs from 'dayjs' -import { zip, uniq, sumBy, concat, countBy, sortBy, sum } from 'lodash' +import { useEffect, useState } from 'react' import { DailyCountChart, DailyPercentChart, @@ -9,265 +9,18 @@ import { Spacer } from 'web/components/layout/spacer' import { Tabs } from 'web/components/layout/tabs' import { Page } from 'web/components/page' import { Title } from 'web/components/title' -import { fromPropz, usePropz } from 'web/hooks/use-propz' -import { getDailyBets } from 'web/lib/firebase/bets' -import { getDailyComments } from 'web/lib/firebase/comments' -import { getDailyContracts } from 'web/lib/firebase/contracts' -import { getDailyNewUsers } from 'web/lib/firebase/users' import { SiteLink } from 'web/components/site-link' import { Linkify } from 'web/components/linkify' -import { average } from 'common/util/math' +import { getStats } from 'web/lib/firebase/stats' +import { Stats } from 'common/stats' -export const getStaticProps = fromPropz(getStaticPropz) -export async function getStaticPropz() { - const numberOfDays = 90 - const today = dayjs(dayjs().format('YYYY-MM-DD')) - // Convert from UTC midnight to PT midnight. - .add(7, 'hours') - - const startDate = today.subtract(numberOfDays, 'day') - - const [dailyBets, dailyContracts, dailyComments, dailyNewUsers] = - await Promise.all([ - getDailyBets(startDate.valueOf(), numberOfDays), - getDailyContracts(startDate.valueOf(), numberOfDays), - getDailyComments(startDate.valueOf(), numberOfDays), - getDailyNewUsers(startDate.valueOf(), numberOfDays), - ]) - - const dailyBetCounts = dailyBets.map((bets) => bets.length) - const dailyContractCounts = dailyContracts.map( - (contracts) => contracts.length - ) - const dailyCommentCounts = dailyComments.map((comments) => comments.length) - - const dailyUserIds = zip(dailyContracts, dailyBets, dailyComments).map( - ([contracts, bets, comments]) => { - const creatorIds = (contracts ?? []).map((c) => c.creatorId) - const betUserIds = (bets ?? []).map((bet) => bet.userId) - const commentUserIds = (comments ?? []).map((comment) => comment.userId) - return uniq([...creatorIds, ...betUserIds, ...commentUserIds]) - } - ) - - const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length) - - const weeklyActiveUsers = dailyUserIds.map((_, i) => { - const start = Math.max(0, i - 6) - const end = i - const uniques = new Set<string>() - for (let j = start; j <= end; j++) - dailyUserIds[j].forEach((userId) => uniques.add(userId)) - return uniques.size - }) - - const monthlyActiveUsers = dailyUserIds.map((_, i) => { - const start = Math.max(0, i - 29) - const end = i - const uniques = new Set<string>() - for (let j = start; j <= end; j++) - dailyUserIds[j].forEach((userId) => uniques.add(userId)) - return uniques.size - }) - - const weekOnWeekRetention = dailyUserIds.map((_userId, i) => { - const twoWeeksAgo = { - start: Math.max(0, i - 13), - end: Math.max(0, i - 7), - } - const lastWeek = { - start: Math.max(0, i - 6), - end: i, - } - - const activeTwoWeeksAgo = new Set<string>() - for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) { - dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId)) - } - const activeLastWeek = new Set<string>() - for (let j = lastWeek.start; j <= lastWeek.end; j++) { - dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId)) - } - const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) => - activeLastWeek.has(userId) ? 1 : 0 - ) - const retainedFrac = retainedCount / activeTwoWeeksAgo.size - return Math.round(retainedFrac * 100 * 100) / 100 - }) - - const monthlyRetention = dailyUserIds.map((_userId, i) => { - const twoMonthsAgo = { - start: Math.max(0, i - 60), - end: Math.max(0, i - 30), - } - const lastMonth = { - start: Math.max(0, i - 30), - end: i, - } - - const activeTwoMonthsAgo = new Set<string>() - for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) { - dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId)) - } - const activeLastMonth = new Set<string>() - for (let j = lastMonth.start; j <= lastMonth.end; j++) { - dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId)) - } - const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) => - activeLastMonth.has(userId) ? 1 : 0 - ) - const retainedFrac = retainedCount / activeTwoMonthsAgo.size - return Math.round(retainedFrac * 100 * 100) / 100 - }) - - const firstBetDict: { [userId: string]: number } = {} - for (let i = 0; i < dailyBets.length; i++) { - const bets = dailyBets[i] - for (const bet of bets) { - if (bet.userId in firstBetDict) continue - firstBetDict[bet.userId] = i - } - } - const weeklyActivationRate = dailyNewUsers.map((_, i) => { - const start = Math.max(0, i - 6) - const end = i - let activatedCount = 0 - let newUsers = 0 - for (let j = start; j <= end; j++) { - const userIds = dailyNewUsers[j].map((user) => user.id) - newUsers += userIds.length - for (const userId of userIds) { - const dayIndex = firstBetDict[userId] - if (dayIndex !== undefined && dayIndex <= end) { - activatedCount++ - } - } - } - const frac = activatedCount / (newUsers || 1) - return Math.round(frac * 100 * 100) / 100 - }) - const dailySignups = dailyNewUsers.map((users) => users.length) - - const dailyTopTenthActions = zip( - dailyContracts, - dailyBets, - dailyComments - ).map(([contracts, bets, comments]) => { - const userIds = concat( - contracts?.map((c) => c.creatorId) ?? [], - bets?.map((b) => b.userId) ?? [], - comments?.map((c) => c.userId) ?? [] - ) - const counts = Object.values(countBy(userIds)) - const sortedCounts = sortBy(counts, (count) => count).reverse() - if (sortedCounts.length === 0) return 0 - const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)] - return tenthPercentile - }) - const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => { - const start = Math.max(0, i - 6) - const end = i - return average(dailyTopTenthActions.slice(start, end)) - }) - const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => { - const start = Math.max(0, i - 29) - const end = i - return average(dailyTopTenthActions.slice(start, end)) - }) - - // Total mana divided by 100. - const dailyManaBet = dailyBets.map((bets) => { - return Math.round(sumBy(bets, (bet) => bet.amount) / 100) - }) - const weeklyManaBet = dailyManaBet.map((_, i) => { - const start = Math.max(0, i - 6) - const end = i - const total = sum(dailyManaBet.slice(start, end)) - if (end - start < 7) return (total * 7) / (end - start) - return total - }) - const monthlyManaBet = dailyManaBet.map((_, i) => { - const start = Math.max(0, i - 29) - const end = i - const total = sum(dailyManaBet.slice(start, end)) - const range = end - start + 1 - if (range < 30) return (total * 30) / range - return total - }) - - return { - props: { - startDate: startDate.valueOf(), - dailyActiveUsers, - weeklyActiveUsers, - monthlyActiveUsers, - dailyBetCounts, - dailyContractCounts, - dailyCommentCounts, - dailySignups, - weekOnWeekRetention, - weeklyActivationRate, - monthlyRetention, - topTenthActions: { - daily: dailyTopTenthActions, - weekly: weeklyTopTenthActions, - monthly: monthlyTopTenthActions, - }, - manaBet: { - daily: dailyManaBet, - weekly: weeklyManaBet, - monthly: monthlyManaBet, - }, - }, - revalidate: 60 * 60, // Regenerate after an hour - } -} - -export default function Analytics(props: { - startDate: number - dailyActiveUsers: number[] - weeklyActiveUsers: number[] - monthlyActiveUsers: number[] - dailyBetCounts: number[] - dailyContractCounts: number[] - dailyCommentCounts: number[] - dailySignups: number[] - weekOnWeekRetention: number[] - monthlyRetention: number[] - weeklyActivationRate: number[] - topTenthActions: { - daily: number[] - weekly: number[] - monthly: number[] - } - manaBet: { - daily: number[] - weekly: number[] - monthly: number[] - } -}) { - props = usePropz(props, getStaticPropz) ?? { - startDate: 0, - dailyActiveUsers: [], - weeklyActiveUsers: [], - monthlyActiveUsers: [], - dailyBetCounts: [], - dailyContractCounts: [], - dailyCommentCounts: [], - dailySignups: [], - weekOnWeekRetention: [], - monthlyRetention: [], - weeklyActivationRate: [], - topTenthActions: { - daily: [], - weekly: [], - monthly: [], - }, - manaBet: { - daily: [], - weekly: [], - monthly: [], - }, +export default function Analytics() { + const [stats, setStats] = useState<Stats | undefined>(undefined) + useEffect(() => { + getStats().then(setStats) + }, []) + if (stats == null) { + return <></> } return ( <Page> @@ -275,7 +28,7 @@ export default function Analytics(props: { tabs={[ { title: 'Activity', - content: <CustomAnalytics {...props} />, + content: <CustomAnalytics {...stats} />, }, { title: 'Market Stats', From 3b6ba76db65f15cf8a5d3b794c116f4f18cbe73e Mon Sep 17 00:00:00 2001 From: Ben Congdon <ben@congdon.dev> Date: Sun, 26 Jun 2022 17:00:02 -0700 Subject: [PATCH 039/220] Add market liquidity addition events to bets feed (#578) * Add liquidity events to bets feed * Use larger avatar for liquidity feed items --- web/components/contract/contract-tabs.tsx | 7 +- web/components/feed/activity-items.ts | 30 +++++++- web/components/feed/contract-activity.tsx | 6 +- web/components/feed/feed-items.tsx | 3 + web/components/feed/feed-liquidity.tsx | 85 +++++++++++++++++++++++ web/pages/[username]/[contractSlug].tsx | 4 ++ 6 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 web/components/feed/feed-liquidity.tsx diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index cb4a3537..e68e59b9 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -8,15 +8,17 @@ import { Spacer } from '../layout/spacer' import { Tabs } from '../layout/tabs' import { Col } from '../layout/col' import { CommentTipMap } from 'web/hooks/use-tip-txns' +import { LiquidityProvision } from 'common/liquidity-provision' export function ContractTabs(props: { contract: Contract user: User | null | undefined bets: Bet[] + liquidityProvisions: LiquidityProvision[] comments: Comment[] tips: CommentTipMap }) { - const { contract, user, bets, comments, tips } = props + const { contract, user, bets, comments, tips, liquidityProvisions } = props const { outcomeType } = contract const userBets = user && bets.filter((bet) => bet.userId === user.id) @@ -25,6 +27,7 @@ export function ContractTabs(props: { <ContractActivity contract={contract} bets={bets} + liquidityProvisions={liquidityProvisions} comments={comments} tips={tips} user={user} @@ -38,6 +41,7 @@ export function ContractTabs(props: { <ContractActivity contract={contract} bets={bets} + liquidityProvisions={liquidityProvisions} comments={comments} tips={tips} user={user} @@ -55,6 +59,7 @@ export function ContractTabs(props: { <ContractActivity contract={contract} bets={bets} + liquidityProvisions={liquidityProvisions} comments={comments} tips={tips} user={user} diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index 346314a3..68dfcb2d 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -7,6 +7,7 @@ import { Comment } from 'common/comment' import { Contract, FreeResponseContract } from 'common/contract' import { User } from 'common/user' import { CommentTipMap } from 'web/hooks/use-tip-txns' +import { LiquidityProvision } from 'common/liquidity-provision' export type ActivityItem = | DescriptionItem @@ -17,6 +18,7 @@ export type ActivityItem = | ResolveItem | CommentInputItem | CommentThreadItem + | LiquidityItem type BaseActivityItem = { id: string @@ -72,6 +74,14 @@ export type ResolveItem = BaseActivityItem & { type: 'resolve' } +export type LiquidityItem = BaseActivityItem & { + type: 'liquidity' + liquidity: LiquidityProvision + hideOutcome: boolean + smallAvatar: boolean + hideComment?: boolean +} + function getAnswerAndCommentInputGroups( contract: FreeResponseContract, bets: Bet[], @@ -139,6 +149,7 @@ export function getSpecificContractActivityItems( contract: Contract, bets: Bet[], comments: Comment[], + liquidityProvisions: LiquidityProvision[], tips: CommentTipMap, user: User | null | undefined, options: { @@ -146,7 +157,7 @@ export function getSpecificContractActivityItems( } ) { const { mode } = options - const items = [] as ActivityItem[] + let items = [] as ActivityItem[] switch (mode) { case 'bets': @@ -163,6 +174,23 @@ export function getSpecificContractActivityItems( hideComment: true, })) ) + items.push( + ...liquidityProvisions.map((liquidity) => ({ + type: 'liquidity' as const, + id: liquidity.id, + contract, + liquidity, + hideOutcome: false, + smallAvatar: false, + })) + ) + items = sortBy(items, (item) => + item.type === 'bet' + ? item.bet.createdTime + : item.type === 'liquidity' + ? item.liquidity.createdTime + : undefined + ) break case 'comments': { diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index 9ffc8717..8f728d39 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -8,11 +8,13 @@ import { FeedItems } from './feed-items' import { User } from 'common/user' import { useContractWithPreload } from 'web/hooks/use-contract' import { CommentTipMap } from 'web/hooks/use-tip-txns' +import { LiquidityProvision } from 'common/liquidity-provision' export function ContractActivity(props: { contract: Contract bets: Bet[] comments: Comment[] + liquidityProvisions: LiquidityProvision[] tips: CommentTipMap user: User | null | undefined mode: 'comments' | 'bets' | 'free-response-comment-answer-groups' @@ -20,7 +22,8 @@ export function ContractActivity(props: { className?: string betRowClassName?: string }) { - const { user, mode, tips, className, betRowClassName } = props + const { user, mode, tips, className, betRowClassName, liquidityProvisions } = + props const contract = useContractWithPreload(props.contract) ?? props.contract @@ -33,6 +36,7 @@ export function ContractActivity(props: { contract, bets, comments, + liquidityProvisions, tips, user, { mode } diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index ef0d133b..312190e4 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -35,6 +35,7 @@ import { } from 'web/components/feed/feed-comments' import { FeedBet } from 'web/components/feed/feed-bets' import { NumericContract } from 'common/contract' +import { FeedLiquidity } from './feed-liquidity' export function FeedItems(props: { contract: Contract @@ -83,6 +84,8 @@ export function FeedItem(props: { item: ActivityItem }) { return <FeedDescription {...item} /> case 'bet': return <FeedBet {...item} /> + case 'liquidity': + return <FeedLiquidity {...item} /> case 'answergroup': return <FeedAnswerCommentGroup {...item} /> case 'close': diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx new file mode 100644 index 00000000..cfce3861 --- /dev/null +++ b/web/components/feed/feed-liquidity.tsx @@ -0,0 +1,85 @@ +import dayjs from 'dayjs' +import { User } from 'common/user' +import { useUser, useUserById } from 'web/hooks/use-user' +import { Row } from 'web/components/layout/row' +import { Avatar, EmptyAvatar } from 'web/components/avatar' +import clsx from 'clsx' +import { formatMoney } from 'common/util/format' +import { RelativeTimestamp } from 'web/components/relative-timestamp' +import React from 'react' +import { UserLink } from '../user-page' +import { LiquidityProvision } from 'common/liquidity-provision' + +export function FeedLiquidity(props: { + liquidity: LiquidityProvision + smallAvatar: boolean +}) { + const { liquidity, smallAvatar } = props + const { userId, createdTime } = liquidity + + const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01') + // eslint-disable-next-line react-hooks/rules-of-hooks + const bettor = isBeforeJune2022 ? undefined : useUserById(userId) + + const user = useUser() + const isSelf = user?.id === userId + + return ( + <> + <Row className={'flex w-full gap-2 pt-3'}> + {isSelf ? ( + <Avatar + className={clsx(smallAvatar && 'ml-1')} + size={smallAvatar ? 'sm' : undefined} + avatarUrl={user.avatarUrl} + username={user.username} + /> + ) : bettor ? ( + <Avatar + className={clsx(smallAvatar && 'ml-1')} + size={smallAvatar ? 'sm' : undefined} + avatarUrl={bettor.avatarUrl} + username={bettor.username} + /> + ) : ( + <div className="relative px-1"> + <EmptyAvatar /> + </div> + )} + <div className={'min-w-0 flex-1 py-1.5'}> + <LiquidityStatusText + liquidity={liquidity} + isSelf={isSelf} + bettor={bettor} + /> + </div> + </Row> + </> + ) +} + +export function LiquidityStatusText(props: { + liquidity: LiquidityProvision + isSelf: boolean + bettor?: User +}) { + const { liquidity, bettor, isSelf } = props + const { amount, createdTime } = liquidity + + // TODO: Withdrawn liquidity will never be shown, since liquidity amounts currently are zeroed out upon withdrawal. + const bought = amount >= 0 ? 'added' : 'withdrew' + const money = formatMoney(Math.abs(amount)) + + return ( + <div className="text-sm text-gray-500"> + {bettor ? ( + <UserLink name={bettor.name} username={bettor.username} /> + ) : ( + <span>{isSelf ? 'You' : 'A trader'}</span> + )}{' '} + {bought} {money} + {' of liquidity'} + <RelativeTimestamp time={createdTime} /> + </div> + ) +} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 099ba57e..24982b4f 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -42,6 +42,7 @@ import { useBets } from 'web/hooks/use-bets' import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' +import { useLiquidity } from 'web/hooks/use-liquidity' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -117,6 +118,8 @@ export function ContractPageContent( }) const bets = useBets(contract.id) ?? props.bets + const liquidityProvisions = + useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? [] // Sort for now to see if bug is fixed. comments.sort((c1, c2) => c1.createdTime - c2.createdTime) @@ -233,6 +236,7 @@ export function ContractPageContent( <ContractTabs contract={contract} user={user} + liquidityProvisions={liquidityProvisions} bets={bets} tips={tips} comments={comments} From c1765ca0cb628b40f5c8970604f33f1ed63051c0 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 26 Jun 2022 19:44:10 -0500 Subject: [PATCH 040/220] Use green for FR (and numeric) cards --- web/components/contract/quick-bet.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 5dbb0fb5..9ee8b165 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -325,14 +325,6 @@ export function getColor(contract: Contract) { ) } - if (contract.outcomeType === 'NUMERIC') { - return 'blue-400' - } - - if (contract.outcomeType === 'FREE_RESPONSE') { - return 'blue-400' - } - if ((contract.closeTime ?? Infinity) < Date.now()) { return 'gray-400' } From 54356b8d2f82c25b4add64c77bcaffd19f0f45b3 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Mon, 27 Jun 2022 11:18:15 -0500 Subject: [PATCH 041/220] Remove undo. Show full tip amount. Linear scale. (#573) --- web/components/tipper.tsx | 47 +++++++++++++++------------------------ 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index 3b4d3603..64bad4eb 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -7,8 +7,8 @@ import clsx from 'clsx' import { Comment } from 'common/comment' import { User } from 'common/user' import { formatMoney } from 'common/util/format' -import { debounce, sumBy } from 'lodash' -import { useEffect, useMemo, useRef, useState } from 'react' +import { debounce, sum } from 'lodash' +import { useEffect, useRef, useState } from 'react' import { CommentTips } from 'web/hooks/use-tip-txns' import { useUser } from 'web/hooks/use-user' import { transact } from 'web/lib/firebase/fn-call' @@ -16,33 +16,24 @@ import { track } from 'web/lib/service/analytics' import { Row } from './layout/row' import { Tooltip } from './tooltip' -// xth triangle number * 5 = 5 + 10 + 15 + ... + (x * 5) -const quad = (x: number) => (5 / 2) * x * (x + 1) - -// inverse (see https://math.stackexchange.com/questions/2041988/how-to-get-inverse-of-formula-for-sum-of-integers-from-1-to-nsee ) -const invQuad = (y: number) => Math.sqrt((2 / 5) * y + 1 / 4) - 1 / 2 - export function Tipper(prop: { comment: Comment; tips: CommentTips }) { const { comment, tips } = prop const me = useUser() const myId = me?.id ?? '' - const savedTip = tips[myId] as number | undefined + const savedTip = tips[myId] ?? 0 - // optimistically increase the tip count, but debounce the update - const [localTip, setLocalTip] = useState(savedTip ?? 0) + const [localTip, setLocalTip] = useState(savedTip) + // listen for user being set const initialized = useRef(false) useEffect(() => { - if (savedTip && !initialized.current) { - setLocalTip(savedTip) + if (tips[myId] && !initialized.current) { + setLocalTip(tips[myId]) initialized.current = true } - }, [savedTip]) + }, [tips, myId]) - const score = useMemo(() => { - const tipVals = Object.values({ ...tips, [myId]: localTip }) - return sumBy(tipVals, invQuad) - }, [localTip, tips, myId]) + const total = sum(Object.values(tips)) - savedTip + localTip // declare debounced function only on first render const [saveTip] = useState(() => @@ -80,7 +71,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { const changeTip = (tip: number) => { setLocalTip(tip) - me && saveTip(me, tip - (savedTip ?? 0)) + me && saveTip(me, tip - savedTip) } return ( @@ -88,13 +79,13 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { <DownTip value={localTip} onChange={changeTip} - disabled={!me || localTip <= 0} + disabled={!me || localTip <= savedTip} /> - <span className="font-bold">{Math.floor(score)} </span> + <span className="font-bold">{Math.floor(total)}</span> <UpTip value={localTip} onChange={changeTip} - disabled={!me || me.id === comment.userId} + disabled={!me || me.id === comment.userId || me.balance < localTip + 5} /> {localTip === 0 ? ( '' @@ -118,16 +109,15 @@ function DownTip(prop: { disabled?: boolean }) { const { onChange, value, disabled } = prop - const marginal = 5 * invQuad(value) return ( <Tooltip className="tooltip-bottom" - text={!disabled && `Refund ${formatMoney(marginal)}`} + text={!disabled && `-${formatMoney(5)}`} > <button className="flex h-max items-center hover:text-red-600 disabled:text-gray-300" disabled={disabled} - onClick={() => onChange(value - marginal)} + onClick={() => onChange(value - 5)} > <ChevronLeftIcon className="h-6 w-6" /> </button> @@ -141,19 +131,18 @@ function UpTip(prop: { disabled?: boolean }) { const { onChange, value, disabled } = prop - const marginal = 5 * invQuad(value) + 5 return ( <Tooltip className="tooltip-bottom" - text={!disabled && `Tip ${formatMoney(marginal)}`} + text={!disabled && `Tip ${formatMoney(5)}`} > <button className="hover:text-primary flex h-max items-center disabled:text-gray-300" disabled={disabled} - onClick={() => onChange(value + marginal)} + onClick={() => onChange(value + 5)} > - {value >= quad(2) ? ( + {value >= 10 ? ( <ChevronDoubleRightIcon className="text-primary mx-1 h-6 w-6" /> ) : value > 0 ? ( <ChevronRightIcon className="text-primary h-6 w-6" /> From 1e904f567a0e37390ae7ce8a9ef8c25b31217f49 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 27 Jun 2022 12:30:22 -0500 Subject: [PATCH 042/220] Revert "Use Typescript project references, improve functions build/deploy (#575)" This reverts commit 4edad9f19b28fa08f5cffa3306b7249f807bf40e. --- .github/workflows/check.yml | 4 ++-- common/.gitignore | 5 +++-- common/tsconfig.json | 2 -- firebase.json | 4 ++-- functions/.gitignore | 6 ++---- functions/package.json | 5 ++--- functions/tsconfig.json | 8 +------- web/next.config.js | 3 --- web/tsconfig.json | 8 +------- 9 files changed, 13 insertions(+), 32 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 49e988de..dcf81c44 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -48,8 +48,8 @@ jobs: - name: Run Typescript checker on web client if: ${{ success() || failure() }} working-directory: web - run: tsc -b -v --pretty + run: tsc --pretty --project tsconfig.json --noEmit - name: Run Typescript checker on cloud functions if: ${{ success() || failure() }} working-directory: functions - run: tsc -b -v --pretty + run: tsc --pretty --project tsconfig.json --noEmit diff --git a/common/.gitignore b/common/.gitignore index 11320851..e0ba0181 100644 --- a/common/.gitignore +++ b/common/.gitignore @@ -1,5 +1,6 @@ # Compiled JavaScript files -lib/ +lib/**/*.js +lib/**/*.js.map # TypeScript v1 declaration files typings/ @@ -9,4 +10,4 @@ node_modules/ package-lock.json ui-debug.log -firebase-debug.log +firebase-debug.log \ No newline at end of file diff --git a/common/tsconfig.json b/common/tsconfig.json index 62a5c745..158a5218 100644 --- a/common/tsconfig.json +++ b/common/tsconfig.json @@ -1,8 +1,6 @@ { "compilerOptions": { "baseUrl": "../", - "composite": true, - "module": "commonjs", "moduleResolution": "node", "noImplicitReturns": true, "outDir": "lib", diff --git a/firebase.json b/firebase.json index 25f9b61f..de1e19b7 100644 --- a/firebase.json +++ b/firebase.json @@ -1,8 +1,8 @@ { "functions": { - "predeploy": "cd functions && yarn build", + "predeploy": "npm --prefix \"$RESOURCE_DIR\" run build", "runtime": "nodejs16", - "source": "functions/dist" + "source": "functions" }, "firestore": { "rules": "firestore.rules", diff --git a/functions/.gitignore b/functions/.gitignore index 2aeae30c..7aeaedd4 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -2,11 +2,9 @@ .env* .runtimeconfig.json -# GCP deployment artifact -dist/ - # Compiled JavaScript files -lib/ +lib/**/*.js +lib/**/*.js.map # TypeScript v1 declaration files typings/ diff --git a/functions/package.json b/functions/package.json index 45ddcac2..7b5c30b0 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,8 +5,7 @@ "firestore": "dev-mantic-markets.appspot.com" }, "scripts": { - "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist", - "compile": "tsc -b", + "build": "tsc", "watch": "tsc -w", "shell": "yarn build && firebase functions:shell", "start": "yarn shell", @@ -19,7 +18,7 @@ "db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/", "verify": "(cd .. && yarn verify)" }, - "main": "functions/src/index.js", + "main": "lib/functions/src/index.js", "dependencies": { "@amplitude/node": "1.10.0", "fetch": "1.1.0", diff --git a/functions/tsconfig.json b/functions/tsconfig.json index 9496b9cb..e183bb44 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "baseUrl": "../", - "composite": true, "module": "commonjs", "noImplicitReturns": true, "outDir": "lib", @@ -9,11 +8,6 @@ "strict": true, "target": "es2017" }, - "references": [ - { - "path": "../common" - } - ], "compileOnSave": true, - "include": ["src"] + "include": ["src", "../common/**/*.ts"] } diff --git a/web/next.config.js b/web/next.config.js index 28a009ad..56f643d3 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -4,9 +4,6 @@ const API_DOCS_URL = 'https://docs.manifold.markets/api' module.exports = { staticPageGenerationTimeout: 600, // e.g. stats page reactStrictMode: true, - typescript: { - ignoreBuildErrors: true, - }, experimental: { externalDir: true, optimizeCss: true, diff --git a/web/tsconfig.json b/web/tsconfig.json index 7fbb2472..96cf1311 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "target": "es5", - "composite": true, "baseUrl": "../", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, @@ -17,15 +16,10 @@ "jsx": "preserve", "incremental": true }, - "references": [ - { - "path": "../common" - } - ], "watchOptions": { "excludeDirectories": [".next"] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "../common/**/*.ts"], "exclude": ["node_modules"] } From 4107d5fedb0fcea044c5c50de248e36afda9e955 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 27 Jun 2022 14:40:40 -0500 Subject: [PATCH 043/220] Fix weird layout on refreshing create page --- web/pages/create.tsx | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index e4cba4e0..ebbb6f65 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -26,6 +26,7 @@ import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' import { track } from 'web/lib/service/analytics' import { GroupSelector } from 'web/components/groups/group-selector' import { CATEGORIES } from 'common/categories' +import { User } from 'common/user' export default function Create() { const [question, setQuestion] = useState('') @@ -33,7 +34,13 @@ export default function Create() { const router = useRouter() const { groupId } = router.query as { groupId: string } useTracking('view create page') - if (!router.isReady) return <div /> + const creator = useUser() + + useEffect(() => { + if (creator === null) router.push('/') + }, [creator, router]) + + if (!router.isReady || !creator) return <div /> return ( <Page> @@ -58,7 +65,11 @@ export default function Create() { </div> </form> <Spacer h={6} /> - <NewContract question={question} groupId={groupId} /> + <NewContract + question={question} + groupId={groupId} + creator={creator} + /> </div> </div> </Page> @@ -66,14 +77,12 @@ export default function Create() { } // Allow user to create a new contract -export function NewContract(props: { question: string; groupId?: string }) { - const { question, groupId } = props - const creator = useUser() - - useEffect(() => { - if (creator === null) router.push('/') - }, [creator]) - +export function NewContract(props: { + creator: User + question: string + groupId?: string +}) { + const { creator, question, groupId } = props const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY') const [initialProb] = useState(50) const [minString, setMinString] = useState('') From 0b585d1c9897db273acfa19c019dc700a4c4f9f8 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Mon, 27 Jun 2022 13:32:24 -0700 Subject: [PATCH 044/220] Typescript project references take 2 (#586) * More liberal .gitignores on TS output directories * Use project references for Typescript functions project * Use /dist dir for Cloud Functions deployment payload * Fix Github actions functions tsc job --- .github/workflows/check.yml | 2 +- common/.gitignore | 5 ++--- common/tsconfig.json | 2 ++ firebase.json | 4 ++-- functions/.gitignore | 6 ++++-- functions/package.json | 5 +++-- functions/tsconfig.json | 8 +++++++- web/tsconfig.json | 1 - 8 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index dcf81c44..e441edcf 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -52,4 +52,4 @@ jobs: - name: Run Typescript checker on cloud functions if: ${{ success() || failure() }} working-directory: functions - run: tsc --pretty --project tsconfig.json --noEmit + run: tsc -b -v --pretty diff --git a/common/.gitignore b/common/.gitignore index e0ba0181..11320851 100644 --- a/common/.gitignore +++ b/common/.gitignore @@ -1,6 +1,5 @@ # Compiled JavaScript files -lib/**/*.js -lib/**/*.js.map +lib/ # TypeScript v1 declaration files typings/ @@ -10,4 +9,4 @@ node_modules/ package-lock.json ui-debug.log -firebase-debug.log \ No newline at end of file +firebase-debug.log diff --git a/common/tsconfig.json b/common/tsconfig.json index 158a5218..62a5c745 100644 --- a/common/tsconfig.json +++ b/common/tsconfig.json @@ -1,6 +1,8 @@ { "compilerOptions": { "baseUrl": "../", + "composite": true, + "module": "commonjs", "moduleResolution": "node", "noImplicitReturns": true, "outDir": "lib", diff --git a/firebase.json b/firebase.json index de1e19b7..25f9b61f 100644 --- a/firebase.json +++ b/firebase.json @@ -1,8 +1,8 @@ { "functions": { - "predeploy": "npm --prefix \"$RESOURCE_DIR\" run build", + "predeploy": "cd functions && yarn build", "runtime": "nodejs16", - "source": "functions" + "source": "functions/dist" }, "firestore": { "rules": "firestore.rules", diff --git a/functions/.gitignore b/functions/.gitignore index 7aeaedd4..2aeae30c 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -2,9 +2,11 @@ .env* .runtimeconfig.json +# GCP deployment artifact +dist/ + # Compiled JavaScript files -lib/**/*.js -lib/**/*.js.map +lib/ # TypeScript v1 declaration files typings/ diff --git a/functions/package.json b/functions/package.json index 7b5c30b0..45ddcac2 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,8 @@ "firestore": "dev-mantic-markets.appspot.com" }, "scripts": { - "build": "tsc", + "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist", + "compile": "tsc -b", "watch": "tsc -w", "shell": "yarn build && firebase functions:shell", "start": "yarn shell", @@ -18,7 +19,7 @@ "db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/", "verify": "(cd .. && yarn verify)" }, - "main": "lib/functions/src/index.js", + "main": "functions/src/index.js", "dependencies": { "@amplitude/node": "1.10.0", "fetch": "1.1.0", diff --git a/functions/tsconfig.json b/functions/tsconfig.json index e183bb44..9496b9cb 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "baseUrl": "../", + "composite": true, "module": "commonjs", "noImplicitReturns": true, "outDir": "lib", @@ -8,6 +9,11 @@ "strict": true, "target": "es2017" }, + "references": [ + { + "path": "../common" + } + ], "compileOnSave": true, - "include": ["src", "../common/**/*.ts"] + "include": ["src"] } diff --git a/web/tsconfig.json b/web/tsconfig.json index 96cf1311..2f31aa8c 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -16,7 +16,6 @@ "jsx": "preserve", "incremental": true }, - "watchOptions": { "excludeDirectories": [".next"] }, From 2f434c849d53bf4c75cf0f525477b259c3e93c66 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 28 Jun 2022 11:03:14 -0500 Subject: [PATCH 045/220] Remove portfolio link; user icon links to portfolio --- web/components/nav/profile-menu.tsx | 2 +- web/components/nav/sidebar.tsx | 12 ++---------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index 397f6e4e..9e869c40 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -8,7 +8,7 @@ import { trackCallback } from 'web/lib/service/analytics' export function ProfileSummary(props: { user: User }) { const { user } = props return ( - <Link href={`/${user.username}`}> + <Link href={`/${user.username}?tab=bets`}> <a onClick={trackCallback('sidebar: profile')} className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 48cdd0ca..6c3d4fc4 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -5,7 +5,6 @@ import { DotsHorizontalIcon, CashIcon, HeartIcon, - PresentationChartLineIcon, UserGroupIcon, ChevronDownIcon, TrendingUpIcon, @@ -27,14 +26,9 @@ import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' import { Group } from 'common/group' -function getNavigation(username: string) { +function getNavigation() { return [ { name: 'Home', href: '/home', icon: HomeIcon }, - { - name: 'Portfolio', - href: `/${username}?tab=bets`, - icon: PresentationChartLineIcon, - }, { name: 'Notifications', href: `/notifications`, @@ -177,9 +171,7 @@ export default function Sidebar(props: { className?: string }) { const currentPage = router.pathname const user = useUser() - const navigationOptions = !user - ? signedOutNavigation - : getNavigation(user?.username || 'error') + const navigationOptions = !user ? signedOutNavigation : getNavigation() const mobileNavigationOptions = !user ? signedOutMobileNavigation : signedInMobileNavigation From c18a0378e9117798e6025bd5792c76a96dd2baf5 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 28 Jun 2022 11:18:55 -0500 Subject: [PATCH 046/220] Tweak nav items around --- web/components/nav/sidebar.tsx | 40 ++++++++++++---------------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 6c3d4fc4..e5f3cd5c 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -57,12 +57,10 @@ function getMoreNavigation(user?: User | null) { } return [ + { name: 'Send M$', href: '/links' }, { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Charity', href: '/charity' }, - { name: 'Blog', href: 'https://news.manifold.markets' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, - { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }, - { name: 'Statistics', href: '/stats' }, { name: 'About', href: 'https://docs.manifold.markets/$how-to' }, { name: 'Sign out', @@ -100,6 +98,18 @@ const signedInMobileNavigation = [ ...signedOutMobileNavigation, ] +function getMoreMobileNav() { + return [ + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'Statistics', href: '/stats' }, + { + name: 'Sign out', + href: '#', + onClick: withTracking(firebaseLogout, 'sign out'), + }, + ] +} + export type Item = { name: string href: string @@ -211,29 +221,7 @@ export default function Sidebar(props: { className?: string }) { {user && ( <MenuButton - menuItems={[ - { - name: 'Blog', - href: 'https://news.manifold.markets', - }, - { - name: 'Discord', - href: 'https://discord.gg/eHQBNBqXuh', - }, - { - name: 'Twitter', - href: 'https://twitter.com/ManifoldMarkets', - }, - { - name: 'Statistics', - href: '/stats', - }, - { - name: 'Sign out', - href: '#', - onClick: withTracking(firebaseLogout, 'sign out'), - }, - ]} + menuItems={getMoreMobileNav()} buttonContent={<MoreButton />} /> )} From 7f9b0557c4db38ca7a01e3e1bd6ff48d3a81c087 Mon Sep 17 00:00:00 2001 From: Forrest Wolf <forrest.weiswolf@gmail.com> Date: Tue, 28 Jun 2022 14:46:25 -0500 Subject: [PATCH 047/220] Reorganize verify scripts (#589) * Update verify to match check for functions * Give each subdirectory a verify:dir script --- common/package.json | 3 ++- functions/package.json | 3 ++- package.json | 2 +- web/package.json | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/common/package.json b/common/package.json index 1bd67851..c8115d84 100644 --- a/common/package.json +++ b/common/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "private": true, "scripts": { - "verify": "(cd .. && yarn verify)" + "verify": "(cd .. && yarn verify)", + "verify:dir": "npx eslint . --max-warnings 0" }, "sideEffects": false, "dependencies": { diff --git a/functions/package.json b/functions/package.json index 45ddcac2..eb6c7151 100644 --- a/functions/package.json +++ b/functions/package.json @@ -17,7 +17,8 @@ "db:backup-local": "firebase emulators:export --force ./firestore_export", "db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", "db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/", - "verify": "(cd .. && yarn verify)" + "verify": "(cd .. && yarn verify)", + "verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty" }, "main": "functions/src/index.js", "dependencies": { diff --git a/package.json b/package.json index a5c1e29e..e4aee3fd 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "web" ], "scripts": { - "verify": "(cd web && npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit); (cd common && npx eslint . --max-warnings 0); (cd functions && npx eslint . --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit)" + "verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)" }, "dependencies": {}, "devDependencies": { diff --git a/web/package.json b/web/package.json index cde76121..454db57c 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,8 @@ "lint": "next lint", "format": "npx prettier --write .", "postbuild": "next-sitemap", - "verify": "(cd .. && yarn verify)" + "verify": "(cd .. && yarn verify)", + "verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit" }, "dependencies": { "@amplitude/analytics-browser": "0.4.1", From 63528aa0f3d92e78aa42e91d9582287d2be4dc2e Mon Sep 17 00:00:00 2001 From: SirSaltyy <104849031+SirSaltyy@users.noreply.github.com> Date: Tue, 28 Jun 2022 17:19:58 -0500 Subject: [PATCH 048/220] Add CES charity (#591) Added CES charity to the charity page. --- common/charity.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/common/charity.ts b/common/charity.ts index 249bcc51..0d8a0aa6 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -516,6 +516,22 @@ The American Civil Liberties Union is our nation's guardian of liberty, working The U.S. Constitution and the Bill of Rights trumpet our aspirations for the kind of society that we want to be. But for much of our history, our nation failed to fulfill the promise of liberty for whole groups of people.`, }, + { + name: 'The Center for Election Science', + website: 'https://electionscience.org/', + photo: 'https://i.imgur.com/WvdHHZa.png', + preview: + 'The Center for Election Science is a nonpartisan nonprofit dedicated to empowering voters with voting methods that strengthen democracy. We believe you deserve a vote that empowers you to impact the world you live in.', + description: `Founded in 2011, The Center for Election Science is a national, nonpartisan nonprofit focused on voting reform. + +Our Mission — To empower people with voting methods that strengthen democracy. + +Our Vision — A world where democracies thrive because voters’ voices are heard. + +With an emphasis on approval voting, we bring better elections to people across the country through both advocacy and research. + +The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that don’t represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`, + }, ].map((charity) => { const slug = charity.name.toLowerCase().replace(/\s/g, '-') return { From 8c3c30c70743cfb89281eab332268493dc4b882f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 29 Jun 2022 11:00:43 -0500 Subject: [PATCH 049/220] Show groups on user page, allow to join/leave (#594) * Show groups on user page, allow to join/leave * Link to groups * Unused var --- web/components/groups/group-selector.tsx | 2 +- web/components/groups/groups-button.tsx | 144 +++++++++++++++++++++++ web/components/nav/sidebar.tsx | 2 +- web/components/user-page.tsx | 2 + web/hooks/use-group.ts | 6 +- web/lib/firebase/groups.ts | 21 ++++ web/pages/groups.tsx | 15 +++ 7 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 web/components/groups/groups-button.tsx diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx index 6bc943dc..ea1597f2 100644 --- a/web/components/groups/group-selector.tsx +++ b/web/components/groups/group-selector.tsx @@ -22,7 +22,7 @@ export function GroupSelector(props: { const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false) const [query, setQuery] = useState('') - const memberGroups = useMemberGroups(creator) + const memberGroups = useMemberGroups(creator?.id) const filteredGroups = memberGroups ? query === '' ? memberGroups diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx new file mode 100644 index 00000000..e6ee217d --- /dev/null +++ b/web/components/groups/groups-button.tsx @@ -0,0 +1,144 @@ +import clsx from 'clsx' +import { User } from 'common/user' +import { useState } from 'react' +import { useUser } from 'web/hooks/use-user' +import { withTracking } from 'web/lib/service/analytics' +import { Row } from 'web/components/layout/row' +import { useMemberGroups } from 'web/hooks/use-group' +import { TextButton } from 'web/components/text-button' +import { Group } from 'common/group' +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { joinGroup, leaveGroup } from 'web/lib/firebase/groups' +import { firebaseLogin } from 'web/lib/firebase/users' +import { GroupLink } from 'web/pages/groups' + +export function GroupsButton(props: { user: User }) { + const { user } = props + const [isOpen, setIsOpen] = useState(false) + const groups = useMemberGroups(user.id) + + return ( + <> + <TextButton onClick={() => setIsOpen(true)}> + <span className="font-semibold">{groups?.length ?? ''}</span> Groups + </TextButton> + + <GroupsDialog + user={user} + groups={groups ?? []} + isOpen={isOpen} + setIsOpen={setIsOpen} + /> + </> + ) +} + +function GroupsDialog(props: { + user: User + groups: Group[] + isOpen: boolean + setIsOpen: (isOpen: boolean) => void +}) { + const { user, groups, isOpen, setIsOpen } = props + + return ( + <Modal open={isOpen} setOpen={setIsOpen}> + <Col className="rounded bg-white p-6"> + <div className="p-2 pb-1 text-xl">{user.name}</div> + <div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div> + <GroupsList groups={groups} /> + </Col> + </Modal> + ) +} + +function GroupsList(props: { groups: Group[] }) { + const { groups } = props + return ( + <Col className="gap-2"> + {groups.length === 0 && ( + <div className="text-gray-500">No groups yet...</div> + )} + {groups + .sort((group1, group2) => group2.createdTime - group1.createdTime) + .map((group) => ( + <GroupItem key={group.id} group={group} /> + ))} + </Col> + ) +} + +function GroupItem(props: { group: Group; className?: string }) { + const { group, className } = props + return ( + <Row className={clsx('items-center justify-between gap-2 p-2', className)}> + <Row className="line-clamp-1 items-center gap-2"> + <GroupLink group={group} /> + </Row> + <JoinOrLeaveGroupButton group={group} /> + </Row> + ) +} + +export function JoinOrLeaveGroupButton(props: { + group: Group + small?: boolean + className?: string +}) { + const { group, small, className } = props + const currentUser = useUser() + const isFollowing = currentUser + ? group.memberIds.includes(currentUser.id) + : false + const onJoinGroup = () => { + if (!currentUser) return + joinGroup(group, currentUser.id) + } + const onLeaveGroup = () => { + if (!currentUser) return + leaveGroup(group, currentUser.id) + } + + const smallStyle = + 'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500' + + if (!currentUser || isFollowing === undefined) { + if (!group.anyoneCanJoin) + return <div className={clsx(className, 'text-gray-500')}>Closed</div> + return ( + <button + onClick={firebaseLogin} + className={clsx('btn btn-sm', small && smallStyle, className)} + > + Login to Join + </button> + ) + } + + if (isFollowing) { + return ( + <button + className={clsx( + 'btn btn-outline btn-sm', + small && smallStyle, + className + )} + onClick={withTracking(onLeaveGroup, 'leave group')} + > + Leave + </button> + ) + } + + if (!group.anyoneCanJoin) + return <div className={clsx(className, 'text-gray-500')}>Closed</div> + return ( + <button + className={clsx('btn btn-sm', small && smallStyle, className)} + onClick={withTracking(onJoinGroup, 'join group')} + > + Join + </button> + ) +} diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index e5f3cd5c..0b3d9393 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -185,7 +185,7 @@ export default function Sidebar(props: { className?: string }) { const mobileNavigationOptions = !user ? signedOutMobileNavigation : signedInMobileNavigation - const memberItems = (useMemberGroups(user) ?? []).map((group: Group) => ({ + const memberItems = (useMemberGroups(user?.id) ?? []).map((group: Group) => ({ name: group.name, href: groupPath(group.slug), })) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 2019a9de..246ed2aa 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -36,6 +36,7 @@ import { FollowersButton, FollowingButton } from './following-button' import { useFollows } from 'web/hooks/use-follows' import { FollowButton } from './follow-button' import { PortfolioMetrics } from 'common/user' +import { GroupsButton } from 'web/components/groups/groups-button' export function UserLink(props: { name: string @@ -197,6 +198,7 @@ export function UserPage(props: { <Row className="gap-4"> <FollowingButton user={user} /> <FollowersButton user={user} /> + <GroupsButton user={user} /> </Row> {user.website && ( diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index f73fd04e..41f84707 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -29,11 +29,11 @@ export const useGroups = () => { return groups } -export const useMemberGroups = (user: User | null | undefined) => { +export const useMemberGroups = (userId: string | null | undefined) => { const [memberGroups, setMemberGroups] = useState<Group[] | undefined>() useEffect(() => { - if (user) return listenForMemberGroups(user.id, setMemberGroups) - }, [user]) + if (userId) return listenForMemberGroups(userId, setMemberGroups) + }, [userId]) return memberGroups } diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 1438dd4c..d7244f98 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -94,3 +94,24 @@ export async function getGroupsWithContractId( const groups = await getValues<Group>(q) setGroups(groups) } + +export async function joinGroup(group: Group, userId: string): Promise<Group> { + const { memberIds } = group + if (memberIds.includes(userId)) { + return group + } + const newMemberIds = [...memberIds, userId] + const newGroup = { ...group, memberIds: newMemberIds } + await updateGroup(newGroup, { memberIds: newMemberIds }) + return newGroup +} +export async function leaveGroup(group: Group, userId: string): Promise<Group> { + const { memberIds } = group + if (!memberIds.includes(userId)) { + return group + } + const newMemberIds = memberIds.filter((id) => id !== userId) + const newGroup = { ...group, memberIds: newMemberIds } + await updateGroup(newGroup, { memberIds: newMemberIds }) + return newGroup +} diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index c8f08b25..a8f99b23 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -15,6 +15,8 @@ import { getUser, User } from 'web/lib/firebase/users' import { Tabs } from 'web/components/layout/tabs' import { GroupMembersList } from 'web/pages/group/[...slugs]' import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' +import { SiteLink } from 'web/components/site-link' +import clsx from 'clsx' export async function getStaticProps() { const groups = await listAllGroups().catch((_) => []) @@ -202,3 +204,16 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { </Col> ) } + +export function GroupLink(props: { group: Group; className?: string }) { + const { group, className } = props + + return ( + <SiteLink + href={groupPath(group.slug)} + className={clsx('z-10 truncate', className)} + > + {group.name} + </SiteLink> + ) +} From 2d79d7f8dbf98eeee28d99cbad4418cb13d8f63f Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 29 Jun 2022 12:33:20 -0500 Subject: [PATCH 050/220] Rework nav to show list of groups (#596) * Rework nav to show list of groups * Fix lint * Replace Portfolio with Profile link * Lint: remove unused vars --- web/components/nav/nav-bar.tsx | 55 +++++++------- web/components/nav/sidebar.tsx | 133 ++++++++++++++++----------------- 2 files changed, 90 insertions(+), 98 deletions(-) diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 23c9ab38..5a997b46 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -3,7 +3,6 @@ import Link from 'next/link' import { HomeIcon, MenuAlt3Icon, - PresentationChartLineIcon, SearchIcon, XIcon, } from '@heroicons/react/outline' @@ -19,14 +18,9 @@ import NotificationsIcon from 'web/components/notifications-icon' import { useIsIframe } from 'web/hooks/use-is-iframe' import { trackCallback } from 'web/lib/service/analytics' -function getNavigation(username: string) { +function getNavigation() { return [ { name: 'Home', href: '/home', icon: HomeIcon }, - { - name: 'Portfolio', - href: `/${username}?tab=bets`, - icon: PresentationChartLineIcon, - }, { name: 'Notifications', href: `/notifications`, @@ -55,38 +49,39 @@ export function BottomNavBar() { } const navigationOptions = - user === null - ? signedOutNavigation - : getNavigation(user?.username || 'error') + user === null ? signedOutNavigation : getNavigation() return ( <nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden"> {navigationOptions.map((item) => ( <NavBarItem key={item.name} item={item} currentPage={currentPage} /> ))} + + {user && ( + <NavBarItem + key={'profile'} + currentPage={currentPage} + item={{ + name: formatMoney(user.balance), + href: `/${user.username}?tab=bets`, + icon: () => ( + <Avatar + className="mx-auto my-1" + size="xs" + username={user.username} + avatarUrl={user.avatarUrl} + noLink + /> + ), + }} + /> + )} <div className="w-full select-none py-1 px-3 text-center hover:cursor-pointer hover:bg-indigo-200 hover:text-indigo-700" onClick={() => setSidebarOpen(true)} > - {user === null ? ( - <> - <MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" /> - More - </> - ) : user ? ( - <> - <Avatar - className="mx-auto my-1" - size="xs" - username={user.username} - avatarUrl={user.avatarUrl} - noLink - /> - {formatMoney(user.balance)} - </> - ) : ( - <></> - )} + <MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" /> + More </div> <MobileSidebar @@ -109,7 +104,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) { )} onClick={trackCallback('navbar: ' + item.name)} > - <item.icon className="my-1 mx-auto h-6 w-6" /> + {item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />} {item.name} </a> </Link> diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 0b3d9393..58011127 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -6,8 +6,8 @@ import { CashIcon, HeartIcon, UserGroupIcon, - ChevronDownIcon, TrendingUpIcon, + ChatIcon, } from '@heroicons/react/outline' import clsx from 'clsx' import Link from 'next/link' @@ -25,6 +25,7 @@ import { useMemberGroups } from 'web/hooks/use-group' import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' import { Group } from 'common/group' +import { Spacer } from '../layout/spacer' function getNavigation() { return [ @@ -82,8 +83,20 @@ const signedOutNavigation = [ ] const signedOutMobileNavigation = [ + { + name: 'About', + href: 'https://docs.manifold.markets/$how-to', + icon: BookOpenIcon, + }, { name: 'Charity', href: '/charity', icon: HeartIcon }, { name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon }, +] + +const signedInMobileNavigation = [ + ...(IS_PRIVATE_MANIFOLD + ? [] + : [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]), { name: 'About', href: 'https://docs.manifold.markets/$how-to', @@ -91,17 +104,12 @@ const signedOutMobileNavigation = [ }, ] -const signedInMobileNavigation = [ - ...(IS_PRIVATE_MANIFOLD - ? [] - : [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]), - ...signedOutMobileNavigation, -] - function getMoreMobileNav() { return [ + { name: 'Send M$', href: '/links' }, + { name: 'Charity', href: '/charity' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, - { name: 'Statistics', href: '/stats' }, + { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Sign out', href: '#', @@ -113,7 +121,7 @@ function getMoreMobileNav() { export type Item = { name: string href: string - icon: React.ComponentType<{ className?: string }> + icon?: React.ComponentType<{ className?: string }> } function SidebarItem(props: { item: Item; currentPage: string }) { @@ -130,15 +138,17 @@ function SidebarItem(props: { item: Item; currentPage: string }) { )} aria-current={item.href == currentPage ? 'page' : undefined} > - <item.icon - className={clsx( - item.href == currentPage - ? 'text-gray-500' - : 'text-gray-400 group-hover:text-gray-500', - '-ml-1 mr-3 h-6 w-6 flex-shrink-0' - )} - aria-hidden="true" - /> + {item.icon && ( + <item.icon + className={clsx( + item.href == currentPage + ? 'text-gray-500' + : 'text-gray-400 group-hover:text-gray-500', + '-ml-1 mr-3 h-6 w-6 flex-shrink-0' + )} + aria-hidden="true" + /> + )} <span className="truncate">{item.name}</span> </a> </Link> @@ -167,14 +177,6 @@ function MoreButton() { return <SidebarButton text={'More'} icon={DotsHorizontalIcon} /> } -function GroupsButton() { - return ( - <SidebarButton icon={UserGroupIcon} text={'Groups'}> - <ChevronDownIcon className=" mt-0.5 ml-2 h-5 w-5" aria-hidden="true" /> - </SidebarButton> - ) -} - export default function Sidebar(props: { className?: string }) { const { className } = props const router = useRouter() @@ -193,31 +195,20 @@ export default function Sidebar(props: { className?: string }) { return ( <nav aria-label="Sidebar" className={className}> <ManifoldLogo className="pb-6" twoLine /> + + <CreateQuestionButton user={user} /> + <Spacer h={4} /> {user && ( - <div className="mb-2" style={{ minHeight: 80 }}> + <div className="w-full" style={{ minHeight: 80 }}> <ProfileSummary user={user} /> </div> )} {/* Mobile navigation */} <div className="space-y-1 lg:hidden"> - {user && ( - <MenuButton - buttonContent={<GroupsButton />} - menuItems={[{ name: 'Explore', href: '/groups' }, ...memberItems]} - className={'relative z-50 flex-shrink-0'} - /> - )} {mobileNavigationOptions.map((item) => ( <SidebarItem key={item.href} item={item} currentPage={currentPage} /> ))} - {!user && ( - <SidebarItem - key={'Groups'} - item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }} - currentPage={currentPage} - /> - )} {user && ( <MenuButton @@ -225,41 +216,47 @@ export default function Sidebar(props: { className?: string }) { buttonContent={<MoreButton />} /> )} + + <GroupsList currentPage={currentPage} memberItems={memberItems} /> </div> {/* Desktop navigation */} <div className="hidden space-y-1 lg:block"> - {navigationOptions.map((item) => - item.name === 'Notifications' ? ( - <div key={item.href}> - <SidebarItem item={item} currentPage={currentPage} /> - {user && ( - <MenuButton - key={'groupsdropdown'} - buttonContent={<GroupsButton />} - menuItems={[ - { name: 'Explore', href: '/groups' }, - ...memberItems, - ]} - className={'relative z-50 flex-shrink-0'} - /> - )} - </div> - ) : ( - <SidebarItem - key={item.href} - item={item} - currentPage={currentPage} - /> - ) - )} - + {navigationOptions.map((item) => ( + <SidebarItem key={item.href} item={item} currentPage={currentPage} /> + ))} <MenuButton menuItems={getMoreNavigation(user)} buttonContent={<MoreButton />} /> + + <Spacer h={6} /> + <GroupsList currentPage={currentPage} memberItems={memberItems} /> </div> - <CreateQuestionButton user={user} /> </nav> ) } + +function GroupsList(props: { currentPage: string; memberItems: Item[] }) { + const { currentPage, memberItems } = props + return ( + <> + <SidebarItem + item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }} + currentPage={currentPage} + /> + + <div className="mt-1 space-y-0.5"> + {memberItems.map((item) => ( + <a + key={item.name} + href={item.href} + className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900" + > + <span className="truncate">  {item.name}</span> + </a> + ))} + </div> + </> + ) +} From 8132fa595b9ed47bec9bf0d183e054f909ba5bd7 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 29 Jun 2022 12:48:04 -0500 Subject: [PATCH 051/220] Don't add space when there are 0 groups --- web/components/nav/sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 58011127..aa0acf05 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -230,7 +230,7 @@ export default function Sidebar(props: { className?: string }) { buttonContent={<MoreButton />} /> - <Spacer h={6} /> + {memberItems.length > 0 && <Spacer h={6} />} <GroupsList currentPage={currentPage} memberItems={memberItems} /> </div> </nav> From 3b4666ba3e239d4c0545aea64d00cf795688f5b9 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 29 Jun 2022 12:21:40 -0700 Subject: [PATCH 052/220] Add Firebase schema collection helpers (kind of an RFC) (#583) * Add Firebase schema collection helpers * Decentralize definitions from schema file (James feedback) * Add lint comment --- web/hooks/use-contract.ts | 6 +-- web/hooks/use-user.ts | 6 +-- web/lib/firebase/contracts.ts | 83 +++++++++++++++-------------------- web/lib/firebase/groups.ts | 42 +++++++++--------- web/lib/firebase/manalinks.ts | 26 ++++------- web/lib/firebase/txns.ts | 19 ++++---- web/lib/firebase/users.ts | 81 ++++++++++++++++------------------ web/lib/firebase/utils.ts | 7 +++ 8 files changed, 124 insertions(+), 146 deletions(-) diff --git a/web/hooks/use-contract.ts b/web/hooks/use-contract.ts index 9810d9d4..acaf7730 100644 --- a/web/hooks/use-contract.ts +++ b/web/hooks/use-contract.ts @@ -2,16 +2,16 @@ import { useEffect } from 'react' import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { Contract, - contractDocRef, + contracts, listenForContract, } from 'web/lib/firebase/contracts' import { useStateCheckEquality } from './use-state-check-equality' -import { DocumentData } from 'firebase/firestore' +import { doc, DocumentData } from 'firebase/firestore' export const useContract = (contractId: string) => { const result = useFirestoreDocumentData<DocumentData, Contract>( ['contracts', contractId], - contractDocRef(contractId), + doc(contracts, contractId), { subscribe: true, includeMetadataChanges: true } ) diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index c4d1dff9..158235ca 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { QueryClient } from 'react-query' -import { DocumentData } from 'firebase/firestore' +import { doc, DocumentData } from 'firebase/firestore' import { PrivateUser } from 'common/user' import { getUser, @@ -10,7 +10,7 @@ import { listenForPrivateUser, listenForUser, User, - userDocRef, + users, } from 'web/lib/firebase/users' import { useStateCheckEquality } from './use-state-check-equality' import { identifyUser, setUserProperty } from 'web/lib/service/analytics' @@ -49,7 +49,7 @@ export const usePrivateUser = (userId?: string) => { export const useUserById = (userId: string) => { const result = useFirestoreDocumentData<DocumentData, User>( ['users', userId], - userDocRef(userId), + doc(users, userId), { subscribe: true, includeMetadataChanges: true } ) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index f177d841..d5fb85cb 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -1,6 +1,5 @@ import dayjs from 'dayjs' import { - getFirestore, doc, setDoc, deleteDoc, @@ -16,8 +15,7 @@ import { } from 'firebase/firestore' import { sortBy, sum } from 'lodash' -import { app } from './init' -import { getValues, listenForValue, listenForValues } from './utils' +import { coll, getValues, listenForValue, listenForValues } from './utils' import { BinaryContract, Contract } from 'common/contract' import { getDpmProbability } from 'common/calculate-dpm' import { createRNG, shuffle } from 'common/util/random' @@ -28,6 +26,9 @@ import { MAX_FEED_CONTRACTS } from 'common/recommended-contracts' import { Bet } from 'common/bet' import { Comment } from 'common/comment' import { ENV_CONFIG } from 'common/envs/constants' + +export const contracts = coll<Contract>('contracts') + export type { Contract } export function contractPath(contract: Contract) { @@ -86,83 +87,72 @@ export function tradingAllowed(contract: Contract) { ) } -const db = getFirestore(app) -export const contractCollection = collection(db, 'contracts') -export const contractDocRef = (contractId: string) => - doc(db, 'contracts', contractId) - // Push contract to Firestore export async function setContract(contract: Contract) { - const docRef = doc(db, 'contracts', contract.id) - await setDoc(docRef, contract) + await setDoc(doc(contracts, contract.id), contract) } export async function updateContract( contractId: string, update: Partial<Contract> ) { - const docRef = doc(db, 'contracts', contractId) - await updateDoc(docRef, update) + await updateDoc(doc(contracts, contractId), update) } export async function getContractFromId(contractId: string) { - const docRef = doc(db, 'contracts', contractId) - const result = await getDoc(docRef) - - return result.exists() ? (result.data() as Contract) : undefined + const result = await getDoc(doc(contracts, contractId)) + return result.exists() ? result.data() : undefined } export async function getContractFromSlug(slug: string) { - const q = query(contractCollection, where('slug', '==', slug)) + const q = query(contracts, where('slug', '==', slug)) const snapshot = await getDocs(q) - - return snapshot.empty ? undefined : (snapshot.docs[0].data() as Contract) + return snapshot.empty ? undefined : snapshot.docs[0].data() } export async function deleteContract(contractId: string) { - const docRef = doc(db, 'contracts', contractId) - await deleteDoc(docRef) + await deleteDoc(doc(contracts, contractId)) } export async function listContracts(creatorId: string): Promise<Contract[]> { const q = query( - contractCollection, + contracts, where('creatorId', '==', creatorId), orderBy('createdTime', 'desc') ) const snapshot = await getDocs(q) - return snapshot.docs.map((doc) => doc.data() as Contract) + return snapshot.docs.map((doc) => doc.data()) } export async function listTaggedContractsCaseInsensitive( tag: string ): Promise<Contract[]> { const q = query( - contractCollection, + contracts, where('lowercaseTags', 'array-contains', tag.toLowerCase()), orderBy('createdTime', 'desc') ) const snapshot = await getDocs(q) - return snapshot.docs.map((doc) => doc.data() as Contract) + return snapshot.docs.map((doc) => doc.data()) } export async function listAllContracts( n: number, before?: string ): Promise<Contract[]> { - let q = query(contractCollection, orderBy('createdTime', 'desc'), limit(n)) + let q = query(contracts, orderBy('createdTime', 'desc'), limit(n)) if (before != null) { - const snap = await getDoc(doc(db, 'contracts', before)) + const snap = await getDoc(doc(contracts, before)) q = query(q, startAfter(snap)) } const snapshot = await getDocs(q) - return snapshot.docs.map((doc) => doc.data() as Contract) + return snapshot.docs.map((doc) => doc.data()) } export function listenForContracts( setContracts: (contracts: Contract[]) => void ) { - const q = query(contractCollection, orderBy('createdTime', 'desc')) + const q = query(contracts, orderBy('createdTime', 'desc')) return listenForValues<Contract>(q, setContracts) } @@ -171,7 +161,7 @@ export function listenForUserContracts( setContracts: (contracts: Contract[]) => void ) { const q = query( - contractCollection, + contracts, where('creatorId', '==', creatorId), orderBy('createdTime', 'desc') ) @@ -179,7 +169,7 @@ export function listenForUserContracts( } const activeContractsQuery = query( - contractCollection, + contracts, where('isResolved', '==', false), where('visibility', '==', 'public'), where('volume7Days', '>', 0) @@ -196,7 +186,7 @@ export function listenForActiveContracts( } const inactiveContractsQuery = query( - contractCollection, + contracts, where('isResolved', '==', false), where('closeTime', '>', Date.now()), where('visibility', '==', 'public'), @@ -214,7 +204,7 @@ export function listenForInactiveContracts( } const newContractsQuery = query( - contractCollection, + contracts, where('isResolved', '==', false), where('volume7Days', '==', 0), where('createdTime', '>', Date.now() - 7 * DAY_MS) @@ -230,7 +220,7 @@ export function listenForContract( contractId: string, setContract: (contract: Contract | null) => void ) { - const contractRef = doc(contractCollection, contractId) + const contractRef = doc(contracts, contractId) return listenForValue<Contract>(contractRef, setContract) } @@ -242,7 +232,7 @@ function chooseRandomSubset(contracts: Contract[], count: number) { } const hotContractsQuery = query( - contractCollection, + contracts, where('isResolved', '==', false), where('visibility', '==', 'public'), orderBy('volume24Hours', 'desc'), @@ -262,22 +252,22 @@ export function listenForHotContracts( } export async function getHotContracts() { - const contracts = await getValues<Contract>(hotContractsQuery) + const data = await getValues<Contract>(hotContractsQuery) return sortBy( - chooseRandomSubset(contracts, 10), + chooseRandomSubset(data, 10), (contract) => -1 * contract.volume24Hours ) } export async function getContractsBySlugs(slugs: string[]) { - const q = query(contractCollection, where('slug', 'in', slugs)) + const q = query(contracts, where('slug', 'in', slugs)) const snapshot = await getDocs(q) - const contracts = snapshot.docs.map((doc) => doc.data() as Contract) - return sortBy(contracts, (contract) => -1 * contract.volume24Hours) + const data = snapshot.docs.map((doc) => doc.data()) + return sortBy(data, (contract) => -1 * contract.volume24Hours) } const topWeeklyQuery = query( - contractCollection, + contracts, where('isResolved', '==', false), orderBy('volume7Days', 'desc'), limit(MAX_FEED_CONTRACTS) @@ -287,7 +277,7 @@ export async function getTopWeeklyContracts() { } const closingSoonQuery = query( - contractCollection, + contracts, where('isResolved', '==', false), where('visibility', '==', 'public'), where('closeTime', '>', Date.now()), @@ -296,15 +286,12 @@ const closingSoonQuery = query( ) export async function getClosingSoonContracts() { - const contracts = await getValues<Contract>(closingSoonQuery) - return sortBy( - chooseRandomSubset(contracts, 2), - (contract) => contract.closeTime - ) + const data = await getValues<Contract>(closingSoonQuery) + return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime) } export async function getRecentBetsAndComments(contract: Contract) { - const contractDoc = doc(db, 'contracts', contract.id) + const contractDoc = doc(contracts, contract.id) const [recentBets, recentComments] = await Promise.all([ getValues<Bet>( diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index d7244f98..36b05452 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -1,7 +1,7 @@ import { - collection, deleteDoc, doc, + getDocs, query, updateDoc, where, @@ -9,11 +9,16 @@ import { import { sortBy } from 'lodash' import { Group } from 'common/group' import { getContractFromId } from './contracts' -import { db } from './init' -import { getValue, getValues, listenForValue, listenForValues } from './utils' +import { + coll, + getValue, + getValues, + listenForValue, + listenForValues, +} from './utils' import { filterDefined } from 'common/util/array' -const groupCollection = collection(db, 'groups') +export const groups = coll<Group>('groups') export function groupPath( groupSlug: string, @@ -23,30 +28,29 @@ export function groupPath( } export function updateGroup(group: Group, updates: Partial<Group>) { - return updateDoc(doc(groupCollection, group.id), updates) + return updateDoc(doc(groups, group.id), updates) } export function deleteGroup(group: Group) { - return deleteDoc(doc(groupCollection, group.id)) + return deleteDoc(doc(groups, group.id)) } export async function listAllGroups() { - return getValues<Group>(groupCollection) + return getValues<Group>(groups) } export function listenForGroups(setGroups: (groups: Group[]) => void) { - return listenForValues(groupCollection, setGroups) + return listenForValues(groups, setGroups) } export function getGroup(groupId: string) { - return getValue<Group>(doc(groupCollection, groupId)) + return getValue<Group>(doc(groups, groupId)) } export async function getGroupBySlug(slug: string) { - const q = query(groupCollection, where('slug', '==', slug)) - const groups = await getValues<Group>(q) - - return groups.length === 0 ? null : groups[0] + const q = query(groups, where('slug', '==', slug)) + const docs = (await getDocs(q)).docs + return docs.length === 0 ? null : docs[0].data() } export async function getGroupContracts(group: Group) { @@ -68,14 +72,14 @@ export function listenForGroup( groupId: string, setGroup: (group: Group | null) => void ) { - return listenForValue(doc(groupCollection, groupId), setGroup) + return listenForValue(doc(groups, groupId), setGroup) } export function listenForMemberGroups( userId: string, setGroups: (groups: Group[]) => void ) { - const q = query(groupCollection, where('memberIds', 'array-contains', userId)) + const q = query(groups, where('memberIds', 'array-contains', userId)) return listenForValues<Group>(q, (groups) => { const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime]) @@ -87,12 +91,8 @@ export async function getGroupsWithContractId( contractId: string, setGroups: (groups: Group[]) => void ) { - const q = query( - groupCollection, - where('contractIds', 'array-contains', contractId) - ) - const groups = await getValues<Group>(q) - setGroups(groups) + const q = query(groups, where('contractIds', 'array-contains', contractId)) + setGroups(await getValues<Group>(q)) } export async function joinGroup(group: Group, userId: string): Promise<Group> { diff --git a/web/lib/firebase/manalinks.ts b/web/lib/firebase/manalinks.ts index 67c7a00a..532534df 100644 --- a/web/lib/firebase/manalinks.ts +++ b/web/lib/firebase/manalinks.ts @@ -1,18 +1,12 @@ -import { - collection, - getDoc, - orderBy, - query, - setDoc, - where, -} from 'firebase/firestore' +import { getDoc, orderBy, query, setDoc, where } from 'firebase/firestore' import { doc } from 'firebase/firestore' import { Manalink } from '../../../common/manalink' -import { db } from './init' import { customAlphabet } from 'nanoid' -import { listenForValues } from './utils' +import { coll, listenForValues } from './utils' import { useEffect, useState } from 'react' +export const manalinks = coll<Manalink>('manalinks') + export async function createManalink(data: { fromId: string amount: number @@ -45,29 +39,25 @@ export async function createManalink(data: { message, } - const ref = doc(db, 'manalinks', slug) - await setDoc(ref, manalink) + await setDoc(doc(manalinks, slug), manalink) return slug } -const manalinkCol = collection(db, 'manalinks') - // TODO: This required an index, make sure to also set up in prod function listUserManalinks(fromId?: string) { return query( - manalinkCol, + manalinks, where('fromId', '==', fromId), orderBy('createdTime', 'desc') ) } export async function getManalink(slug: string) { - const docSnap = await getDoc(doc(db, 'manalinks', slug)) - return docSnap.data() as Manalink + return (await getDoc(doc(manalinks, slug))).data() } export function useManalink(slug: string) { - const [manalink, setManalink] = useState<Manalink | null>(null) + const [manalink, setManalink] = useState<Manalink | undefined>(undefined) useEffect(() => { if (slug) { getManalink(slug).then(setManalink) diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts index c4c8aa93..17e9a09b 100644 --- a/web/lib/firebase/txns.ts +++ b/web/lib/firebase/txns.ts @@ -1,15 +1,14 @@ -import { ManalinkTxn, DonationTxn, TipTxn } from 'common/txn' -import { collection, orderBy, query, where } from 'firebase/firestore' -import { db } from './init' -import { getValues, listenForValues } from './utils' +import { ManalinkTxn, DonationTxn, TipTxn, Txn } from 'common/txn' +import { orderBy, query, where } from 'firebase/firestore' +import { coll, getValues, listenForValues } from './utils' import { useState, useEffect } from 'react' import { orderBy as _orderBy } from 'lodash' -const txnCollection = collection(db, 'txns') +export const txns = coll<Txn>('txns') const getCharityQuery = (charityId: string) => query( - txnCollection, + txns, where('toType', '==', 'CHARITY'), where('toId', '==', charityId), orderBy('createdTime', 'desc') @@ -22,7 +21,7 @@ export function listenForCharityTxns( return listenForValues<DonationTxn>(getCharityQuery(charityId), setTxns) } -const charitiesQuery = query(txnCollection, where('toType', '==', 'CHARITY')) +const charitiesQuery = query(txns, where('toType', '==', 'CHARITY')) export function getAllCharityTxns() { return getValues<DonationTxn>(charitiesQuery) @@ -30,7 +29,7 @@ export function getAllCharityTxns() { const getTipsQuery = (contractId: string) => query( - txnCollection, + txns, where('category', '==', 'TIP'), where('data.contractId', '==', contractId) ) @@ -50,13 +49,13 @@ export function useManalinkTxns(userId: string) { useEffect(() => { // TODO: Need to instantiate these indexes too const fromQuery = query( - txnCollection, + txns, where('fromId', '==', userId), where('category', '==', 'MANALINK'), orderBy('createdTime', 'desc') ) const toQuery = query( - txnCollection, + txns, where('toId', '==', userId), where('category', '==', 'MANALINK'), orderBy('createdTime', 'desc') diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index e9fcbb93..40be6741 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -1,5 +1,4 @@ import { - getFirestore, doc, setDoc, getDoc, @@ -23,58 +22,62 @@ import { } from 'firebase/auth' import { throttle, zip } from 'lodash' -import { app } from './init' +import { app, db } from './init' import { PortfolioMetrics, PrivateUser, User } from 'common/user' import { createUser } from './fn-call' -import { getValue, getValues, listenForValue, listenForValues } from './utils' +import { + coll, + getValue, + getValues, + listenForValue, + listenForValues, +} from './utils' import { feed } from 'common/feed' import { CATEGORY_LIST } from 'common/categories' import { safeLocalStorage } from '../util/local' import { filterDefined } from 'common/util/array' +export const users = coll<User>('users') +export const privateUsers = coll<PrivateUser>('private-users') + export type { User } export type Period = 'daily' | 'weekly' | 'monthly' | 'allTime' -const db = getFirestore(app) export const auth = getAuth(app) -export const userDocRef = (userId: string) => doc(db, 'users', userId) - export async function getUser(userId: string) { - const docSnap = await getDoc(userDocRef(userId)) - return docSnap.data() as User + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + return (await getDoc(doc(users, userId))).data()! } export async function getUserByUsername(username: string) { // Find a user whose username matches the given username, or null if no such user exists. - const userCollection = collection(db, 'users') - const q = query(userCollection, where('username', '==', username), limit(1)) - const docs = await getDocs(q) - const users = docs.docs.map((doc) => doc.data() as User) - return users[0] || null + const q = query(users, where('username', '==', username), limit(1)) + const docs = (await getDocs(q)).docs + return docs.length > 0 ? docs[0].data() : null } export async function setUser(userId: string, user: User) { - await setDoc(doc(db, 'users', userId), user) + await setDoc(doc(users, userId), user) } export async function updateUser(userId: string, update: Partial<User>) { - await updateDoc(doc(db, 'users', userId), { ...update }) + await updateDoc(doc(users, userId), { ...update }) } export async function updatePrivateUser( userId: string, update: Partial<PrivateUser> ) { - await updateDoc(doc(db, 'private-users', userId), { ...update }) + await updateDoc(doc(privateUsers, userId), { ...update }) } export function listenForUser( userId: string, setUser: (user: User | null) => void ) { - const userRef = doc(db, 'users', userId) + const userRef = doc(users, userId) return listenForValue<User>(userRef, setUser) } @@ -82,7 +85,7 @@ export function listenForPrivateUser( userId: string, setPrivateUser: (privateUser: PrivateUser | null) => void ) { - const userRef = doc(db, 'private-users', userId) + const userRef = doc(privateUsers, userId) return listenForValue<PrivateUser>(userRef, setPrivateUser) } @@ -152,36 +155,29 @@ export async function listUsers(userIds: string[]) { if (userIds.length > 10) { throw new Error('Too many users requested at once; Firestore limits to 10') } - const userCollection = collection(db, 'users') - const q = query(userCollection, where('id', 'in', userIds)) - const docs = await getDocs(q) - return docs.docs.map((doc) => doc.data() as User) + const q = query(users, where('id', 'in', userIds)) + const docs = (await getDocs(q)).docs + return docs.map((doc) => doc.data()) } export async function listAllUsers() { - const userCollection = collection(db, 'users') - const q = query(userCollection) - const docs = await getDocs(q) - return docs.docs.map((doc) => doc.data() as User) + const docs = (await getDocs(users)).docs + return docs.map((doc) => doc.data()) } export function listenForAllUsers(setUsers: (users: User[]) => void) { - const userCollection = collection(db, 'users') - const q = query(userCollection) - listenForValues(q, setUsers) + listenForValues(users, setUsers) } export function listenForPrivateUsers( setUsers: (users: PrivateUser[]) => void ) { - const userCollection = collection(db, 'private-users') - const q = query(userCollection) - listenForValues(q, setUsers) + listenForValues(privateUsers, setUsers) } export function getTopTraders(period: Period) { const topTraders = query( - collection(db, 'users'), + users, orderBy('profitCached.' + period, 'desc'), limit(20) ) @@ -191,7 +187,7 @@ export function getTopTraders(period: Period) { export function getTopCreators(period: Period) { const topCreators = query( - collection(db, 'users'), + users, orderBy('creatorVolumeCached.' + period, 'desc'), limit(20) ) @@ -199,22 +195,21 @@ export function getTopCreators(period: Period) { } export async function getTopFollowed() { - const users = await getValues<User>(topFollowedQuery) - return users.slice(0, 20) + return (await getValues<User>(topFollowedQuery)).slice(0, 20) } const topFollowedQuery = query( - collection(db, 'users'), + users, orderBy('followerCountCached', 'desc'), limit(20) ) export function getUsers() { - return getValues<User>(collection(db, 'users')) + return getValues<User>(users) } export async function getUserFeed(userId: string) { - const feedDoc = doc(db, 'private-users', userId, 'cache', 'feed') + const feedDoc = doc(privateUsers, userId, 'cache', 'feed') const userFeed = await getValue<{ feed: feed }>(feedDoc) @@ -222,7 +217,7 @@ export async function getUserFeed(userId: string) { } export async function getCategoryFeeds(userId: string) { - const cacheCollection = collection(db, 'private-users', userId, 'cache') + const cacheCollection = collection(privateUsers, userId, 'cache') const feedData = await Promise.all( CATEGORY_LIST.map((category) => getValue<{ feed: feed }>(doc(cacheCollection, `feed-${category}`)) @@ -233,7 +228,7 @@ export async function getCategoryFeeds(userId: string) { } export async function follow(userId: string, followedUserId: string) { - const followDoc = doc(db, 'users', userId, 'follows', followedUserId) + const followDoc = doc(collection(users, userId, 'follows'), followedUserId) await setDoc(followDoc, { userId: followedUserId, timestamp: Date.now(), @@ -241,7 +236,7 @@ export async function follow(userId: string, followedUserId: string) { } export async function unfollow(userId: string, unfollowedUserId: string) { - const followDoc = doc(db, 'users', userId, 'follows', unfollowedUserId) + const followDoc = doc(collection(users, userId, 'follows'), unfollowedUserId) await deleteDoc(followDoc) } @@ -259,7 +254,7 @@ export function listenForFollows( userId: string, setFollowIds: (followIds: string[]) => void ) { - const follows = collection(db, 'users', userId, 'follows') + const follows = collection(users, userId, 'follows') return listenForValues<{ userId: string }>(follows, (docs) => setFollowIds(docs.map(({ userId }) => userId)) ) diff --git a/web/lib/firebase/utils.ts b/web/lib/firebase/utils.ts index 1a9e13c5..e63c2d96 100644 --- a/web/lib/firebase/utils.ts +++ b/web/lib/firebase/utils.ts @@ -1,10 +1,17 @@ import { + collection, getDoc, getDocs, onSnapshot, Query, + CollectionReference, DocumentReference, } from 'firebase/firestore' +import { db } from './init' + +export const coll = <T>(path: string, ...rest: string[]) => { + return collection(db, path, ...rest) as CollectionReference<T> +} export const getValue = async <T>(doc: DocumentReference) => { const snap = await getDoc(doc) From 19d12c949a86bd2ae7145bd087cee1dd0bfceb55 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 29 Jun 2022 17:51:11 -0500 Subject: [PATCH 053/220] Add a line spacer on the sidebar --- web/components/nav/sidebar.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index aa0acf05..402f5e12 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -230,7 +230,12 @@ export default function Sidebar(props: { className?: string }) { buttonContent={<MoreButton />} /> - {memberItems.length > 0 && <Spacer h={6} />} + {/* Spacer if there are any groups */} + {memberItems.length > 0 && ( + <div className="py-3"> + <div className="h-[1px] bg-gray-300" /> + </div> + )} <GroupsList currentPage={currentPage} memberItems={memberItems} /> </div> </nav> From 7bbc4256901b4813b943166edb83c3acfda1da32 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 29 Jun 2022 17:54:08 -0500 Subject: [PATCH 054/220] Only show "My Groups" when there is at least 1 group --- web/pages/groups.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index a8f99b23..22fe7661 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -107,7 +107,7 @@ export default function Groups(props: { <Tabs tabs={[ - ...(user + ...(user && memberGroupIds.length > 0 ? [ { title: 'My Groups', From 2fbbc660297d8ed39c66e8dc53c1f687b72f6748 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 29 Jun 2022 16:31:53 -0700 Subject: [PATCH 055/220] Point v2 functions @ localhost during emulation (#597) --- web/lib/firebase/api-call.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index d46b3afa..7509a9f1 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -41,8 +41,13 @@ export async function call(url: string, method: string, params: any) { // one less hop export function getFunctionUrl(name: string) { - const { cloudRunId, cloudRunRegion } = ENV_CONFIG - return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app` + if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { + const { projectId, region } = ENV_CONFIG.firebaseConfig + return `http://localhost:5001/${projectId}/${region}/${name}` + } else { + const { cloudRunId, cloudRunRegion } = ENV_CONFIG + return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app` + } } export function createMarket(params: any) { From fc7f19e78512ab14db5160b8b0ec07d9dd70c3df Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 29 Jun 2022 16:47:06 -0700 Subject: [PATCH 056/220] Finalize v2 resolvemarket migration (#598) * Update resolve-market to be a v2 function * Cleanup API error responses * Update frontend to use v2 version of resolvemarket * Appease ESLint * Address review comments * Appease ESLint * Remove unnecessary auth check * Fix logic bug in FR market validation * Make it so you can specify runtime opts for v2 functions * Cleanup to resolve market API resolutions input, fixes * Fix up tiny lint * Last minute cleanup to resolvemarket FR API input validation Co-authored-by: Benjamin <ben@congdon.dev> --- common/payouts.ts | 18 +- common/scoring.ts | 2 +- functions/src/api.ts | 17 +- functions/src/create-contract.ts | 2 +- functions/src/create-group.ts | 2 +- functions/src/health.ts | 2 +- functions/src/index.ts | 2 +- functions/src/place-bet.ts | 2 +- functions/src/resolve-market.ts | 314 ++++++++++-------- .../src/scripts/pay-out-contract-again.ts | 2 +- functions/src/sell-bet.ts | 2 +- functions/src/sell-shares.ts | 2 +- .../answers/answer-resolve-panel.tsx | 32 +- web/components/numeric-resolution-panel.tsx | 27 +- web/components/resolution-panel.tsx | 27 +- web/lib/firebase/api-call.ts | 4 + web/lib/firebase/fn-call.ts | 11 - 17 files changed, 255 insertions(+), 213 deletions(-) diff --git a/common/payouts.ts b/common/payouts.ts index a3f105cf..f2c8d271 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -48,12 +48,12 @@ export type PayoutInfo = { export const getPayouts = ( outcome: string | undefined, - resolutions: { - [outcome: string]: number - }, contract: Contract, bets: Bet[], liquidities: LiquidityProvision[], + resolutions?: { + [outcome: string]: number + }, resolutionProbability?: number ): PayoutInfo => { if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') { @@ -67,9 +67,9 @@ export const getPayouts = ( } return getDpmPayouts( outcome, - resolutions, contract, bets, + resolutions, resolutionProbability ) } @@ -100,11 +100,11 @@ export const getFixedPayouts = ( export const getDpmPayouts = ( outcome: string | undefined, - resolutions: { - [outcome: string]: number - }, contract: DPMContract, bets: Bet[], + resolutions?: { + [outcome: string]: number + }, resolutionProbability?: number ): PayoutInfo => { const openBets = bets.filter((b) => !b.isSold && !b.sale) @@ -115,8 +115,8 @@ export const getDpmPayouts = ( return getDpmStandardPayouts(outcome, contract, openBets) case 'MKT': - return contract.outcomeType === 'FREE_RESPONSE' - ? getPayoutsMultiOutcome(resolutions, contract, openBets) + return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ? getPayoutsMultiOutcome(resolutions!, contract, openBets) : getDpmMktPayouts(contract, openBets, resolutionProbability) case 'CANCEL': case undefined: diff --git a/common/scoring.ts b/common/scoring.ts index d4e40267..39a342fd 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -42,10 +42,10 @@ export function scoreUsersByContract(contract: Contract, bets: Bet[]) { ) const { payouts: resolvePayouts } = getPayouts( resolution as string, - {}, contract, openBets, [], + {}, resolutionProb ) diff --git a/functions/src/api.ts b/functions/src/api.ts index f7efab5a..290ea3d8 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -108,7 +108,12 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => { } } -const DEFAULT_OPTS: HttpsOptions = { +interface EndpointOptions extends HttpsOptions { + methods?: string[] +} + +const DEFAULT_OPTS = { + methods: ['POST'], minInstances: 1, concurrency: 100, memory: '2GiB', @@ -116,12 +121,13 @@ const DEFAULT_OPTS: HttpsOptions = { cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], } -export const newEndpoint = (methods: [string], fn: Handler) => - onRequest(DEFAULT_OPTS, async (req, res) => { +export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { + const opts = Object.assign(endpointOpts, DEFAULT_OPTS) + return onRequest(opts, async (req, res) => { log('Request processing started.') try { - if (!methods.includes(req.method)) { - const allowed = methods.join(', ') + if (!opts.methods.includes(req.method)) { + const allowed = opts.methods.join(', ') throw new APIError(405, `This endpoint supports only ${allowed}.`) } const authedUser = await lookupUser(await parseCredentials(req)) @@ -140,3 +146,4 @@ export const newEndpoint = (methods: [string], fn: Handler) => } } }) +} diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 71d778b3..c9468fdc 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -50,7 +50,7 @@ const numericSchema = z.object({ max: z.number(), }) -export const createmarket = newEndpoint(['POST'], async (req, auth) => { +export const createmarket = newEndpoint({}, async (req, auth) => { const { question, description, tags, closeTime, outcomeType, groupId } = validate(bodySchema, req.body) diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index e7ee0cf5..a9626916 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -20,7 +20,7 @@ const bodySchema = z.object({ about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(), }) -export const creategroup = newEndpoint(['POST'], async (req, auth) => { +export const creategroup = newEndpoint({}, async (req, auth) => { const { name, about, memberIds, anyoneCanJoin } = validate( bodySchema, req.body diff --git a/functions/src/health.ts b/functions/src/health.ts index 6f4d73dc..938261db 100644 --- a/functions/src/health.ts +++ b/functions/src/health.ts @@ -1,6 +1,6 @@ import { newEndpoint } from './api' -export const health = newEndpoint(['GET'], async (_req, auth) => { +export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => { return { message: 'Server is working.', uid: auth.uid, diff --git a/functions/src/index.ts b/functions/src/index.ts index dcd50e66..726aba15 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -6,7 +6,6 @@ admin.initializeApp() // export * from './keep-awake' export * from './claim-manalink' export * from './transact' -export * from './resolve-market' export * from './stripe' export * from './create-user' export * from './create-answer' @@ -37,3 +36,4 @@ export * from './sell-shares' export * from './create-contract' export * from './withdraw-liquidity' export * from './create-group' +export * from './resolve-market' diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 1b5dd8bc..06d27668 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -33,7 +33,7 @@ const numericSchema = z.object({ value: z.number(), }) -export const placebet = newEndpoint(['POST'], async (req, auth) => { +export const placebet = newEndpoint({}, async (req, auth) => { log('Inside endpoint handler.') const { amount, contractId } = validate(bodySchema, req.body) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 43cb4839..b36ec3ef 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,8 +1,8 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' -import { Contract, resolution, RESOLUTIONS } from '../../common/contract' +import { Contract, RESOLUTIONS } from '../../common/contract' import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' @@ -15,156 +15,150 @@ import { } from '../../common/payouts' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' +import { APIError, newEndpoint, validate } from './api' -export const resolveMarket = functions - .runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) - .https.onCall( - async ( - data: { - outcome: resolution - value?: number - contractId: string - probabilityInt?: number - resolutions?: { [outcome: string]: number } - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string(), +}) - const { outcome, contractId, probabilityInt, resolutions, value } = data +const binarySchema = z.object({ + outcome: z.enum(RESOLUTIONS), + probabilityInt: z.number().gte(0).lt(100).optional(), +}) - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await contractDoc.get() - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract - const { creatorId, outcomeType, closeTime } = contract +const freeResponseSchema = z.union([ + z.object({ + outcome: z.literal('CANCEL'), + }), + z.object({ + outcome: z.literal('MKT'), + resolutions: z.array( + z.object({ + answer: z.number().int().nonnegative(), + pct: z.number().gte(0).lt(100), + }) + ), + }), + z.object({ + outcome: z.number().int().nonnegative(), + }), +]) - if (outcomeType === 'BINARY') { - if (!RESOLUTIONS.includes(outcome)) - return { status: 'error', message: 'Invalid outcome' } - } else if (outcomeType === 'FREE_RESPONSE') { - if ( - isNaN(+outcome) && - !(outcome === 'MKT' && resolutions) && - outcome !== 'CANCEL' - ) - return { status: 'error', message: 'Invalid outcome' } - } else if (outcomeType === 'NUMERIC') { - if (isNaN(+outcome) && outcome !== 'CANCEL') - return { status: 'error', message: 'Invalid outcome' } - } else { - return { status: 'error', message: 'Invalid contract outcomeType' } - } +const numericSchema = z.object({ + outcome: z.union([z.literal('CANCEL'), z.string()]), + value: z.number().optional(), +}) - if (value !== undefined && !isFinite(value)) - return { status: 'error', message: 'Invalid value' } +const opts = { secrets: ['MAILGUN_KEY'] } +export const resolvemarket = newEndpoint(opts, async (req, auth) => { + const { contractId } = validate(bodySchema, req.body) + const userId = auth.uid - if ( - outcomeType === 'BINARY' && - probabilityInt !== undefined && - (probabilityInt < 0 || - probabilityInt > 100 || - !isFinite(probabilityInt)) - ) - return { status: 'error', message: 'Invalid probability' } + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await contractDoc.get() + if (!contractSnap.exists) + throw new APIError(404, 'No contract exists with the provided ID') + const contract = contractSnap.data() as Contract + const { creatorId, outcomeType, closeTime } = contract - if (creatorId !== userId) - return { status: 'error', message: 'User not creator of contract' } - - if (contract.resolution) - return { status: 'error', message: 'Contract already resolved' } - - const creator = await getUser(creatorId) - if (!creator) return { status: 'error', message: 'Creator not found' } - - const resolutionProbability = - probabilityInt !== undefined ? probabilityInt / 100 : undefined - - const resolutionTime = Date.now() - const newCloseTime = closeTime - ? Math.min(closeTime, resolutionTime) - : closeTime - - const betsSnap = await firestore - .collection(`contracts/${contractId}/bets`) - .get() - - const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - - const liquiditiesSnap = await firestore - .collection(`contracts/${contractId}/liquidity`) - .get() - - const liquidities = liquiditiesSnap.docs.map( - (doc) => doc.data() as LiquidityProvision - ) - - const { payouts, creatorPayout, liquidityPayouts, collectedFees } = - getPayouts( - outcome, - resolutions ?? {}, - contract, - bets, - liquidities, - resolutionProbability - ) - - await contractDoc.update( - removeUndefinedProps({ - isResolved: true, - resolution: outcome, - resolutionValue: value, - resolutionTime, - closeTime: newCloseTime, - resolutionProbability, - resolutions, - collectedFees, - }) - ) - - console.log('contract ', contractId, 'resolved to:', outcome) - - const openBets = bets.filter((b) => !b.isSold && !b.sale) - const loanPayouts = getLoanPayouts(openBets) - - if (!isProd()) - console.log( - 'payouts:', - payouts, - 'creator payout:', - creatorPayout, - 'liquidity payout:' - ) - - if (creatorPayout) - await processPayouts( - [{ userId: creatorId, payout: creatorPayout }], - true - ) - - await processPayouts(liquidityPayouts, true) - - const result = await processPayouts([...payouts, ...loanPayouts]) - - const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) - - await sendResolutionEmails( - openBets, - userPayoutsWithoutLoans, - creator, - creatorPayout, - contract, - outcome, - resolutionProbability, - resolutions - ) - - return result - } + const { value, resolutions, probabilityInt, outcome } = getResolutionParams( + outcomeType, + req.body ) + if (creatorId !== userId) + throw new APIError(403, 'User is not creator of contract') + + if (contract.resolution) throw new APIError(400, 'Contract already resolved') + + const creator = await getUser(creatorId) + if (!creator) throw new APIError(500, 'Creator not found') + + const resolutionProbability = + probabilityInt !== undefined ? probabilityInt / 100 : undefined + + const resolutionTime = Date.now() + const newCloseTime = closeTime + ? Math.min(closeTime, resolutionTime) + : closeTime + + const betsSnap = await firestore + .collection(`contracts/${contractId}/bets`) + .get() + + const bets = betsSnap.docs.map((doc) => doc.data() as Bet) + + const liquiditiesSnap = await firestore + .collection(`contracts/${contractId}/liquidity`) + .get() + + const liquidities = liquiditiesSnap.docs.map( + (doc) => doc.data() as LiquidityProvision + ) + + const { payouts, creatorPayout, liquidityPayouts, collectedFees } = + getPayouts( + outcome, + contract, + bets, + liquidities, + resolutions, + resolutionProbability + ) + + const updatedContract = { + ...contract, + ...removeUndefinedProps({ + isResolved: true, + resolution: outcome, + resolutionValue: value, + resolutionTime, + closeTime: newCloseTime, + resolutionProbability, + resolutions, + collectedFees, + }), + } + + await contractDoc.update(updatedContract) + + console.log('contract ', contractId, 'resolved to:', outcome) + + const openBets = bets.filter((b) => !b.isSold && !b.sale) + const loanPayouts = getLoanPayouts(openBets) + + if (!isProd()) + console.log( + 'payouts:', + payouts, + 'creator payout:', + creatorPayout, + 'liquidity payout:' + ) + + if (creatorPayout) + await processPayouts([{ userId: creatorId, payout: creatorPayout }], true) + + await processPayouts(liquidityPayouts, true) + + await processPayouts([...payouts, ...loanPayouts]) + + const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) + + await sendResolutionEmails( + openBets, + userPayoutsWithoutLoans, + creator, + creatorPayout, + contract, + outcome, + resolutionProbability, + resolutions + ) + + return updatedContract +}) + const processPayouts = async (payouts: Payout[], isDeposit = false) => { const userPayouts = groupPayoutsByUser(payouts) @@ -221,4 +215,38 @@ const sendResolutionEmails = async ( ) } +function getResolutionParams(outcomeType: string, body: string) { + if (outcomeType === 'NUMERIC') { + return { + ...validate(numericSchema, body), + resolutions: undefined, + probabilityInt: undefined, + } + } else if (outcomeType === 'FREE_RESPONSE') { + const freeResponseParams = validate(freeResponseSchema, body) + const { outcome } = freeResponseParams + const resolutions = + 'resolutions' in freeResponseParams + ? Object.fromEntries( + freeResponseParams.resolutions.map((r) => [r.answer, r.pct]) + ) + : undefined + return { + // Free Response outcome IDs are numbers by convention, + // but treated as strings everywhere else. + outcome: outcome.toString(), + resolutions, + value: undefined, + probabilityInt: undefined, + } + } else if (outcomeType === 'BINARY') { + return { + ...validate(binarySchema, body), + value: undefined, + resolutions: undefined, + } + } + throw new APIError(500, `Invalid outcome type: ${outcomeType}`) +} + const firestore = admin.firestore() diff --git a/functions/src/scripts/pay-out-contract-again.ts b/functions/src/scripts/pay-out-contract-again.ts index 1686ebd9..a121889f 100644 --- a/functions/src/scripts/pay-out-contract-again.ts +++ b/functions/src/scripts/pay-out-contract-again.ts @@ -27,10 +27,10 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) { const { payouts } = getPayouts( resolution, - resolutions, contract, openBets, [], + resolutions, resolutionProbability ) diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index 419206c0..b3362159 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -13,7 +13,7 @@ const bodySchema = z.object({ betId: z.string(), }) -export const sellbet = newEndpoint(['POST'], async (req, auth) => { +export const sellbet = newEndpoint({}, async (req, auth) => { const { contractId, betId } = validate(bodySchema, req.body) // run as transaction to prevent race conditions diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index dd4e2ec5..26374a16 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -16,7 +16,7 @@ const bodySchema = z.object({ outcome: z.enum(['YES', 'NO']), }) -export const sellshares = newEndpoint(['POST'], async (req, auth) => { +export const sellshares = newEndpoint({}, async (req, auth) => { const { contractId, shares, outcome } = validate(bodySchema, req.body) // Run as transaction to prevent race conditions. diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index 81b94550..6b8e2885 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -1,10 +1,10 @@ import clsx from 'clsx' -import { sum, mapValues } from 'lodash' +import { sum } from 'lodash' import { useState } from 'react' import { Contract, FreeResponse } from 'common/contract' import { Col } from '../layout/col' -import { resolveMarket } from 'web/lib/firebase/fn-call' +import { APIError, resolveMarket } from 'web/lib/firebase/api-call' import { Row } from '../layout/row' import { ChooseCancelSelector } from '../yes-no-selector' import { ResolveConfirmationButton } from '../confirmation-button' @@ -31,30 +31,34 @@ export function AnswerResolvePanel(props: { setIsSubmitting(true) const totalProb = sum(Object.values(chosenAnswers)) - const normalizedProbs = mapValues( - chosenAnswers, - (prob) => (100 * prob) / totalProb - ) + const resolutions = Object.entries(chosenAnswers).map(([i, p]) => { + return { answer: parseInt(i), pct: (100 * p) / totalProb } + }) const resolutionProps = removeUndefinedProps({ outcome: resolveOption === 'CHOOSE' - ? answers[0] + ? parseInt(answers[0]) : resolveOption === 'CHOOSE_MULTIPLE' ? 'MKT' : 'CANCEL', resolutions: - resolveOption === 'CHOOSE_MULTIPLE' ? normalizedProbs : undefined, + resolveOption === 'CHOOSE_MULTIPLE' ? resolutions : undefined, contractId: contract.id, }) - const result = await resolveMarket(resolutionProps).then((r) => r.data) - - console.log('resolved', resolutionProps, 'result:', result) - - if (result?.status !== 'success') { - setError(result?.message || 'Error resolving market') + try { + const result = await resolveMarket(resolutionProps) + console.log('resolved', resolutionProps, 'result:', result) + } catch (e) { + if (e instanceof APIError) { + setError(e.toString()) + } else { + console.error(e) + setError('Error resolving market') + } } + setResolveOption(undefined) setIsSubmitting(false) } diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index f05a1c0a..ebac68e5 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -6,7 +6,7 @@ import { User } from 'web/lib/firebase/users' import { NumberCancelSelector } from './yes-no-selector' import { Spacer } from './layout/spacer' import { ResolveConfirmationButton } from './confirmation-button' -import { resolveMarket } from 'web/lib/firebase/fn-call' +import { APIError, resolveMarket } from 'web/lib/firebase/api-call' import { NumericContract } from 'common/contract' import { BucketInput } from './bucket-input' @@ -37,17 +37,22 @@ export function NumericResolutionPanel(props: { setIsSubmitting(true) - const result = await resolveMarket({ - outcome: finalOutcome, - value, - contractId: contract.id, - }).then((r) => r.data) - - console.log('resolved', outcome, 'result:', result) - - if (result?.status !== 'success') { - setError(result?.message || 'Error resolving market') + try { + const result = await resolveMarket({ + outcome: finalOutcome, + value, + contractId: contract.id, + }) + console.log('resolved', outcome, 'result:', result) + } catch (e) { + if (e instanceof APIError) { + setError(e.toString()) + } else { + console.error(e) + setError('Error resolving market') + } } + setIsSubmitting(false) } diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 8b453765..a46d9478 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -6,7 +6,7 @@ import { User } from 'web/lib/firebase/users' import { YesNoCancelSelector } from './yes-no-selector' import { Spacer } from './layout/spacer' import { ResolveConfirmationButton } from './confirmation-button' -import { resolveMarket } from 'web/lib/firebase/fn-call' +import { APIError, resolveMarket } from 'web/lib/firebase/api-call' import { ProbabilitySelector } from './probability-selector' import { DPM_CREATOR_FEE } from 'common/fees' import { getProbability } from 'common/calculate' @@ -42,17 +42,22 @@ export function ResolutionPanel(props: { setIsSubmitting(true) - const result = await resolveMarket({ - outcome, - contractId: contract.id, - probabilityInt: prob, - }).then((r) => r.data) - - console.log('resolved', outcome, 'result:', result) - - if (result?.status !== 'success') { - setError(result?.message || 'Error resolving market') + try { + const result = await resolveMarket({ + outcome, + contractId: contract.id, + probabilityInt: prob, + }) + console.log('resolved', outcome, 'result:', result) + } catch (e) { + if (e instanceof APIError) { + setError(e.toString()) + } else { + console.error(e) + setError('Error resolving market') + } } + setIsSubmitting(false) } diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 7509a9f1..e02872ae 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -54,6 +54,10 @@ export function createMarket(params: any) { return call(getFunctionUrl('createmarket'), 'POST', params) } +export function resolveMarket(params: any) { + return call(getFunctionUrl('resolvemarket'), 'POST', params) +} + export function placeBet(params: any) { return call(getFunctionUrl('placebet'), 'POST', params) } diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index e99bf393..ce78ac3a 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -29,17 +29,6 @@ export const createAnswer = cloudFunction< } >('createAnswer') -export const resolveMarket = cloudFunction< - { - outcome: string - value?: number - contractId: string - probabilityInt?: number - resolutions?: { [outcome: string]: number } - }, - { status: 'error' | 'success'; message?: string } ->('resolveMarket') - export const createUser: () => Promise<User | null> = () => { const local = safeLocalStorage() let deviceToken = local?.getItem('device-token') From a5a0a1370a23399446eeecf4d16233a94243e013 Mon Sep 17 00:00:00 2001 From: Ben Congdon <ben@congdon.dev> Date: Thu, 30 Jun 2022 08:07:28 -0700 Subject: [PATCH 057/220] Remove daily free market text from docs (#601) --- docs/docs/market-details.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/docs/market-details.md b/docs/docs/market-details.md index f7eeb0f6..9836b850 100644 --- a/docs/docs/market-details.md +++ b/docs/docs/market-details.md @@ -19,7 +19,6 @@ for the pool to be sorted into. - Users can create a market on any question they want. - When a user creates a market, they must choose a close date, after which trading will halt. - They must also pay a M$100 market creation fee, which is used as liquidity to subsidize trading on the market. - - The creation fee for the first market created each day is provided by Manifold. - The market creator will earn a commission on all bets placed in the market. - The market creator is responsible for resolving each market in a timely manner. All fees earned as a commission will be paid out after resolution. - Creators can also resolve N/A to cancel all transactions and reverse all transactions made on the market - this includes profits from selling shares. From c5efd5b7d00770ed78ed572ff2b006b8143300fe Mon Sep 17 00:00:00 2001 From: Ben Congdon <ben@congdon.dev> Date: Thu, 30 Jun 2022 15:11:45 -0700 Subject: [PATCH 058/220] Market Resolution API (#600) * Add market resolution API * Add additional free market resolution validation * Address review comments * Refactor resolution validation code somewhat Co-authored-by: Marshall Polaris <marshall@pol.rs> --- docs/docs/api.md | 55 +++++++++++++++- functions/src/resolve-market.ts | 66 ++++++++++++++----- web/lib/api/proxy.ts | 7 +- .../api/v0/market/{[id].ts => [id]/index.ts} | 2 +- web/pages/api/v0/market/[id]/resolve.ts | 28 ++++++++ 5 files changed, 138 insertions(+), 20 deletions(-) rename web/pages/api/v0/market/{[id].ts => [id]/index.ts} (93%) create mode 100644 web/pages/api/v0/market/[id]/resolve.ts diff --git a/docs/docs/api.md b/docs/docs/api.md index ffdaa65f..9172fc5a 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -456,7 +456,6 @@ Requires no authorization. } ``` - ### `POST /v0/bet` Places a new bet on behalf of the authorized user. @@ -514,6 +513,60 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat "initialProb":25}' ``` +### `POST /v0/market/[marketId]/resolve` + +Resolves a market on behalf of the authorized user. + +Parameters: + +For binary markets: + +- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`. +- `probabilityInt`: Optional. The probability to use for `MKT` resolution. + +For free response markets: + +- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the Free Response outcome ID. +- `resolutions`: A map of from outcome => number to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. + +For numeric markets: + +- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID. +- `value`: The value that the market may resolves to. + +Example request: + +``` +# Resolve a binary market +$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' + --data-raw '{"contractId":"{...}", \ + "outcome":"YES"}' + +# Resolve a binary market with a specified probability +$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' + --data-raw '{"contractId":"{...}", \ + "outcome":"MKT", + "probabilityInt": 75}' + +# Resolve a free response market with a single answer chosen +$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' + --data-raw '{"contractId":"{...}", \ + "outcome":"{...}"}' + +# Resolve a free response market with multiple answers chosen +$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' + --data-raw '{"contractId":"{...}", \ + "outcome":"MKT", + "resolutions": { + "{...}": 1, + "{...}": 2, + }}' +``` + ## Changelog - 2022-06-08: Add paging to markets endpoint diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index b36ec3ef..ee78dfec 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -2,7 +2,11 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' -import { Contract, RESOLUTIONS } from '../../common/contract' +import { + Contract, + FreeResponseContract, + RESOLUTIONS, +} from '../../common/contract' import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' @@ -59,10 +63,10 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { if (!contractSnap.exists) throw new APIError(404, 'No contract exists with the provided ID') const contract = contractSnap.data() as Contract - const { creatorId, outcomeType, closeTime } = contract + const { creatorId, closeTime } = contract const { value, resolutions, probabilityInt, outcome } = getResolutionParams( - outcomeType, + contract, req.body ) @@ -215,7 +219,8 @@ const sendResolutionEmails = async ( ) } -function getResolutionParams(outcomeType: string, body: string) { +function getResolutionParams(contract: Contract, body: string) { + const { outcomeType } = contract if (outcomeType === 'NUMERIC') { return { ...validate(numericSchema, body), @@ -225,19 +230,39 @@ function getResolutionParams(outcomeType: string, body: string) { } else if (outcomeType === 'FREE_RESPONSE') { const freeResponseParams = validate(freeResponseSchema, body) const { outcome } = freeResponseParams - const resolutions = - 'resolutions' in freeResponseParams - ? Object.fromEntries( - freeResponseParams.resolutions.map((r) => [r.answer, r.pct]) - ) - : undefined - return { - // Free Response outcome IDs are numbers by convention, - // but treated as strings everywhere else. - outcome: outcome.toString(), - resolutions, - value: undefined, - probabilityInt: undefined, + switch (outcome) { + case 'CANCEL': + return { + outcome: outcome.toString(), + resolutions: undefined, + value: undefined, + probabilityInt: undefined, + } + case 'MKT': { + const { resolutions } = freeResponseParams + resolutions.forEach(({ answer }) => validateAnswer(contract, answer)) + const pctSum = sumBy(resolutions, ({ pct }) => pct) + if (Math.abs(pctSum - 100) > 0.1) { + throw new APIError(400, 'Resolution percentages must sum to 100') + } + return { + outcome: outcome.toString(), + resolutions: Object.fromEntries( + resolutions.map((r) => [r.answer, r.pct]) + ), + value: undefined, + probabilityInt: undefined, + } + } + default: { + validateAnswer(contract, outcome) + return { + outcome: outcome.toString(), + resolutions: undefined, + value: undefined, + probabilityInt: undefined, + } + } } } else if (outcomeType === 'BINARY') { return { @@ -249,4 +274,11 @@ function getResolutionParams(outcomeType: string, body: string) { throw new APIError(500, `Invalid outcome type: ${outcomeType}`) } +function validateAnswer(contract: FreeResponseContract, answer: number) { + const validIds = contract.answers.map((a) => a.id) + if (!validIds.includes(answer.toString())) { + throw new APIError(400, `${answer} is not a valid answer ID`) + } +} + const firestore = admin.firestore() diff --git a/web/lib/api/proxy.ts b/web/lib/api/proxy.ts index ec027518..294868ac 100644 --- a/web/lib/api/proxy.ts +++ b/web/lib/api/proxy.ts @@ -41,7 +41,12 @@ export const fetchBackend = (req: NextApiRequest, name: string) => { 'Origin', ]) const hasBody = req.method != 'HEAD' && req.method != 'GET' - const opts = { headers, method: req.method, body: hasBody ? req : undefined } + const body = req.body ? JSON.stringify(req.body) : req + const opts = { + headers, + method: req.method, + body: hasBody ? body : undefined, + } return fetch(url, opts) } diff --git a/web/pages/api/v0/market/[id].ts b/web/pages/api/v0/market/[id]/index.ts similarity index 93% rename from web/pages/api/v0/market/[id].ts rename to web/pages/api/v0/market/[id]/index.ts index d1d676a3..eb238dab 100644 --- a/web/pages/api/v0/market/[id].ts +++ b/web/pages/api/v0/market/[id]/index.ts @@ -3,7 +3,7 @@ import { listAllBets } from 'web/lib/firebase/bets' import { listAllComments } from 'web/lib/firebase/comments' import { getContractFromId } from 'web/lib/firebase/contracts' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' -import { FullMarket, ApiError, toFullMarket } from '../_types' +import { FullMarket, ApiError, toFullMarket } from '../../_types' export default async function handler( req: NextApiRequest, diff --git a/web/pages/api/v0/market/[id]/resolve.ts b/web/pages/api/v0/market/[id]/resolve.ts new file mode 100644 index 00000000..1f291288 --- /dev/null +++ b/web/pages/api/v0/market/[id]/resolve.ts @@ -0,0 +1,28 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { + CORS_ORIGIN_MANIFOLD, + CORS_ORIGIN_LOCALHOST, +} from 'common/envs/constants' +import { applyCorsHeaders } from 'web/lib/api/cors' +import { fetchBackend, forwardResponse } from 'web/lib/api/proxy' + +export const config = { api: { bodyParser: true } } + +export default async function route(req: NextApiRequest, res: NextApiResponse) { + await applyCorsHeaders(req, res, { + origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], + methods: 'POST', + }) + + const { id } = req.query + const contractId = id as string + + if (req.body) req.body.contractId = contractId + try { + const backendRes = await fetchBackend(req, 'resolvemarket') + await forwardResponse(res, backendRes) + } catch (err) { + console.error('Error talking to cloud function: ', err) + res.status(500).json({ message: 'Error communicating with backend.' }) + } +} From 7fc1ec6bd2f8f379277cd747008ecd92c275af11 Mon Sep 17 00:00:00 2001 From: Ben Congdon <ben@congdon.dev> Date: Thu, 30 Jun 2022 15:13:59 -0700 Subject: [PATCH 059/220] Clear suggested FR answer after submission (#603) --- web/components/answers/create-answer-panel.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 6eeadf97..ed9012c9 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -58,6 +58,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { setText('') setBetAmount(10) setAmountError(undefined) + setPossibleDuplicateAnswer(undefined) } else setAmountError(result.message) } } From b0b8c6e98bf50c767758a1db395cb82c30de5248 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 30 Jun 2022 15:25:32 -0700 Subject: [PATCH 060/220] Make the resolve API docs not obviously wrong (#604) --- docs/docs/api.md | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/docs/api.md b/docs/docs/api.md index 9172fc5a..a8ac18fe 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -526,8 +526,8 @@ For binary markets: For free response markets: -- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the Free Response outcome ID. -- `resolutions`: A map of from outcome => number to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. +- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index. +- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. For numeric markets: @@ -538,33 +538,33 @@ Example request: ``` # Resolve a binary market -$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ - -H 'Authorization: Key {...}' - --data-raw '{"contractId":"{...}", \ - "outcome":"YES"}' +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "YES"}' # Resolve a binary market with a specified probability -$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ - -H 'Authorization: Key {...}' - --data-raw '{"contractId":"{...}", \ - "outcome":"MKT", +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "MKT", \ "probabilityInt": 75}' # Resolve a free response market with a single answer chosen -$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ - -H 'Authorization: Key {...}' - --data-raw '{"contractId":"{...}", \ - "outcome":"{...}"}' +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": 2}' # Resolve a free response market with multiple answers chosen -$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ - -H 'Authorization: Key {...}' - --data-raw '{"contractId":"{...}", \ - "outcome":"MKT", - "resolutions": { - "{...}": 1, - "{...}": 2, - }}' +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "MKT", \ + "resolutions": [ \ + {"answer": 0, "pct": 50}, \ + {"answer": 2, "pct": 50} \ + ]}' ``` ## Changelog From 3165e421190de8208b42d454f633df2cc7d1913f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 1 Jul 2022 07:47:19 -0600 Subject: [PATCH 061/220] Referrals (#592) * add trigger for updated user * Add referral bonuses and notifications for them * Cleanup * Add share group button, cleanup * Cleanup * Add referrals list to user profile * Remove unused * Referral bonus => constant * Refactor * Add referral txn to helper fn * Move reads into firebase transaction * Use effects to write referral info * Flex-wrap profile objects * Small ui changes * Restrict referral user to one update * Remove rogue semicolon * Note about group referral query details * Track referrals, add them to settings list --- common/notification.ts | 3 + common/txn.ts | 11 +- common/user.ts | 5 +- firestore.rules | 7 +- functions/src/create-notification.ts | 81 +++++---- functions/src/index.ts | 1 + functions/src/on-update-user.ts | 107 ++++++++++++ web/components/contract/contract-details.tsx | 8 + .../contract/contract-info-dialog.tsx | 14 +- web/components/groups/edit-group-button.tsx | 2 +- web/components/groups/group-chat.tsx | 11 +- web/components/referrals-button.tsx | 93 ++++++++++ web/components/share-icon-button.tsx | 70 ++++++++ web/components/user-page.tsx | 4 +- web/hooks/use-notifications.ts | 2 +- web/hooks/use-referrals.ts | 12 ++ web/lib/firebase/groups.ts | 16 +- web/lib/firebase/users.ts | 104 +++++++++++ web/pages/[username]/[contractSlug].tsx | 13 +- web/pages/group/[...slugs]/index.tsx | 61 +++++-- web/pages/notifications.tsx | 162 +++++------------- 21 files changed, 602 insertions(+), 185 deletions(-) create mode 100644 functions/src/on-update-user.ts create mode 100644 web/components/referrals-button.tsx create mode 100644 web/components/share-icon-button.tsx create mode 100644 web/hooks/use-referrals.ts diff --git a/common/notification.ts b/common/notification.ts index 919cf917..64a00a36 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -33,6 +33,7 @@ export type notification_source_types = | 'tip' | 'admin_message' | 'group' + | 'user' export type notification_source_update_types = | 'created' @@ -53,3 +54,5 @@ export type notification_reason_types = | 'on_new_follow' | 'you_follow_user' | 'added_you_to_group' + | 'you_referred_user' + | 'user_joined_to_bet_on_your_market' diff --git a/common/txn.ts b/common/txn.ts index 25d4a1c3..0e772e0d 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -1,6 +1,6 @@ // A txn (pronounced "texan") respresents a payment between two ids on Manifold // Shortened from "transaction" to distinguish from Firebase transactions (and save chars) -type AnyTxnType = Donation | Tip | Manalink +type AnyTxnType = Donation | Tip | Manalink | Referral type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn<T extends AnyTxnType = AnyTxnType> = { @@ -16,7 +16,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET' + category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' // | 'BET' // Any extra data data?: { [key: string]: any } @@ -46,6 +46,13 @@ type Manalink = { category: 'MANALINK' } +type Referral = { + fromType: 'BANK' + toType: 'USER' + category: 'REFERRAL' +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink +export type ReferralTxn = Txn & Referral diff --git a/common/user.ts b/common/user.ts index 298fee56..0a8565dd 100644 --- a/common/user.ts +++ b/common/user.ts @@ -33,11 +33,14 @@ export type User = { followerCountCached: number followedCategories?: string[] + + referredByUserId?: string + referredByContractId?: string } export const STARTING_BALANCE = 1000 export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person - +export const REFERRAL_AMOUNT = 500 export type PrivateUser = { id: string // same as User.id username: string // denormalized from User diff --git a/firestore.rules b/firestore.rules index 176cc71e..50df415a 100644 --- a/firestore.rules +++ b/firestore.rules @@ -20,7 +20,12 @@ service cloud.firestore { allow read; allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']); + .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']); + // only one referral allowed per user + allow update: if resource.data.id == request.auth.uid + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['referredByUserId']) + && !("referredByUserId" in resource.data); } match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index daf7e9d7..a32ed3bc 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -68,6 +68,7 @@ export const createNotification = async ( sourceContractCreatorUsername: sourceContract?.creatorUsername, // TODO: move away from sourceContractTitle to sourceTitle sourceContractTitle: sourceContract?.question, + // TODO: move away from sourceContractSlug to sourceSlug sourceContractSlug: sourceContract?.slug, sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, @@ -252,44 +253,62 @@ export const createNotification = async ( } } + const notifyUserReceivedReferralBonus = async ( + userToReasonTexts: user_to_reason_texts, + relatedUserId: string + ) => { + if (shouldGetNotification(relatedUserId, userToReasonTexts)) + userToReasonTexts[relatedUserId] = { + // If the referrer is the market creator, just tell them they joined to bet on their market + reason: + sourceContract?.creatorId === relatedUserId + ? 'user_joined_to_bet_on_your_market' + : 'you_referred_user', + } + } + const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. - if (sourceContract) { - if ( - sourceType === 'comment' || - sourceType === 'answer' || - (sourceType === 'contract' && - (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) - ) { - if (sourceType === 'comment') { - if (relatedUserId && relatedSourceType) - await notifyRepliedUsers( - userToReasonTexts, - relatedUserId, - relatedSourceType - ) - if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText) - } - await notifyContractCreator(userToReasonTexts, sourceContract) - await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) - await notifyLiquidityProviders(userToReasonTexts, sourceContract) - await notifyBettorsOnContract(userToReasonTexts, sourceContract) - await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) - } else if (sourceType === 'contract' && sourceUpdateType === 'created') { - await notifyUsersFollowers(userToReasonTexts) - } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { - await notifyContractCreator(userToReasonTexts, sourceContract, { - force: true, - }) - } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { - await notifyContractCreator(userToReasonTexts, sourceContract) - } - } else if (sourceType === 'follow' && relatedUserId) { + if (sourceType === 'follow' && relatedUserId) { await notifyFollowedUser(userToReasonTexts, relatedUserId) } else if (sourceType === 'group' && relatedUserId) { if (sourceUpdateType === 'created') await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) + } else if (sourceType === 'user' && relatedUserId) { + await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId) + } + + // The following functions need sourceContract to be defined. + if (!sourceContract) return userToReasonTexts + if ( + sourceType === 'comment' || + sourceType === 'answer' || + (sourceType === 'contract' && + (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) + ) { + if (sourceType === 'comment') { + if (relatedUserId && relatedSourceType) + await notifyRepliedUsers( + userToReasonTexts, + relatedUserId, + relatedSourceType + ) + if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText) + } + await notifyContractCreator(userToReasonTexts, sourceContract) + await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) + await notifyLiquidityProviders(userToReasonTexts, sourceContract) + await notifyBettorsOnContract(userToReasonTexts, sourceContract) + await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) + } else if (sourceType === 'contract' && sourceUpdateType === 'created') { + await notifyUsersFollowers(userToReasonTexts) + } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { + await notifyContractCreator(userToReasonTexts, sourceContract, { + force: true, + }) + } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { + await notifyContractCreator(userToReasonTexts, sourceContract) } return userToReasonTexts } diff --git a/functions/src/index.ts b/functions/src/index.ts index 726aba15..b643ff5e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -27,6 +27,7 @@ export * from './on-unfollow-user' export * from './on-create-liquidity-provision' export * from './on-update-group' export * from './on-create-group' +export * from './on-update-user' // v2 export * from './health' diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts new file mode 100644 index 00000000..2e5e2145 --- /dev/null +++ b/functions/src/on-update-user.ts @@ -0,0 +1,107 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { REFERRAL_AMOUNT, User } from '../../common/user' +import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' +import { createNotification } from './create-notification' +import { ReferralTxn } from '../../common/txn' +import { Contract } from '../../common/contract' +const firestore = admin.firestore() + +export const onUpdateUser = functions.firestore + .document('users/{userId}') + .onUpdate(async (change, context) => { + const prevUser = change.before.data() as User + const user = change.after.data() as User + const { eventId } = context + + if (prevUser.referredByUserId !== user.referredByUserId) { + await handleUserUpdatedReferral(user, eventId) + } + }) + +async function handleUserUpdatedReferral(user: User, eventId: string) { + // Only create a referral txn if the user has a referredByUserId + if (!user.referredByUserId) { + console.log(`Not set: referredByUserId ${user.referredByUserId}`) + return + } + const referredByUserId = user.referredByUserId + + await firestore.runTransaction(async (transaction) => { + // get user that referred this user + const referredByUserDoc = firestore.doc(`users/${referredByUserId}`) + const referredByUserSnap = await transaction.get(referredByUserDoc) + if (!referredByUserSnap.exists) { + console.log(`User ${referredByUserId} not found`) + return + } + const referredByUser = referredByUserSnap.data() as User + + let referredByContract: Contract | undefined = undefined + if (user.referredByContractId) { + const referredByContractDoc = firestore.doc( + `contracts/${user.referredByContractId}` + ) + referredByContract = await transaction + .get(referredByContractDoc) + .then((snap) => snap.data() as Contract) + } + console.log(`referredByContract: ${referredByContract}`) + + const txns = ( + await firestore + .collection('txns') + .where('toId', '==', referredByUserId) + .where('category', '==', 'REFERRAL') + .get() + ).docs.map((txn) => txn.ref) + const referralTxns = await transaction.getAll(...txns).catch((err) => { + console.error('error getting txns:', err) + throw err + }) + // If the referring user already has a referral txn due to referring this user, halt + if (referralTxns.map((txn) => txn.data()?.description).includes(user.id)) { + console.log('found referral txn with the same details, aborting') + return + } + console.log('creating referral txns') + const fromId = HOUSE_LIQUIDITY_PROVIDER_ID + + // if they're updating their referredId, create a txn for both + const txn: ReferralTxn = { + id: eventId, + createdTime: Date.now(), + fromId, + fromType: 'BANK', + toId: referredByUserId, + toType: 'USER', + amount: REFERRAL_AMOUNT, + token: 'M$', + category: 'REFERRAL', + description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`, + } + + const txnDoc = await firestore.collection(`txns/`).doc(txn.id) + await transaction.set(txnDoc, txn) + console.log('created referral with txn id:', txn.id) + // We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes. + transaction.update(referredByUserDoc, { + balance: referredByUser.balance + REFERRAL_AMOUNT, + totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT, + }) + + await createNotification( + user.id, + 'user', + 'updated', + user, + eventId, + txn.amount.toString(), + referredByContract, + 'user', + referredByUser.id, + referredByContract?.slug, + referredByContract?.question + ) + }) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 03925a35..3512efa2 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -29,6 +29,8 @@ import { groupPath } from 'web/lib/firebase/groups' import { SiteLink } from 'web/components/site-link' import { DAY_MS } from 'common/util/time' import { useGroupsWithContract } from 'web/hooks/use-group' +import { ShareIconButton } from 'web/components/share-icon-button' +import { useUser } from 'web/hooks/use-user' export type ShowTime = 'resolve-date' | 'close-date' @@ -130,6 +132,7 @@ export function ContractDetails(props: { const { volumeLabel, resolvedDate } = contractMetrics(contract) // Find a group that this contract id is in const groups = useGroupsWithContract(contract.id) + const user = useUser() return ( <Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500"> <Row className="items-center gap-2"> @@ -192,6 +195,11 @@ export function ContractDetails(props: { <div className="whitespace-nowrap">{volumeLabel}</div> </Row> + <ShareIconButton + contract={contract} + toastClassName={'sm:-left-40 -left-24 min-w-[250%]'} + username={user?.username} + /> {!disabled && <ContractInfoDialog contract={contract} bets={bets} />} </Row> diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 7027d06a..12fd8dd9 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -13,7 +13,6 @@ import { getBinaryProbPercent, } from 'web/lib/firebase/contracts' import { LiquidityPanel } from '../liquidity-panel' -import { CopyLinkButton } from '../copy-link-button' import { Col } from '../layout/col' import { Modal } from '../layout/modal' import { Row } from '../layout/row' @@ -23,6 +22,9 @@ import { TweetButton } from '../tweet-button' import { InfoTooltip } from '../info-tooltip' import { TagsInput } from 'web/components/tags-input' +export const contractDetailsButtonClassName = + 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' + export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props @@ -48,13 +50,11 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { return ( <> <button - 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" + className={contractDetailsButtonClassName} onClick={() => setOpen(true)} > <DotsHorizontalIcon - className={clsx( - 'h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500' - )} + className={clsx('h-6 w-6 flex-shrink-0')} aria-hidden="true" /> </button> @@ -66,10 +66,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { <div>Share</div> <Row className="justify-start gap-4"> - <CopyLinkButton - contract={contract} - toastClassName={'sm:-left-10 -left-4 min-w-[250%]'} - /> <TweetButton className="self-start" tweetText={getTweetText(contract, false)} diff --git a/web/components/groups/edit-group-button.tsx b/web/components/groups/edit-group-button.tsx index 902cc8cc..6ad7237a 100644 --- a/web/components/groups/edit-group-button.tsx +++ b/web/components/groups/edit-group-button.tsx @@ -46,7 +46,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) { <div className={clsx('flex p-1', className)}> <div className={clsx( - 'btn-ghost cursor-pointer whitespace-nowrap rounded-full text-sm text-white' + 'btn-ghost cursor-pointer whitespace-nowrap rounded-md p-1 text-sm text-gray-700' )} onClick={() => updateOpen(!open)} > diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 5dedbc8f..114a9003 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -91,6 +91,9 @@ export function GroupChat(props: { setReplyToUsername('') inputRef?.focus() } + function focusInput() { + inputRef?.focus() + } return ( <Col className={'flex-1'}> @@ -117,7 +120,13 @@ export function GroupChat(props: { ))} {messages.length === 0 && ( <div className="p-2 text-gray-500"> - No messages yet. 🦗... Why not say something? + No messages yet. Why not{' '} + <button + className={'cursor-pointer font-bold text-gray-700'} + onClick={() => focusInput()} + > + add one? + </button> </div> )} </Col> diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx new file mode 100644 index 00000000..c23958fc --- /dev/null +++ b/web/components/referrals-button.tsx @@ -0,0 +1,93 @@ +import clsx from 'clsx' +import { User } from 'common/user' +import { useEffect, useState } from 'react' +import { prefetchUsers, useUserById } from 'web/hooks/use-user' +import { Col } from './layout/col' +import { Modal } from './layout/modal' +import { Tabs } from './layout/tabs' +import { TextButton } from './text-button' +import { Row } from 'web/components/layout/row' +import { Avatar } from 'web/components/avatar' +import { UserLink } from 'web/components/user-page' +import { useReferrals } from 'web/hooks/use-referrals' + +export function ReferralsButton(props: { user: User }) { + const { user } = props + const [isOpen, setIsOpen] = useState(false) + const referralIds = useReferrals(user.id) + + return ( + <> + <TextButton onClick={() => setIsOpen(true)}> + <span className="font-semibold">{referralIds?.length ?? ''}</span>{' '} + Referrals + </TextButton> + + <ReferralsDialog + user={user} + referralIds={referralIds ?? []} + isOpen={isOpen} + setIsOpen={setIsOpen} + /> + </> + ) +} + +function ReferralsDialog(props: { + user: User + referralIds: string[] + isOpen: boolean + setIsOpen: (isOpen: boolean) => void +}) { + const { user, referralIds, isOpen, setIsOpen } = props + + useEffect(() => { + prefetchUsers(referralIds) + }, [referralIds]) + + return ( + <Modal open={isOpen} setOpen={setIsOpen}> + <Col className="rounded bg-white p-6"> + <div className="p-2 pb-1 text-xl">{user.name}</div> + <div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div> + <Tabs + tabs={[ + { + title: 'Referrals', + content: <ReferralsList userIds={referralIds} />, + }, + ]} + /> + </Col> + </Modal> + ) +} + +function ReferralsList(props: { userIds: string[] }) { + const { userIds } = props + + return ( + <Col className="gap-2"> + {userIds.length === 0 && ( + <div className="text-gray-500">No users yet...</div> + )} + {userIds.map((userId) => ( + <UserReferralItem key={userId} userId={userId} /> + ))} + </Col> + ) +} + +function UserReferralItem(props: { userId: string; className?: string }) { + const { userId, className } = props + const user = useUserById(userId) + + return ( + <Row className={clsx('items-center justify-between gap-2 p-2', className)}> + <Row className="items-center gap-2"> + <Avatar username={user?.username} avatarUrl={user?.avatarUrl} /> + {user && <UserLink name={user.name} username={user.username} />} + </Row> + </Row> + ) +} diff --git a/web/components/share-icon-button.tsx b/web/components/share-icon-button.tsx new file mode 100644 index 00000000..507d90c2 --- /dev/null +++ b/web/components/share-icon-button.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react' +import { ShareIcon } from '@heroicons/react/outline' +import clsx from 'clsx' + +import { Contract } from 'common/contract' +import { copyToClipboard } from 'web/lib/util/copy' +import { contractPath } from 'web/lib/firebase/contracts' +import { ENV_CONFIG } from 'common/envs/constants' +import { ToastClipboard } from 'web/components/toast-clipboard' +import { track } from 'web/lib/service/analytics' +import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog' +import { Group } from 'common/group' +import { groupPath } from 'web/lib/firebase/groups' + +function copyContractWithReferral(contract: Contract, username?: string) { + const postFix = + username && contract.creatorUsername !== username + ? '?referrer=' + username + : '' + copyToClipboard( + `https://${ENV_CONFIG.domain}${contractPath(contract)}${postFix}` + ) +} + +// Note: if a user arrives at a /group endpoint with a ?referral= query, they'll be added to the group automatically +function copyGroupWithReferral(group: Group, username?: string) { + const postFix = username ? '?referrer=' + username : '' + copyToClipboard( + `https://${ENV_CONFIG.domain}${groupPath(group.slug)}${postFix}` + ) +} + +export function ShareIconButton(props: { + contract?: Contract + group?: Group + buttonClassName?: string + toastClassName?: string + username?: string + children?: React.ReactNode +}) { + const { + contract, + buttonClassName, + toastClassName, + username, + group, + children, + } = props + const [showToast, setShowToast] = useState(false) + + return ( + <div className="relative z-10 flex-shrink-0"> + <button + className={clsx(contractDetailsButtonClassName, buttonClassName)} + onClick={() => { + if (contract) copyContractWithReferral(contract, username) + if (group) copyGroupWithReferral(group, username) + track('copy share link') + setShowToast(true) + setTimeout(() => setShowToast(false), 2000) + }} + > + <ShareIcon className="h-[24px] w-5" aria-hidden="true" /> + {children} + </button> + + {showToast && <ToastClipboard className={toastClassName} />} + </div> + ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 246ed2aa..ac9fe8fd 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -36,6 +36,7 @@ import { FollowersButton, FollowingButton } from './following-button' import { useFollows } from 'web/hooks/use-follows' import { FollowButton } from './follow-button' import { PortfolioMetrics } from 'common/user' +import { ReferralsButton } from 'web/components/referrals-button' import { GroupsButton } from 'web/components/groups/groups-button' export function UserLink(props: { @@ -194,10 +195,11 @@ export function UserPage(props: { </> )} - <Col className="gap-2 sm:flex-row sm:items-center sm:gap-4"> + <Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4"> <Row className="gap-4"> <FollowingButton user={user} /> <FollowersButton user={user} /> + <ReferralsButton user={user} /> <GroupsButton user={user} /> </Row> diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 051d6cbb..c947e8d0 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -117,7 +117,7 @@ function getAppropriateNotifications( return notifications.filter( (n) => n.reason && - // Show all contract notifications + // Show all contract notifications and any that aren't in the above list: (n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason)) ) if (notificationPreferences === 'none') return [] diff --git a/web/hooks/use-referrals.ts b/web/hooks/use-referrals.ts new file mode 100644 index 00000000..0feba62c --- /dev/null +++ b/web/hooks/use-referrals.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react' +import { listenForReferrals } from 'web/lib/firebase/users' + +export const useReferrals = (userId: string | null | undefined) => { + const [referralIds, setReferralIds] = useState<string[] | undefined>() + + useEffect(() => { + if (userId) return listenForReferrals(userId, setReferralIds) + }, [userId]) + + return referralIds +} diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 36b05452..506849ad 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -6,7 +6,7 @@ import { updateDoc, where, } from 'firebase/firestore' -import { sortBy } from 'lodash' +import { sortBy, uniq } from 'lodash' import { Group } from 'common/group' import { getContractFromId } from './contracts' import { @@ -95,6 +95,16 @@ export async function getGroupsWithContractId( setGroups(await getValues<Group>(q)) } +export async function addUserToGroupViaSlug(groupSlug: string, userId: string) { + // get group to get the member ids + const group = await getGroupBySlug(groupSlug) + if (!group) { + console.error(`Group not found: ${groupSlug}`) + return + } + return await joinGroup(group, userId) +} + export async function joinGroup(group: Group, userId: string): Promise<Group> { const { memberIds } = group if (memberIds.includes(userId)) { @@ -102,7 +112,7 @@ export async function joinGroup(group: Group, userId: string): Promise<Group> { } const newMemberIds = [...memberIds, userId] const newGroup = { ...group, memberIds: newMemberIds } - await updateGroup(newGroup, { memberIds: newMemberIds }) + await updateGroup(newGroup, { memberIds: uniq(newMemberIds) }) return newGroup } export async function leaveGroup(group: Group, userId: string): Promise<Group> { @@ -112,6 +122,6 @@ export async function leaveGroup(group: Group, userId: string): Promise<Group> { } const newMemberIds = memberIds.filter((id) => id !== userId) const newGroup = { ...group, memberIds: newMemberIds } - await updateGroup(newGroup, { memberIds: newMemberIds }) + await updateGroup(newGroup, { memberIds: uniq(newMemberIds) }) return newGroup } diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 40be6741..e72fe141 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -36,6 +36,10 @@ import { feed } from 'common/feed' import { CATEGORY_LIST } from 'common/categories' import { safeLocalStorage } from '../util/local' import { filterDefined } from 'common/util/array' +import { addUserToGroupViaSlug } from 'web/lib/firebase/groups' +import { removeUndefinedProps } from 'common/util/object' +import dayjs from 'dayjs' +import { track } from '@amplitude/analytics-browser' export const users = coll<User>('users') export const privateUsers = coll<PrivateUser>('private-users') @@ -90,12 +94,92 @@ export function listenForPrivateUser( } const CACHED_USER_KEY = 'CACHED_USER_KEY' +const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY' +const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY' +const CACHED_REFERRAL_GROUP_SLUG_KEY = 'CACHED_REFERRAL_GROUP_KEY' // used to avoid weird race condition let createUserPromise: Promise<User | null> | undefined = undefined const warmUpCreateUser = throttle(createUser, 5000 /* ms */) +export function writeReferralInfo( + defaultReferrerUsername: string, + contractId?: string, + referralUsername?: string, + groupSlug?: string +) { + const local = safeLocalStorage() + const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY) + // Write the first referral username we see. + if (!cachedReferralUser) + local?.setItem( + CACHED_REFERRAL_USERNAME_KEY, + referralUsername || defaultReferrerUsername + ) + + // If an explicit referral query is passed, overwrite the cached referral username. + if (referralUsername) + local?.setItem(CACHED_REFERRAL_USERNAME_KEY, referralUsername) + + // Always write the most recent explicit group invite query value + if (groupSlug) local?.setItem(CACHED_REFERRAL_GROUP_SLUG_KEY, groupSlug) + + // Write the first contract id that we see. + const cachedReferralContract = local?.getItem(CACHED_REFERRAL_CONTRACT_ID_KEY) + if (!cachedReferralContract && contractId) + local?.setItem(CACHED_REFERRAL_CONTRACT_ID_KEY, contractId) +} + +async function setCachedReferralInfoForUser(user: User | null) { + if (!user || user.referredByUserId) return + // if the user wasn't created in the last minute, don't bother + const now = dayjs().utc() + const userCreatedTime = dayjs(user.createdTime) + if (now.diff(userCreatedTime, 'minute') > 1) return + + const local = safeLocalStorage() + const cachedReferralUsername = local?.getItem(CACHED_REFERRAL_USERNAME_KEY) + const cachedReferralContractId = local?.getItem( + CACHED_REFERRAL_CONTRACT_ID_KEY + ) + const cachedReferralGroupSlug = local?.getItem(CACHED_REFERRAL_GROUP_SLUG_KEY) + + // get user via username + if (cachedReferralUsername) + getUserByUsername(cachedReferralUsername).then((referredByUser) => { + if (!referredByUser) return + // update user's referralId + updateUser( + user.id, + removeUndefinedProps({ + referredByUserId: referredByUser.id, + referredByContractId: cachedReferralContractId + ? cachedReferralContractId + : undefined, + }) + ) + .catch((err) => { + console.log('error setting referral details', err) + }) + .then(() => { + track('Referral', { + userId: user.id, + referredByUserId: referredByUser.id, + referredByContractId: cachedReferralContractId, + referredByGroupSlug: cachedReferralGroupSlug, + }) + }) + }) + + if (cachedReferralGroupSlug) + addUserToGroupViaSlug(cachedReferralGroupSlug, user.id) + + local?.removeItem(CACHED_REFERRAL_GROUP_SLUG_KEY) + local?.removeItem(CACHED_REFERRAL_USERNAME_KEY) + local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY) +} + export function listenForLogin(onUser: (user: User | null) => void) { const local = safeLocalStorage() const cachedUser = local?.getItem(CACHED_USER_KEY) @@ -119,6 +203,7 @@ export function listenForLogin(onUser: (user: User | null) => void) { // Persist to local storage, to reduce login blink next time. // Note: Cap on localStorage size is ~5mb local?.setItem(CACHED_USER_KEY, JSON.stringify(user)) + setCachedReferralInfoForUser(user) } else { // User logged out; reset to null onUser(null) @@ -279,3 +364,22 @@ export function listenForFollowers( } ) } +export function listenForReferrals( + userId: string, + setReferralIds: (referralIds: string[]) => void +) { + const referralsQuery = query( + collection(db, 'users'), + where('referredByUserId', '==', userId) + ) + return onSnapshot( + referralsQuery, + { includeMetadataChanges: true }, + (snapshot) => { + if (snapshot.metadata.fromCache) return + + const values = snapshot.docs.map((doc) => doc.ref.id) + setReferralIds(filterDefined(values)) + } + ) +} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 24982b4f..413de725 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -10,7 +10,7 @@ import { useUser } from 'web/hooks/use-user' import { ResolutionPanel } from 'web/components/resolution-panel' import { Title } from 'web/components/title' import { Spacer } from 'web/components/layout/spacer' -import { listUsers, User } from 'web/lib/firebase/users' +import { listUsers, User, writeReferralInfo } from 'web/lib/firebase/users' import { Contract, getContractFromSlug, @@ -42,6 +42,7 @@ import { useBets } from 'web/hooks/use-bets' import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' +import { useRouter } from 'next/router' import { useLiquidity } from 'web/hooks/use-liquidity' export const getStaticProps = fromPropz(getStaticPropz) @@ -150,6 +151,16 @@ export function ContractPageContent( const ogCardProps = getOpenGraphProps(contract) + const router = useRouter() + + useEffect(() => { + const { referrer } = router.query as { + referrer?: string + } + if (!user && router.isReady) + writeReferralInfo(contract.creatorUsername, contract.id, referrer) + }, [user, contract, router]) + const rightSidebar = hasSidePanel ? ( <Col className="gap-4"> {allowTrade && diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index a3b99128..3a3db14d 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -13,7 +13,12 @@ import { } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' -import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' +import { + firebaseLogin, + getUser, + User, + writeReferralInfo, +} from 'web/lib/firebase/users' import { Spacer } from 'web/components/layout/spacer' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' @@ -40,6 +45,9 @@ import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' import { useCommentsOnGroup } from 'web/hooks/use-comments' import ShortToggle from 'web/components/widgets/short-toggle' +import { ShareIconButton } from 'web/components/share-icon-button' +import { REFERRAL_AMOUNT } from 'common/user' +import { SiteLink } from 'web/components/site-link' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -150,6 +158,14 @@ export default function GroupPage(props: { }, [group]) const user = useUser() + useEffect(() => { + const { referrer } = router.query as { + referrer?: string + } + if (!user && router.isReady) + writeReferralInfo(creator.username, undefined, referrer, group?.slug) + }, [user, creator, group, router]) + if (group === null || !groupSubpages.includes(page) || slugs[2]) { return <Custom404 /> } @@ -257,7 +273,13 @@ export default function GroupPage(props: { </> ) : ( <div className="p-2 text-gray-500"> - No questions yet. 🦗... Why not add one? + No questions yet. Why not{' '} + <SiteLink + href={`/create/?groupId=${group.id}`} + className={'font-bold text-gray-700'} + > + add one? + </SiteLink> </div> ) ) : ( @@ -321,18 +343,17 @@ function GroupOverview(props: { return ( <Col> - <Row className="items-center justify-end rounded-t bg-indigo-500 px-4 py-3 text-sm text-white"> - <Row className="flex-1 justify-start">About {group.name}</Row> - {isCreator && <EditGroupButton className={'ml-1'} group={group} />} - </Row> <Col className="gap-2 rounded-b bg-white p-4"> - <Row> - <div className="mr-1 text-gray-500">Created by</div> - <UserLink - className="text-neutral" - name={creator.name} - username={creator.username} - /> + <Row className={'flex-wrap justify-between'}> + <div className={'inline-flex items-center'}> + <div className="mr-1 text-gray-500">Created by</div> + <UserLink + className="text-neutral" + name={creator.name} + username={creator.username} + /> + </div> + {isCreator && <EditGroupButton className={'ml-1'} group={group} />} </Row> <Row className={'items-center gap-1'}> <span className={'text-gray-500'}>Membership</span> @@ -352,6 +373,20 @@ function GroupOverview(props: { </span> )} </Row> + {anyoneCanJoin && user && ( + <Row className={'flex-wrap items-center gap-1'}> + <span className={'text-gray-500'}>Sharing</span> + <ShareIconButton + group={group} + username={user.username} + buttonClassName={'hover:bg-gray-300 mt-1 !text-gray-700'} + > + <span className={'mx-2'}> + Invite a friend and get M${REFERRAL_AMOUNT} if they sign up! + </span> + </ShareIconButton> + </Row> + )} </Col> </Col> ) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index a3af0a9a..9b0216b6 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -14,9 +14,6 @@ import { Title } from 'web/components/title' import { doc, updateDoc } from 'firebase/firestore' import { db } from 'web/lib/firebase/init' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' -import { Answer } from 'common/answer' -import { Comment } from 'web/lib/firebase/comments' -import { getValue } from 'web/lib/firebase/utils' import Custom404 from 'web/pages/404' import { UserLink } from 'web/components/user-page' import { notification_subscribe_types, PrivateUser } from 'common/user' @@ -38,7 +35,6 @@ import { NotificationGroup, usePreferredGroupedNotifications, } from 'web/hooks/use-notifications' -import { getContractFromId } from 'web/lib/firebase/contracts' import { CheckIcon, XIcon } from '@heroicons/react/outline' import toast from 'react-hot-toast' import { formatMoney } from 'common/util/format' @@ -182,7 +178,7 @@ function NotificationGroupItem(props: { className?: string }) { const { notificationGroup, className } = props - const { sourceContractId, notifications } = notificationGroup + const { notifications } = notificationGroup const { sourceContractTitle, sourceContractSlug, @@ -191,28 +187,6 @@ function NotificationGroupItem(props: { const numSummaryLines = 3 const [expanded, setExpanded] = useState(false) - const [contract, setContract] = useState<Contract | undefined>(undefined) - - useEffect(() => { - if ( - sourceContractTitle && - sourceContractSlug && - sourceContractCreatorUsername - ) - return - if (sourceContractId) { - getContractFromId(sourceContractId) - .then((contract) => { - if (contract) setContract(contract) - }) - .catch((e) => console.log(e)) - } - }, [ - sourceContractCreatorUsername, - sourceContractId, - sourceContractSlug, - sourceContractTitle, - ]) useEffect(() => { setNotificationsAsSeen(notifications) @@ -240,20 +214,20 @@ function NotificationGroupItem(props: { onClick={() => setExpanded(!expanded)} className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'} > - {sourceContractTitle || contract ? ( + {sourceContractTitle ? ( <span> {'Activity on '} <a href={ sourceContractCreatorUsername ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` - : `/${contract?.creatorUsername}/${contract?.slug}` + : '' } className={ 'font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' } > - {sourceContractTitle || contract?.question} + {sourceContractTitle} </a> </span> ) : ( @@ -306,6 +280,7 @@ function NotificationGroupItem(props: { ) } +// TODO: where should we put referral bonus notifications? function NotificationSettings() { const user = useUser() const [notificationSettings, setNotificationSettings] = @@ -455,6 +430,10 @@ function NotificationSettings() { highlight={notificationSettings !== 'none'} label={"Activity on questions you're betting on"} /> + <NotificationSettingLine + highlight={notificationSettings !== 'none'} + label={"Referral bonuses you've received"} + /> <NotificationSettingLine label={"Activity on questions you've ever bet or commented on"} highlight={notificationSettings === 'all'} @@ -515,7 +494,6 @@ function NotificationItem(props: { const { notification, justSummary } = props const { sourceType, - sourceContractId, sourceId, sourceUserName, sourceUserAvatarUrl, @@ -534,60 +512,15 @@ function NotificationItem(props: { const [defaultNotificationText, setDefaultNotificationText] = useState<string>('') - const [contract, setContract] = useState<Contract | null>(null) - - useEffect(() => { - if ( - !sourceContractId || - (sourceContractSlug && sourceContractCreatorUsername) - ) - return - getContractFromId(sourceContractId) - .then((contract) => { - if (contract) setContract(contract) - }) - .catch((e) => console.log(e)) - }, [ - sourceContractCreatorUsername, - sourceContractId, - sourceContractSlug, - sourceContractTitle, - ]) useEffect(() => { if (sourceText) { setDefaultNotificationText(sourceText) - } else if (!contract || !sourceContractId || !sourceId) return - else if ( - sourceType === 'answer' || - sourceType === 'comment' || - sourceType === 'contract' - ) { - try { - parseOldStyleNotificationText( - sourceId, - sourceContractId, - sourceType, - sourceUpdateType, - setDefaultNotificationText, - contract - ) - } catch (err) { - console.error(err) - } } else if (reasonText) { // Handle arbitrary notifications with reason text here. setDefaultNotificationText(reasonText) } - }, [ - contract, - reasonText, - sourceContractId, - sourceId, - sourceText, - sourceType, - sourceUpdateType, - ]) + }, [reasonText, sourceText]) useEffect(() => { setNotificationsAsSeen([notification]) @@ -596,14 +529,16 @@ function NotificationItem(props: { function getSourceUrl() { if (sourceType === 'follow') return `/${sourceUserUsername}` if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}` + if ( + sourceContractCreatorUsername && + sourceContractSlug && + sourceType === 'user' + ) + return `/${sourceContractCreatorUsername}/${sourceContractSlug}` if (sourceContractCreatorUsername && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( sourceId ?? '' )}` - if (!contract) return '' - return `/${contract.creatorUsername}/${ - contract.slug - }#${getSourceIdForLinkComponent(sourceId ?? '')}` } function getSourceIdForLinkComponent(sourceId: string) { @@ -619,38 +554,6 @@ function NotificationItem(props: { } } - async function parseOldStyleNotificationText( - sourceId: string, - sourceContractId: string, - sourceType: 'answer' | 'comment' | 'contract', - sourceUpdateType: notification_source_update_types | undefined, - setText: (text: string) => void, - contract: Contract - ) { - if (sourceType === 'contract') { - if ( - isNotificationAboutContractResolution( - sourceType, - sourceUpdateType, - contract - ) && - contract.resolution - ) - setText(contract.resolution) - else setText(contract.question) - } else if (sourceType === 'answer') { - const answer = await getValue<Answer>( - doc(db, `contracts/${sourceContractId}/answers/`, sourceId) - ) - setText(answer?.text ?? '') - } else { - const comment = await getValue<Comment>( - doc(db, `contracts/${sourceContractId}/comments/`, sourceId) - ) - setText(comment?.text ?? '') - } - } - if (justSummary) { return ( <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> @@ -669,13 +572,13 @@ function NotificationItem(props: { sourceType, reason, sourceUpdateType, - contract, + undefined, true ).replace(' on', '')} </span> <div className={'ml-1 text-black'}> <NotificationTextLabel - contract={contract} + contract={null} defaultText={defaultNotificationText} className={'line-clamp-1'} notification={notification} @@ -717,7 +620,9 @@ function NotificationItem(props: { sourceType, reason, sourceUpdateType, - contract + undefined, + false, + sourceSlug )} <a href={ @@ -725,13 +630,13 @@ function NotificationItem(props: { ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` : sourceType === 'group' && sourceSlug ? `${groupPath(sourceSlug)}` - : `/${contract?.creatorUsername}/${contract?.slug}` + : '' } className={ 'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' } > - {contract?.question || sourceContractTitle || sourceTitle} + {sourceContractTitle || sourceTitle} </a> </div> )} @@ -752,7 +657,7 @@ function NotificationItem(props: { </Row> <div className={'mt-1 ml-1 md:text-base'}> <NotificationTextLabel - contract={contract} + contract={null} defaultText={defaultNotificationText} notification={notification} /> @@ -811,6 +716,16 @@ function NotificationTextLabel(props: { </span> ) } + } else if (sourceType === 'user' && sourceText) { + return ( + <span> + As a thank you, we sent you{' '} + <span className="text-primary"> + {formatMoney(parseInt(sourceText))} + </span> + ! + </span> + ) } else if (sourceType === 'liquidity' && sourceText) { return ( <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> @@ -829,7 +744,8 @@ function getReasonForShowingNotification( reason: notification_reason_types, sourceUpdateType: notification_source_update_types | undefined, contract: Contract | undefined | null, - simple?: boolean + simple?: boolean, + sourceSlug?: string ) { let reasonText: string switch (source) { @@ -883,6 +799,12 @@ function getReasonForShowingNotification( case 'group': reasonText = 'added you to the group' break + case 'user': + if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') + reasonText = 'joined to bet on your market' + else if (sourceSlug) reasonText = 'joined because you shared' + else reasonText = 'joined because of you' + break default: reasonText = '' } From 5034a43c3ccb2f4b38043524af728bd885947bf6 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 1 Jul 2022 08:29:12 -0600 Subject: [PATCH 062/220] Filter for ian's deleted users --- functions/src/on-update-user.ts | 20 ++++++++++++-------- web/hooks/use-group.ts | 4 +++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts index 2e5e2145..b6ba6e0b 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -55,14 +55,18 @@ async function handleUserUpdatedReferral(user: User, eventId: string) { .where('category', '==', 'REFERRAL') .get() ).docs.map((txn) => txn.ref) - const referralTxns = await transaction.getAll(...txns).catch((err) => { - console.error('error getting txns:', err) - throw err - }) - // If the referring user already has a referral txn due to referring this user, halt - if (referralTxns.map((txn) => txn.data()?.description).includes(user.id)) { - console.log('found referral txn with the same details, aborting') - return + if (txns.length > 0) { + const referralTxns = await transaction.getAll(...txns).catch((err) => { + console.error('error getting txns:', err) + throw err + }) + // If the referring user already has a referral txn due to referring this user, halt + if ( + referralTxns.map((txn) => txn.data()?.description).includes(user.id) + ) { + console.log('found referral txn with the same details, aborting') + return + } } console.log('creating referral txns') const fromId = HOUSE_LIQUIDITY_PROVIDER_ID diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 41f84707..39f6f3f8 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -74,7 +74,9 @@ export function useMembers(group: Group) { } export async function listMembers(group: Group) { - return await Promise.all(group.memberIds.map(getUser)) + return (await Promise.all(group.memberIds.map(getUser))).filter( + (user) => user + ) } export const useGroupsWithContract = (contractId: string | undefined) => { From d29115b05aeefb789e4b76d5735f7c7f812b4c05 Mon Sep 17 00:00:00 2001 From: Ben Congdon <ben@congdon.dev> Date: Fri, 1 Jul 2022 08:40:43 -0700 Subject: [PATCH 063/220] Nitpick on Manalinks claim page (#608) --- web/pages/link/[slug].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index 0b0186ed..60966756 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -31,7 +31,7 @@ export default function ClaimPage() { url="/send" /> <div className="mx-auto max-w-xl"> - <Title text={`Claim ${manalink.amount} mana`} /> + <Title text={`Claim M$${manalink.amount} mana`} /> <ManalinkCard defaultMessage={fromUser?.name || 'Enjoy this mana!'} info={info} From cb68530e2a8af2c50836ab3a04b26bd1797ea2d1 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 1 Jul 2022 12:26:45 -0400 Subject: [PATCH 064/220] Use client side contract search for emulator --- web/components/contract-search.tsx | 2 +- web/components/outcome-label.tsx | 2 +- web/pages/contract-search-firestore.tsx | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index fac02d74..9a4da597 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -122,7 +122,7 @@ export function ContractSearch(props: { const indexName = `${indexPrefix}contracts-${sort}` - if (IS_PRIVATE_MANIFOLD) { + if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return ( <ContractSearchFirestore querySortOptions={querySortOptions} diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index 6daa855b..054ebfd2 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -74,7 +74,7 @@ export function FreeResponseOutcomeLabel(props: { if (resolution === 'CANCEL') return <CancelLabel /> if (resolution === 'MKT') return <MultiLabel /> - const chosen = contract.answers.find((answer) => answer.id === resolution) + const chosen = contract.answers?.find((answer) => answer.id === resolution) if (!chosen) return <AnswerNumberLabel number={resolution} /> return ( <FreeResponseAnswerToolTip text={chosen.text}> diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index c9a7a666..8cd80f7a 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -9,6 +9,8 @@ import { useInitialQueryAndSort, } from 'web/hooks/use-sort-and-query-params' +const MAX_CONTRACTS_RENDERED = 100 + export default function ContractSearchFirestore(props: { querySortOptions?: { defaultSort: Sort @@ -80,6 +82,8 @@ export default function ContractSearchFirestore(props: { } } + matches = matches.slice(0, MAX_CONTRACTS_RENDERED) + const showTime = ['close-date', 'closed'].includes(sort) ? 'close-date' : sort === 'resolve-date' From b9931e65dad9fe8d0d5de921e785a858a8a286b8 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 1 Jul 2022 16:37:30 -0600 Subject: [PATCH 065/220] Allow adding anyone's contract to a group --- firestore.rules | 11 ++- web/components/contract-search.tsx | 49 ++++++++--- web/components/contract/contract-details.tsx | 36 ++++++-- web/components/groups/edit-group-button.tsx | 3 +- web/components/groups/groups-button.tsx | 4 +- web/components/layout/modal.tsx | 11 ++- web/components/layout/tabs.tsx | 14 +-- web/components/nav/sidebar.tsx | 2 +- web/components/user-page.tsx | 2 +- web/hooks/use-group.ts | 4 +- web/lib/firebase/groups.ts | 18 +++- web/pages/create.tsx | 6 +- web/pages/group/[...slugs]/index.tsx | 93 +++++++------------- web/pages/links.tsx | 2 +- web/pages/notifications.tsx | 2 +- 15 files changed, 150 insertions(+), 107 deletions(-) diff --git a/firestore.rules b/firestore.rules index 50df415a..4645343d 100644 --- a/firestore.rules +++ b/firestore.rules @@ -21,11 +21,16 @@ service cloud.firestore { allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']); - // only one referral allowed per user allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['referredByUserId']) - && !("referredByUserId" in resource.data); + .hasOnly(['referredByUserId']) + // only one referral allowed per user + && !("referredByUserId" in resource.data) + // user can't refer themselves + && (resource.data.id != request.resource.data.referredByUserId) + // user can't refer someone who referred them quid pro quo + && get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId != resource.data.id; + } match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 9a4da597..2c7f5b62 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -9,7 +9,7 @@ import { useSortBy, } from 'react-instantsearch-hooks-web' -import { Contract } from '../../common/contract' +import { Contract } from 'common/contract' import { Sort, useInitialQueryAndSort, @@ -58,15 +58,24 @@ export function ContractSearch(props: { additionalFilter?: { creatorId?: string tag?: string + excludeContractIds?: string[] } showCategorySelector: boolean onContractClick?: (contract: Contract) => void + showPlaceHolder?: boolean + hideOrderSelector?: boolean + overrideGridClassName?: string + hideQuickBet?: boolean }) { const { querySortOptions, additionalFilter, showCategorySelector, onContractClick, + overrideGridClassName, + hideOrderSelector, + showPlaceHolder, + hideQuickBet, } = props const user = useUser() @@ -136,6 +145,7 @@ export function ContractSearch(props: { <Row className="gap-1 sm:gap-2"> <SearchBox className="flex-1" + placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''} classNames={{ form: 'before:top-6', input: '!pl-10 !input !input-bordered shadow-none w-[100px]', @@ -153,13 +163,15 @@ export function ContractSearch(props: { <option value="resolved">Resolved</option> <option value="all">All</option> </select> - <SortBy - items={sortIndexes} - classNames={{ - select: '!select !select-bordered', - }} - onBlur={trackCallback('select search sort')} - /> + {!hideOrderSelector && ( + <SortBy + items={sortIndexes} + classNames={{ + select: '!select !select-bordered', + }} + onBlur={trackCallback('select search sort')} + /> + )} <Configure facetFilters={filters} numericFilters={numericFilters} @@ -187,6 +199,9 @@ export function ContractSearch(props: { <ContractSearchInner querySortOptions={querySortOptions} onContractClick={onContractClick} + overrideGridClassName={overrideGridClassName} + hideQuickBet={hideQuickBet} + excludeContractIds={additionalFilter?.excludeContractIds} /> )} </InstantSearch> @@ -199,8 +214,17 @@ export function ContractSearchInner(props: { shouldLoadFromStorage?: boolean } onContractClick?: (contract: Contract) => void + overrideGridClassName?: string + hideQuickBet?: boolean + excludeContractIds?: string[] }) { - const { querySortOptions, onContractClick } = props + const { + querySortOptions, + onContractClick, + overrideGridClassName, + hideQuickBet, + excludeContractIds, + } = props const { initialQuery } = useInitialQueryAndSort(querySortOptions) const { query, setQuery, setSort } = useUpdateQueryAndSort({ @@ -239,7 +263,7 @@ export function ContractSearchInner(props: { }, []) const { showMore, hits, isLastPage } = useInfiniteHits() - const contracts = hits as any as Contract[] + let contracts = hits as any as Contract[] if (isInitialLoad && contracts.length === 0) return <></> @@ -249,6 +273,9 @@ export function ContractSearchInner(props: { ? 'resolve-date' : undefined + if (excludeContractIds) + contracts = contracts.filter((c) => !excludeContractIds.includes(c.id)) + return ( <ContractsGrid contracts={contracts} @@ -256,6 +283,8 @@ export function ContractSearchInner(props: { hasMore={!isLastPage} showTime={showTime} onContractClick={onContractClick} + overrideGridClassName={overrideGridClassName} + hideQuickBet={hideQuickBet} /> ) } diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 3512efa2..f908918e 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -130,9 +130,32 @@ export function ContractDetails(props: { const { contract, bets, isCreator, disabled } = props const { closeTime, creatorName, creatorUsername, creatorId } = contract const { volumeLabel, resolvedDate } = contractMetrics(contract) - // Find a group that this contract id is in - const groups = useGroupsWithContract(contract.id) + + const groups = (useGroupsWithContract(contract.id) ?? []).sort((g1, g2) => { + return g2.createdTime - g1.createdTime + }) const user = useUser() + + const groupsUserIsMemberOf = groups + ? groups.filter((g) => g.memberIds.includes(contract.creatorId)) + : [] + const groupsUserIsCreatorOf = groups + ? groups.filter((g) => g.creatorId === contract.creatorId) + : [] + + // Priorities for which group the contract belongs to: + // In order of created most recently + // Group that the contract owner created + // Group the contract owner is a member of + // Any group the contract is in + const groupToDisplay = + groupsUserIsCreatorOf.length > 0 + ? groupsUserIsCreatorOf[0] + : groupsUserIsMemberOf.length > 0 + ? groupsUserIsMemberOf[0] + : groups + ? groups[0] + : undefined return ( <Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500"> <Row className="items-center gap-2"> @@ -153,14 +176,15 @@ export function ContractDetails(props: { )} {!disabled && <UserFollowButton userId={creatorId} small />} </Row> - {/*// TODO: we can add contracts to multiple groups but only show the first it was added to*/} - {groups && groups.length > 0 && ( + {groupToDisplay ? ( <Row className={'line-clamp-1 mt-1 max-w-[200px]'}> - <SiteLink href={`${groupPath(groups[0].slug)}`}> + <SiteLink href={`${groupPath(groupToDisplay.slug)}`}> <UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" /> - <span>{groups[0].name}</span> + <span>{groupToDisplay.name}</span> </SiteLink> </Row> + ) : ( + <div /> )} {(!!closeTime || !!resolvedDate) && ( diff --git a/web/components/groups/edit-group-button.tsx b/web/components/groups/edit-group-button.tsx index 6ad7237a..834af5ec 100644 --- a/web/components/groups/edit-group-button.tsx +++ b/web/components/groups/edit-group-button.tsx @@ -9,6 +9,7 @@ import { useRouter } from 'next/router' import { Modal } from 'web/components/layout/modal' import { FilterSelectUsers } from 'web/components/filter-select-users' import { User } from 'common/user' +import { uniq } from 'lodash' export function EditGroupButton(props: { group: Group; className?: string }) { const { group, className } = props @@ -35,7 +36,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) { await updateGroup(group, { name, about, - memberIds: [...memberIds, ...addMemberUsers.map((user) => user.id)], + memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]), }) setIsSubmitting(false) diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index e6ee217d..b81155d1 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -9,7 +9,7 @@ import { TextButton } from 'web/components/text-button' import { Group } from 'common/group' import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' -import { joinGroup, leaveGroup } from 'web/lib/firebase/groups' +import { addUserToGroup, leaveGroup } from 'web/lib/firebase/groups' import { firebaseLogin } from 'web/lib/firebase/users' import { GroupLink } from 'web/pages/groups' @@ -93,7 +93,7 @@ export function JoinOrLeaveGroupButton(props: { : false const onJoinGroup = () => { if (!currentUser) return - joinGroup(group, currentUser.id) + addUserToGroup(group, currentUser.id) } const onLeaveGroup = () => { if (!currentUser) return diff --git a/web/components/layout/modal.tsx b/web/components/layout/modal.tsx index d61a38dd..7a320f24 100644 --- a/web/components/layout/modal.tsx +++ b/web/components/layout/modal.tsx @@ -1,13 +1,15 @@ import { Fragment, ReactNode } from 'react' import { Dialog, Transition } from '@headlessui/react' +import clsx from 'clsx' // From https://tailwindui.com/components/application-ui/overlays/modals export function Modal(props: { children: ReactNode open: boolean setOpen: (open: boolean) => void + className?: string }) { - const { children, open, setOpen } = props + const { children, open, setOpen, className } = props return ( <Transition.Root show={open} as={Fragment}> @@ -45,7 +47,12 @@ export function Modal(props: { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - <div className="inline-block transform overflow-hidden text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-md sm:p-6 sm:align-middle"> + <div + className={clsx( + 'inline-block transform overflow-hidden text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-md sm:p-6 sm:align-middle', + className + )} + > {children} </div> </Transition.Child> diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index 69e8cfab..796f5dae 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -14,17 +14,17 @@ type Tab = { export function Tabs(props: { tabs: Tab[] defaultIndex?: number - className?: string + labelClassName?: string onClick?: (tabTitle: string, index: number) => void }) { - const { tabs, defaultIndex, className, onClick } = props + const { tabs, defaultIndex, labelClassName, onClick } = props const [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0) const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case return ( - <div> + <> <div className="border-b border-gray-200"> - <nav className="-mb-px flex space-x-8" aria-label="Tabs"> + <nav className="-mb-px mb-4 flex space-x-8" aria-label="Tabs"> {tabs.map((tab, i) => ( <Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}> <a @@ -42,7 +42,7 @@ export function Tabs(props: { ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700', 'cursor-pointer whitespace-nowrap border-b-2 py-3 px-1 text-sm font-medium', - className + labelClassName )} aria-current={activeIndex === i ? 'page' : undefined} > @@ -56,7 +56,7 @@ export function Tabs(props: { </nav> </div> - <div className="mt-4">{activeTab?.content}</div> - </div> + {activeTab?.content} + </> ) } diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 402f5e12..8c3ceb02 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -254,7 +254,7 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) { <div className="mt-1 space-y-0.5"> {memberItems.map((item) => ( <a - key={item.name} + key={item.href} href={item.href} className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900" > diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index ac9fe8fd..ccacca04 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -258,7 +258,7 @@ export function UserPage(props: { {usersContracts !== 'loading' && commentsByContract != 'loading' ? ( <Tabs - className={'pb-2 pt-1 '} + labelClassName={'pb-2 pt-1 '} defaultIndex={ defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0 } diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 39f6f3f8..41f84707 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -74,9 +74,7 @@ export function useMembers(group: Group) { } export async function listMembers(group: Group) { - return (await Promise.all(group.memberIds.map(getUser))).filter( - (user) => user - ) + return await Promise.all(group.memberIds.map(getUser)) } export const useGroupsWithContract = (contractId: string | undefined) => { diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 506849ad..04a5bd44 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -102,10 +102,13 @@ export async function addUserToGroupViaSlug(groupSlug: string, userId: string) { console.error(`Group not found: ${groupSlug}`) return } - return await joinGroup(group, userId) + return await addUserToGroup(group, userId) } -export async function joinGroup(group: Group, userId: string): Promise<Group> { +export async function addUserToGroup( + group: Group, + userId: string +): Promise<Group> { const { memberIds } = group if (memberIds.includes(userId)) { return group @@ -125,3 +128,14 @@ export async function leaveGroup(group: Group, userId: string): Promise<Group> { await updateGroup(newGroup, { memberIds: uniq(newMemberIds) }) return newGroup } + +export async function addContractToGroup(group: Group, contractId: string) { + return await updateGroup(group, { + contractIds: uniq([...group.contractIds, contractId]), + }) + .then(() => group) + .catch((err) => { + console.error('error adding contract to group', err) + return err + }) +} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ebbb6f65..7d645b04 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -19,7 +19,7 @@ import { import { formatMoney } from 'common/util/format' import { removeUndefinedProps } from 'common/util/object' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { getGroup, updateGroup } from 'web/lib/firebase/groups' +import { addContractToGroup, getGroup } from 'web/lib/firebase/groups' import { Group } from 'common/group' import { useTracking } from 'web/hooks/use-tracking' import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' @@ -186,9 +186,7 @@ export function NewContract(props: { isFree: false, }) if (result && selectedGroup) { - await updateGroup(selectedGroup, { - contractIds: [...selectedGroup.contractIds, result.id], - }) + await addContractToGroup(selectedGroup, result.id) } await router.push(contractPath(result as Contract)) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 3a3db14d..8a8bc4c1 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -4,12 +4,14 @@ import { Group } from 'common/group' import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { listAllBets } from 'web/lib/firebase/bets' -import { Contract, listenForUserContracts } from 'web/lib/firebase/contracts' +import { Contract } from 'web/lib/firebase/contracts' import { groupPath, getGroupBySlug, getGroupContracts, updateGroup, + addContractToGroup, + addUserToGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' @@ -39,7 +41,6 @@ import React, { useEffect, useState } from 'react' import { GroupChat } from 'web/components/groups/group-chat' import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' -import { PlusIcon } from '@heroicons/react/outline' import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' @@ -48,6 +49,7 @@ import ShortToggle from 'web/components/widgets/short-toggle' import { ShareIconButton } from 'web/components/share-icon-button' import { REFERRAL_AMOUNT } from 'common/user' import { SiteLink } from 'web/components/site-link' +import { ContractSearch } from 'web/components/contract-search' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -509,75 +511,46 @@ function GroupLeaderboards(props: { } function AddContractButton(props: { group: Group; user: User }) { - const { group, user } = props + const { group } = props const [open, setOpen] = useState(false) - const [contracts, setContracts] = useState<Contract[] | undefined>(undefined) - const [query, setQuery] = useState('') - useEffect(() => { - return listenForUserContracts(user.id, (contracts) => { - setContracts(contracts.filter((c) => !group.contractIds.includes(c.id))) - }) - }, [group.contractIds, user.id]) - - async function addContractToGroup(contract: Contract) { - await updateGroup(group, { - ...group, - contractIds: [...group.contractIds, contract.id], - }) + async function addContractToCurrentGroup(contract: Contract) { + await addContractToGroup(group, contract.id) setOpen(false) } - // TODO use find-active-contracts to sort by? - const matches = sortBy(contracts, [ - (contract) => -1 * contract.createdTime, - ]).filter( - (c) => - checkAgainstQuery(query, c.question) || - checkAgainstQuery(query, c.description) || - checkAgainstQuery(query, c.tags.flat().join(' ')) - ) - const debouncedQuery = debounce(setQuery, 50) return ( <> - <Modal open={open} setOpen={setOpen}> - <Col className={'max-h-[60vh] w-full gap-4 rounded-md bg-white p-8'}> + <Modal open={open} setOpen={setOpen} className={'sm:p-0'}> + <Col + className={ + 'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white p-8' + } + > <div className={'text-lg text-indigo-700'}> Add a question to your group </div> - <input - type="text" - onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Search your questions" - className="input input-bordered mb-4 w-full" - /> - <div className={'overflow-y-scroll'}> - {contracts ? ( - <ContractsGrid - contracts={matches} - loadMore={() => {}} - hasMore={false} - onContractClick={(contract) => { - addContractToGroup(contract) - }} - overrideGridClassName={'flex grid-cols-1 flex-col gap-3 p-1'} - hideQuickBet={true} - /> - ) : ( - <LoadingIndicator /> - )} + <div className={'overflow-y-scroll p-1'}> + <ContractSearch + hideOrderSelector={true} + onContractClick={addContractToCurrentGroup} + showCategorySelector={false} + overrideGridClassName={'flex grid-cols-1 flex-col gap-3 p-1'} + showPlaceHolder={true} + hideQuickBet={true} + additionalFilter={{ excludeContractIds: group.contractIds }} + /> </div> </Col> </Modal> <Row className={'items-center justify-center'}> <button className={ - 'btn btn-sm btn-outline cursor-pointer gap-2 whitespace-nowrap text-sm normal-case' + 'btn btn-md btn-outline cursor-pointer gap-2 whitespace-nowrap text-sm normal-case' } onClick={() => setOpen(true)} > - <PlusIcon className="mr-1 h-5 w-5" /> - Add old questions to this group + Add an old question </button> </Row> </> @@ -591,17 +564,11 @@ function JoinGroupButton(props: { const { group, user } = props function joinGroup() { if (user && !group.memberIds.includes(user.id)) { - toast.promise( - updateGroup(group, { - ...group, - memberIds: [...group.memberIds, user.id], - }), - { - loading: 'Joining group...', - success: 'Joined group!', - error: "Couldn't join group", - } - ) + toast.promise(addUserToGroup(group, user.id), { + loading: 'Joining group...', + success: 'Joined group!', + error: "Couldn't join group", + }) } } return ( diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 08c99460..12cde274 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -64,7 +64,7 @@ export default function LinkPage() { <Col className="w-full px-8"> <Title text="Manalinks" /> <Tabs - className={'pb-2 pt-1 '} + labelClassName={'pb-2 pt-1 '} defaultIndex={0} tabs={[ { diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 9b0216b6..f3512c56 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -86,7 +86,7 @@ export default function Notifications() { <div className={'p-2 sm:p-4'}> <Title text={'Notifications'} className={'hidden md:block'} /> <Tabs - className={'pb-2 pt-1 '} + labelClassName={'pb-2 pt-1 '} defaultIndex={0} tabs={[ { From 2dce3e15a138bccc38063021003716127fcffa34 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 1 Jul 2022 17:03:26 -0600 Subject: [PATCH 066/220] Correct margin on tabs --- web/components/layout/tabs.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index 796f5dae..ac1c0fe3 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -23,8 +23,8 @@ export function Tabs(props: { return ( <> - <div className="border-b border-gray-200"> - <nav className="-mb-px mb-4 flex space-x-8" aria-label="Tabs"> + <div className="mb-4 border-b border-gray-200"> + <nav className="-mb-px flex space-x-8" aria-label="Tabs"> {tabs.map((tab, i) => ( <Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}> <a From cc52bff05e4202fa5f0e1962cf5bec266226f476 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 1 Jul 2022 16:45:05 -0700 Subject: [PATCH 067/220] fix functions/README formatting --- functions/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/functions/README.md b/functions/README.md index 031cc4fa..8013fb20 100644 --- a/functions/README.md +++ b/functions/README.md @@ -23,8 +23,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started ### For local development 0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI -1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`): 0. `$ brew install java` - 1. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk` +1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`): + + 1. `$ brew install java` + 2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk` 2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud 3. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options) 4. `$ mkdir firestore_export` to create a folder to store the exported database From 1a6afaf44fabdef277bb2837d7658554094469f3 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Sat, 2 Jul 2022 14:37:59 -0500 Subject: [PATCH 068/220] Pseudo numeric market (#609) * create pseudo-numeric contracts * graph and bet panel for pseudo numeric * pseudo numeric market layout, quick betting * Estimated value * sell panel * fix graph * pseudo numeric resolution * bets tab * redemption for pseudo numeric markets * create log scale market, validation * log scale * create: initial value can't be min or max * don't allow log scale for ranges with negative values (b/c of problem with graph library) * prettier delenda est * graph: handle min value of zero * bet labeling * validation * prettier * pseudo numeric embeds * update disclaimer * validation * validation --- common/calculate.ts | 31 ++-- common/contract.ts | 22 ++- common/new-bet.ts | 3 +- common/new-contract.ts | 24 ++- common/payouts.ts | 15 +- common/pseudo-numeric.ts | 45 ++++++ functions/src/create-contract.ts | 30 +++- functions/src/emails.ts | 18 ++- functions/src/place-bet.ts | 5 +- functions/src/redeem-shares.ts | 6 +- functions/src/resolve-market.ts | 22 ++- web/components/bet-panel.tsx | 54 +++++-- web/components/bet-row.tsx | 5 +- web/components/bets-list.tsx | 39 ++++- web/components/contract/contract-card.tsx | 58 +++++++- web/components/contract/contract-overview.tsx | 18 ++- .../contract/contract-prob-graph.tsx | 51 +++++-- web/components/contract/quick-bet.tsx | 82 +++++++---- web/components/feed/feed-bets.tsx | 10 +- web/components/numeric-resolution-panel.tsx | 27 +++- web/components/outcome-label.tsx | 25 +++- web/components/sell-button.tsx | 12 +- web/components/sell-modal.tsx | 4 +- web/components/sell-row.tsx | 4 +- web/components/yes-no-selector.tsx | 6 +- web/pages/[username]/[contractSlug].tsx | 9 +- web/pages/create.tsx | 139 +++++++++++++----- web/pages/embed/[username]/[contractSlug].tsx | 15 +- 28 files changed, 623 insertions(+), 156 deletions(-) create mode 100644 common/pseudo-numeric.ts diff --git a/common/calculate.ts b/common/calculate.ts index a0574c10..482a0ccf 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -18,15 +18,24 @@ import { getDpmProbabilityAfterSale, } from './calculate-dpm' import { calculateFixedPayout } from './calculate-fixed-payouts' -import { Contract, BinaryContract, FreeResponseContract } from './contract' +import { + Contract, + BinaryContract, + FreeResponseContract, + PseudoNumericContract, +} from './contract' -export function getProbability(contract: BinaryContract) { +export function getProbability( + contract: BinaryContract | PseudoNumericContract +) { return contract.mechanism === 'cpmm-1' ? getCpmmProbability(contract.pool, contract.p) : getDpmProbability(contract.totalShares) } -export function getInitialProbability(contract: BinaryContract) { +export function getInitialProbability( + contract: BinaryContract | PseudoNumericContract +) { if (contract.initialProbability) return contract.initialProbability if (contract.mechanism === 'dpm-2' || (contract as any).totalShares) @@ -65,7 +74,9 @@ export function calculateShares( } export function calculateSaleAmount(contract: Contract, bet: Bet) { - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue : calculateDpmSaleAmount(contract, bet) } @@ -87,7 +98,9 @@ export function getProbabilityAfterSale( } export function calculatePayout(contract: Contract, bet: Bet, outcome: string) { - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateFixedPayout(contract, bet, outcome) : calculateDpmPayout(contract, bet, outcome) } @@ -96,7 +109,9 @@ export function resolvedPayout(contract: Contract, bet: Bet) { const outcome = contract.resolution if (!outcome) throw new Error('Contract not resolved') - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateFixedPayout(contract, bet, outcome) : calculateDpmPayout(contract, bet, outcome) } @@ -142,9 +157,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const profit = payout + saleValue + redeemed - totalInvested const profitPercent = (profit / totalInvested) * 100 - const hasShares = Object.values(totalShares).some( - (shares) => shares > 0 - ) + const hasShares = Object.values(totalShares).some((shares) => shares > 0) return { invested: Math.max(0, currentInvested), diff --git a/common/contract.ts b/common/contract.ts index dc91a20e..3a90d01f 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -2,9 +2,10 @@ import { Answer } from './answer' import { Fees } from './fees' export type AnyMechanism = DPM | CPMM -export type AnyOutcomeType = Binary | FreeResponse | Numeric +export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric export type AnyContractType = | (CPMM & Binary) + | (CPMM & PseudoNumeric) | (DPM & Binary) | (DPM & FreeResponse) | (DPM & Numeric) @@ -33,7 +34,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = { isResolved: boolean resolutionTime?: number // When the contract creator resolved the market resolution?: string - resolutionProbability?: number, + resolutionProbability?: number closeEmailsSent?: number @@ -44,7 +45,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = { collectedFees: Fees } & T -export type BinaryContract = Contract & Binary +export type BinaryContract = Contract & Binary +export type PseudoNumericContract = Contract & PseudoNumeric export type NumericContract = Contract & Numeric export type FreeResponseContract = Contract & FreeResponse export type DPMContract = Contract & DPM @@ -75,6 +77,18 @@ export type Binary = { resolution?: resolution } +export type PseudoNumeric = { + outcomeType: 'PSEUDO_NUMERIC' + min: number + max: number + isLogScale: boolean + resolutionValue?: number + + // same as binary market; map everything to probability + initialProbability: number + resolutionProbability?: number +} + export type FreeResponse = { outcomeType: 'FREE_RESPONSE' answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'. @@ -94,7 +108,7 @@ export type Numeric = { export type outcomeType = AnyOutcomeType['outcomeType'] export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const -export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'NUMERIC'] as const +export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'PSEUDO_NUMERIC', 'NUMERIC'] as const export const MAX_QUESTION_LENGTH = 480 export const MAX_DESCRIPTION_LENGTH = 10000 diff --git a/common/new-bet.ts b/common/new-bet.ts index ba799624..236c0908 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -14,6 +14,7 @@ import { DPMBinaryContract, FreeResponseContract, NumericContract, + PseudoNumericContract, } from './contract' import { noFees } from './fees' import { addObjects } from './util/object' @@ -32,7 +33,7 @@ export type BetInfo = { export const getNewBinaryCpmmBetInfo = ( outcome: 'YES' | 'NO', amount: number, - contract: CPMMBinaryContract, + contract: CPMMBinaryContract | PseudoNumericContract, loanAmount: number ) => { const { shares, newPool, newP, fees } = calculateCpmmPurchase( diff --git a/common/new-contract.ts b/common/new-contract.ts index 0b7d294a..6c89c8c4 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -7,6 +7,7 @@ import { FreeResponse, Numeric, outcomeType, + PseudoNumeric, } from './contract' import { User } from './user' import { parseTags } from './util/parse' @@ -27,7 +28,8 @@ export function getNewContract( // used for numeric markets bucketCount: number, min: number, - max: number + max: number, + isLogScale: boolean ) { const tags = parseTags( `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` @@ -37,6 +39,8 @@ export function getNewContract( const propsByOutcomeType = outcomeType === 'BINARY' ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) + : outcomeType === 'PSEUDO_NUMERIC' + ? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale) : outcomeType === 'NUMERIC' ? getNumericProps(ante, bucketCount, min, max) : getFreeAnswerProps(ante) @@ -111,6 +115,24 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => { return system } +const getPseudoNumericCpmmProps = ( + initialProb: number, + ante: number, + min: number, + max: number, + isLogScale: boolean +) => { + const system: CPMM & PseudoNumeric = { + ...getBinaryCpmmProps(initialProb, ante), + outcomeType: 'PSEUDO_NUMERIC', + min, + max, + isLogScale, + } + + return system +} + const getFreeAnswerProps = (ante: number) => { const system: DPM & FreeResponse = { mechanism: 'dpm-2', diff --git a/common/payouts.ts b/common/payouts.ts index f2c8d271..1469cf4e 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -1,7 +1,12 @@ import { sumBy, groupBy, mapValues } from 'lodash' import { Bet, NumericBet } from './bet' -import { Contract, CPMMBinaryContract, DPMContract } from './contract' +import { + Contract, + CPMMBinaryContract, + DPMContract, + PseudoNumericContract, +} from './contract' import { Fees } from './fees' import { LiquidityProvision } from './liquidity-provision' import { @@ -56,7 +61,11 @@ export const getPayouts = ( }, resolutionProbability?: number ): PayoutInfo => { - if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') { + if ( + contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') + ) { return getFixedPayouts( outcome, contract, @@ -76,7 +85,7 @@ export const getPayouts = ( export const getFixedPayouts = ( outcome: string | undefined, - contract: CPMMBinaryContract, + contract: CPMMBinaryContract | PseudoNumericContract, bets: Bet[], liquidities: LiquidityProvision[], resolutionProbability?: number diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts new file mode 100644 index 00000000..9a322e35 --- /dev/null +++ b/common/pseudo-numeric.ts @@ -0,0 +1,45 @@ +import { BinaryContract, PseudoNumericContract } from './contract' +import { formatLargeNumber, formatPercent } from './util/format' + +export function formatNumericProbability( + p: number, + contract: PseudoNumericContract +) { + const value = getMappedValue(contract)(p) + return formatLargeNumber(value) +} + +export const getMappedValue = + (contract: PseudoNumericContract | BinaryContract) => (p: number) => { + if (contract.outcomeType === 'BINARY') return p + + const { min, max, isLogScale } = contract + + if (isLogScale) { + const logValue = p * Math.log10(max - min) + return 10 ** logValue + min + } + + return p * (max - min) + min + } + +export const getFormattedMappedValue = + (contract: PseudoNumericContract | BinaryContract) => (p: number) => { + if (contract.outcomeType === 'BINARY') return formatPercent(p) + + const value = getMappedValue(contract)(p) + return formatLargeNumber(value) + } + +export const getPseudoProbability = ( + value: number, + min: number, + max: number, + isLogScale = false +) => { + if (isLogScale) { + return Math.log10(value - min) / Math.log10(max - min) + } + + return (value - min) / (max - min) +} diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index c9468fdc..0d78ab5c 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -28,6 +28,7 @@ import { getNewContract } from '../../common/new-contract' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { User } from '../../common/user' import { Group, MAX_ID_LENGTH } from '../../common/group' +import { getPseudoProbability } from '../../common/pseudo-numeric' const bodySchema = z.object({ question: z.string().min(1).max(MAX_QUESTION_LENGTH), @@ -45,19 +46,31 @@ const binarySchema = z.object({ initialProb: z.number().min(1).max(99), }) +const finite = () => z.number().gte(Number.MIN_SAFE_INTEGER).lte(Number.MAX_SAFE_INTEGER) + const numericSchema = z.object({ - min: z.number(), - max: z.number(), + min: finite(), + max: finite(), + initialValue: finite(), + isLogScale: z.boolean().optional(), }) export const createmarket = newEndpoint({}, async (req, auth) => { const { question, description, tags, closeTime, outcomeType, groupId } = validate(bodySchema, req.body) - let min, max, initialProb - if (outcomeType === 'NUMERIC') { - ;({ min, max } = validate(numericSchema, req.body)) - if (max - min <= 0.01) throw new APIError(400, 'Invalid range.') + let min, max, initialProb, isLogScale + + if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { + let initialValue + ;({ min, max, initialValue, isLogScale } = validate( + numericSchema, + req.body + )) + if (max - min <= 0.01 || initialValue < min || initialValue > max) + throw new APIError(400, 'Invalid range.') + + initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100 } if (outcomeType === 'BINARY') { ;({ initialProb } = validate(binarySchema, req.body)) @@ -121,7 +134,8 @@ export const createmarket = newEndpoint({}, async (req, auth) => { tags ?? [], NUMERIC_BUCKET_COUNT, min ?? 0, - max ?? 0 + max ?? 0, + isLogScale ?? false ) if (ante) await chargeUser(user.id, ante, true) @@ -130,7 +144,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { const providerId = user.id - if (outcomeType === 'BINARY') { + if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { const liquidityDoc = firestore .collection(`contracts/${contract.id}/liquidity`) .doc() diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 1ba8ca96..40e8900c 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -6,8 +6,13 @@ import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' import { DPM_CREATOR_FEE } from '../../common/fees' import { PrivateUser, User } from '../../common/user' -import { formatMoney, formatPercent } from '../../common/util/format' +import { + formatLargeNumber, + formatMoney, + formatPercent, +} from '../../common/util/format' import { getValueFromBucket } from '../../common/calculate-dpm' +import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' @@ -101,6 +106,17 @@ const toDisplayResolution = ( return display || resolution } + if (contract.outcomeType === 'PSEUDO_NUMERIC') { + const { resolutionValue } = contract + + return resolutionValue + ? formatLargeNumber(resolutionValue) + : formatNumericProbability( + resolutionProbability ?? getProbability(contract), + contract + ) + } + if (resolution === 'MKT' && resolutions) return 'MULTI' if (resolution === 'CANCEL') return 'N/A' diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 06d27668..b6c7d267 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -70,7 +70,10 @@ export const placebet = newEndpoint({}, async (req, auth) => { if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { const { outcome } = validate(binarySchema, req.body) return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount) - } else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') { + } else if ( + (outcomeType == 'BINARY' || outcomeType == 'PSEUDO_NUMERIC') && + mechanism == 'cpmm-1' + ) { const { outcome } = validate(binarySchema, req.body) return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount) } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index bdd3ab94..67922a65 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -16,7 +16,11 @@ export const redeemShares = async (userId: string, contractId: string) => { return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - if (contract.outcomeType !== 'BINARY' || contract.mechanism !== 'cpmm-1') + const { mechanism, outcomeType } = contract + if ( + !(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') || + mechanism !== 'cpmm-1' + ) return { status: 'success' } const betsSnap = await transaction.get( diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index ee78dfec..f8976cb3 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -27,7 +27,7 @@ const bodySchema = z.object({ const binarySchema = z.object({ outcome: z.enum(RESOLUTIONS), - probabilityInt: z.number().gte(0).lt(100).optional(), + probabilityInt: z.number().gte(0).lte(100).optional(), }) const freeResponseSchema = z.union([ @@ -39,7 +39,7 @@ const freeResponseSchema = z.union([ resolutions: z.array( z.object({ answer: z.number().int().nonnegative(), - pct: z.number().gte(0).lt(100), + pct: z.number().gte(0).lte(100), }) ), }), @@ -53,7 +53,19 @@ const numericSchema = z.object({ value: z.number().optional(), }) +const pseudoNumericSchema = z.union([ + z.object({ + outcome: z.literal('CANCEL'), + }), + z.object({ + outcome: z.literal('MKT'), + value: z.number(), + probabilityInt: z.number().gte(0).lte(100), + }), +]) + const opts = { secrets: ['MAILGUN_KEY'] } + export const resolvemarket = newEndpoint(opts, async (req, auth) => { const { contractId } = validate(bodySchema, req.body) const userId = auth.uid @@ -221,12 +233,18 @@ const sendResolutionEmails = async ( function getResolutionParams(contract: Contract, body: string) { const { outcomeType } = contract + if (outcomeType === 'NUMERIC') { return { ...validate(numericSchema, body), resolutions: undefined, probabilityInt: undefined, } + } else if (outcomeType === 'PSEUDO_NUMERIC') { + return { + ...validate(pseudoNumericSchema, body), + resolutions: undefined, + } } else if (outcomeType === 'FREE_RESPONSE') { const freeResponseParams = validate(freeResponseSchema, body) const { outcome } = freeResponseParams diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 73055872..f76117b9 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -3,7 +3,11 @@ import React, { useEffect, useState } from 'react' import { partition, sumBy } from 'lodash' import { useUser } from 'web/hooks/use-user' -import { BinaryContract, CPMMBinaryContract } from 'common/contract' +import { + BinaryContract, + CPMMBinaryContract, + PseudoNumericContract, +} from 'common/contract' import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' @@ -21,7 +25,7 @@ import { APIError, placeBet } from 'web/lib/firebase/api-call' import { sellShares } from 'web/lib/firebase/api-call' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' -import { BinaryOutcomeLabel } from './outcome-label' +import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' import { calculatePayoutAfterCorrectBet, calculateShares, @@ -35,6 +39,7 @@ import { getCpmmProbability, getCpmmLiquidityFee, } from 'common/calculate-cpmm' +import { getFormattedMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' import { useSaveShares } from './use-save-shares' import { SignUpPrompt } from './sign-up-prompt' @@ -42,7 +47,7 @@ import { isIOS } from 'web/lib/util/device' import { track } from 'web/lib/service/analytics' export function BetPanel(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string }) { const { contract, className } = props @@ -81,7 +86,7 @@ export function BetPanel(props: { } export function BetPanelSwitcher(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string title?: string // Set if BetPanel is on a feed modal selected?: 'YES' | 'NO' @@ -89,7 +94,8 @@ export function BetPanelSwitcher(props: { }) { const { contract, className, title, selected, onBetSuccess } = props - const { mechanism } = contract + const { mechanism, outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) @@ -122,7 +128,12 @@ export function BetPanelSwitcher(props: { <Row className="items-center justify-between gap-2"> <div> You have {formatWithCommas(floorShares)}{' '} - <BinaryOutcomeLabel outcome={sharesOutcome} /> shares + {isPseudoNumeric ? ( + <PseudoNumericOutcomeLabel outcome={sharesOutcome} /> + ) : ( + <BinaryOutcomeLabel outcome={sharesOutcome} /> + )}{' '} + shares </div> {tradeType === 'BUY' && ( @@ -190,12 +201,13 @@ export function BetPanelSwitcher(props: { } function BuyPanel(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined selected?: 'YES' | 'NO' onBuySuccess?: () => void }) { const { contract, user, selected, onBuySuccess } = props + const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected) const [betAmount, setBetAmount] = useState<number | undefined>(undefined) @@ -302,6 +314,9 @@ function BuyPanel(props: { : 0) )} ${betChoice ?? 'YES'} shares` : undefined + + const format = getFormattedMappedValue(contract) + return ( <> <YesNoSelector @@ -309,6 +324,7 @@ function BuyPanel(props: { btnClassName="flex-1" selected={betChoice} onSelect={(choice) => onBetChoice(choice)} + isPseudoNumeric={isPseudoNumeric} /> <div className="my-3 text-left text-sm text-gray-500">Amount</div> <BuyAmountInput @@ -323,11 +339,13 @@ function BuyPanel(props: { <Col className="mt-3 w-full gap-3"> <Row className="items-center justify-between text-sm"> - <div className="text-gray-500">Probability</div> + <div className="text-gray-500"> + {isPseudoNumeric ? 'Estimated value' : 'Probability'} + </div> <div> - {formatPercent(initialProb)} + {format(initialProb)} <span className="mx-2">→</span> - {formatPercent(resultProb)} + {format(resultProb)} </div> </Row> @@ -340,6 +358,8 @@ function BuyPanel(props: { <br /> payout if{' '} <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} /> </> + ) : isPseudoNumeric ? ( + 'Max payout' ) : ( <> Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} /> @@ -389,7 +409,7 @@ function BuyPanel(props: { } export function SellPanel(props: { - contract: CPMMBinaryContract + contract: CPMMBinaryContract | PseudoNumericContract userBets: Bet[] shares: number sharesOutcome: 'YES' | 'NO' @@ -488,6 +508,10 @@ export function SellPanel(props: { } } + const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + const format = getFormattedMappedValue(contract) + return ( <> <AmountInput @@ -511,11 +535,13 @@ export function SellPanel(props: { <span className="text-neutral">{formatMoney(saleValue)}</span> </Row> <Row className="items-center justify-between"> - <div className="text-gray-500">Probability</div> + <div className="text-gray-500"> + {isPseudoNumeric ? 'Estimated value' : 'Probability'} + </div> <div> - {formatPercent(initialProb)} + {format(initialProb)} <span className="mx-2">→</span> - {formatPercent(resultProb)} + {format(resultProb)} </div> </Row> </Col> diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index 9621f7a9..ae5e0b00 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx' import { BetPanelSwitcher } from './bet-panel' import { YesNoSelector } from './yes-no-selector' -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { Modal } from './layout/modal' import { SellButton } from './sell-button' import { useUser } from 'web/hooks/use-user' @@ -12,7 +12,7 @@ import { useSaveShares } from './use-save-shares' // Inline version of a bet panel. Opens BetPanel in a new modal. export default function BetRow(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string btnClassName?: string betPanelClassName?: string @@ -32,6 +32,7 @@ export default function BetRow(props: { return ( <> <YesNoSelector + isPseudoNumeric={contract.outcomeType === 'PSEUDO_NUMERIC'} className={clsx('justify-end', className)} btnClassName={clsx('btn-sm w-24', btnClassName)} onSelect={(choice) => { diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index f41f89b6..b8fb7d31 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -8,6 +8,7 @@ import { useUserBets } from 'web/hooks/use-user-bets' import { Bet } from 'web/lib/firebase/bets' import { User } from 'web/lib/firebase/users' import { + formatLargeNumber, formatMoney, formatPercent, formatWithCommas, @@ -40,6 +41,7 @@ import { import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render' import { trackLatency } from 'web/lib/firebase/tracking' import { NumericContract } from 'common/contract' +import { formatNumericProbability } from 'common/pseudo-numeric' import { useUser } from 'web/hooks/use-user' import { SellSharesModal } from './sell-modal' @@ -366,6 +368,7 @@ export function BetsSummary(props: { const { contract, isYourBets, className } = props const { resolution, closeTime, outcomeType, mechanism } = contract const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isCpmm = mechanism === 'cpmm-1' const isClosed = closeTime && Date.now() > closeTime @@ -427,6 +430,25 @@ export function BetsSummary(props: { </div> </Col> </> + ) : isPseudoNumeric ? ( + <> + <Col> + <div className="whitespace-nowrap text-sm text-gray-500"> + Payout if {'>='} {formatLargeNumber(contract.max)} + </div> + <div className="whitespace-nowrap"> + {formatMoney(yesWinnings)} + </div> + </Col> + <Col> + <div className="whitespace-nowrap text-sm text-gray-500"> + Payout if {'<='} {formatLargeNumber(contract.min)} + </div> + <div className="whitespace-nowrap"> + {formatMoney(noWinnings)} + </div> + </Col> + </> ) : ( <Col> <div className="whitespace-nowrap text-sm text-gray-500"> @@ -507,13 +529,15 @@ export function ContractBetsTable(props: { const { isResolved, mechanism, outcomeType } = contract const isCPMM = mechanism === 'cpmm-1' const isNumeric = outcomeType === 'NUMERIC' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return ( <div className={clsx('overflow-x-auto', className)}> {amountRedeemed > 0 && ( <> <div className="pl-2 text-sm text-gray-500"> - {amountRedeemed} YES shares and {amountRedeemed} NO shares + {amountRedeemed} {isPseudoNumeric ? 'HIGHER' : 'YES'} shares and{' '} + {amountRedeemed} {isPseudoNumeric ? 'LOWER' : 'NO'} shares automatically redeemed for {formatMoney(amountRedeemed)}. </div> <Spacer h={4} /> @@ -541,7 +565,7 @@ export function ContractBetsTable(props: { )} {!isCPMM && !isResolved && <th>Payout if chosen</th>} <th>Shares</th> - <th>Probability</th> + {!isPseudoNumeric && <th>Probability</th>} <th>Date</th> </tr> </thead> @@ -585,6 +609,7 @@ function BetRow(props: { const isCPMM = mechanism === 'cpmm-1' const isNumeric = outcomeType === 'NUMERIC' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const saleAmount = saleBet?.sale?.amount @@ -628,14 +653,18 @@ function BetRow(props: { truncate="short" /> )} + {isPseudoNumeric && + ' than ' + formatNumericProbability(bet.probAfter, contract)} </td> <td>{formatMoney(Math.abs(amount))}</td> {!isCPMM && !isNumeric && <td>{saleDisplay}</td>} {!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>} <td>{formatWithCommas(Math.abs(shares))}</td> - <td> - {formatPercent(probBefore)} → {formatPercent(probAfter)} - </td> + {!isPseudoNumeric && ( + <td> + {formatPercent(probBefore)} → {formatPercent(probAfter)} + </td> + )} <td>{dayjs(createdTime).format('MMM D, h:mma')}</td> </tr> ) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 87239465..c6cda43c 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -9,6 +9,7 @@ import { BinaryContract, FreeResponseContract, NumericContract, + PseudoNumericContract, } from 'common/contract' import { AnswerLabel, @@ -16,7 +17,11 @@ import { CancelLabel, FreeResponseOutcomeLabel, } from '../outcome-label' -import { getOutcomeProbability, getTopAnswer } from 'common/calculate' +import { + getOutcomeProbability, + getProbability, + getTopAnswer, +} from 'common/calculate' import { AvatarDetails, MiscDetails, ShowTime } from './contract-details' import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' import { QuickBet, ProbBar, getColor } from './quick-bet' @@ -24,6 +29,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract' import { useUser } from 'web/hooks/use-user' import { track } from '@amplitude/analytics-browser' import { trackCallback } from 'web/lib/service/analytics' +import { formatNumericProbability } from 'common/pseudo-numeric' export function ContractCard(props: { contract: Contract @@ -131,6 +137,13 @@ export function ContractCard(props: { /> )} + {outcomeType === 'PSEUDO_NUMERIC' && ( + <PseudoNumericResolutionOrExpectation + className="items-center" + contract={contract} + /> + )} + {outcomeType === 'NUMERIC' && ( <NumericResolutionOrExpectation className="items-center" @@ -270,7 +283,9 @@ export function NumericResolutionOrExpectation(props: { {resolution === 'CANCEL' ? ( <CancelLabel /> ) : ( - <div className="text-blue-400">{resolutionValue}</div> + <div className="text-blue-400"> + {formatLargeNumber(resolutionValue)} + </div> )} </> ) : ( @@ -284,3 +299,42 @@ export function NumericResolutionOrExpectation(props: { </Col> ) } + +export function PseudoNumericResolutionOrExpectation(props: { + contract: PseudoNumericContract + className?: string +}) { + const { contract, className } = props + const { resolution, resolutionValue, resolutionProbability } = contract + const textColor = `text-blue-400` + + return ( + <Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}> + {resolution ? ( + <> + <div className={clsx('text-base text-gray-500')}>Resolved</div> + + {resolution === 'CANCEL' ? ( + <CancelLabel /> + ) : ( + <div className="text-blue-400"> + {resolutionValue + ? formatLargeNumber(resolutionValue) + : formatNumericProbability( + resolutionProbability ?? 0, + contract + )} + </div> + )} + </> + ) : ( + <> + <div className={clsx('text-3xl', textColor)}> + {formatNumericProbability(getProbability(contract), contract)} + </div> + <div className={clsx('text-base', textColor)}>expected</div> + </> + )} + </Col> + ) +} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index a68f37be..897bef04 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -11,6 +11,7 @@ import { FreeResponseResolutionOrChance, BinaryResolutionOrChance, NumericResolutionOrExpectation, + PseudoNumericResolutionOrExpectation, } from './contract-card' import { Bet } from 'common/bet' import BetRow from '../bet-row' @@ -32,6 +33,7 @@ export const ContractOverview = (props: { const user = useUser() const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return ( <Col className={clsx('mb-6', className)}> @@ -49,6 +51,13 @@ export const ContractOverview = (props: { /> )} + {isPseudoNumeric && ( + <PseudoNumericResolutionOrExpectation + contract={contract} + className="hidden items-end xl:flex" + /> + )} + {outcomeType === 'NUMERIC' && ( <NumericResolutionOrExpectation contract={contract} @@ -61,6 +70,11 @@ export const ContractOverview = (props: { <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> + {tradingAllowed(contract) && <BetRow contract={contract} />} + </Row> + ) : isPseudoNumeric ? ( + <Row className="items-center justify-between gap-4 xl:hidden"> + <PseudoNumericResolutionOrExpectation contract={contract} /> {tradingAllowed(contract) && <BetRow contract={contract} />} </Row> ) : ( @@ -86,7 +100,9 @@ export const ContractOverview = (props: { /> </Col> <Spacer h={4} /> - {isBinary && <ContractProbGraph contract={contract} bets={bets} />}{' '} + {(isBinary || isPseudoNumeric) && ( + <ContractProbGraph contract={contract} bets={bets} /> + )}{' '} {outcomeType === 'FREE_RESPONSE' && ( <AnswersGraph contract={contract} bets={bets} /> )} diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index 7386d748..a9d26e2e 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -5,16 +5,20 @@ import dayjs from 'dayjs' import { memo } from 'react' import { Bet } from 'common/bet' import { getInitialProbability } from 'common/calculate' -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { useWindowSize } from 'web/hooks/use-window-size' +import { getMappedValue } from 'common/pseudo-numeric' +import { formatLargeNumber } from 'common/util/format' export const ContractProbGraph = memo(function ContractProbGraph(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract bets: Bet[] height?: number }) { const { contract, height } = props - const { resolutionTime, closeTime } = contract + const { resolutionTime, closeTime, outcomeType } = contract + const isBinary = outcomeType === 'BINARY' + const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption) @@ -24,7 +28,10 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { contract.createdTime, ...bets.map((bet) => bet.createdTime), ].map((time) => new Date(time)) - const probs = [startProb, ...bets.map((bet) => bet.probAfter)] + + const f = getMappedValue(contract) + + const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f) const isClosed = !!closeTime && Date.now() > closeTime const latestTime = dayjs( @@ -39,7 +46,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { times.push(latestTime.toDate()) probs.push(probs[probs.length - 1]) - const yTickValues = [0, 25, 50, 75, 100] + const quartiles = [0, 25, 50, 75, 100] + + const yTickValues = isBinary + ? quartiles + : quartiles.map((x) => x / 100).map(f) const { width } = useWindowSize() @@ -55,9 +66,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const totalPoints = width ? (width > 800 ? 300 : 50) : 1 const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints + const points: { x: Date; y: number }[] = [] + const s = isBinary ? 100 : 1 + const c = isLogScale && contract.min === 0 ? 1 : 0 + for (let i = 0; i < times.length - 1; i++) { - points[points.length] = { x: times[i], y: probs[i] * 100 } + points[points.length] = { x: times[i], y: s * probs[i] + c } const numPoints: number = Math.floor( dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep ) @@ -69,17 +84,23 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { x: dayjs(times[i]) .add(thisTimeStep * n, 'ms') .toDate(), - y: probs[i] * 100, + y: s * probs[i] + c, } } } } - const data = [{ id: 'Yes', data: points, color: '#11b981' }] + const data = [ + { id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' }, + ] const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime) + const formatter = isBinary + ? formatPercent + : (x: DatumValue) => formatLargeNumber(+x.valueOf()) + return ( <div className="w-full overflow-visible" @@ -87,12 +108,20 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { > <ResponsiveLine data={data} - yScale={{ min: 0, max: 100, type: 'linear' }} - yFormat={formatPercent} + yScale={ + isBinary + ? { min: 0, max: 100, type: 'linear' } + : { + min: contract.min + c, + max: contract.max + c, + type: contract.isLogScale ? 'log' : 'linear', + } + } + yFormat={formatter} gridYValues={yTickValues} axisLeft={{ tickValues: yTickValues, - format: formatPercent, + format: formatter, }} xScale={{ type: 'time', diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 9ee8b165..adbcc456 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx' import { getOutcomeProbability, getOutcomeProbabilityAfterBet, + getProbability, getTopAnswer, } from 'common/calculate' import { getExpectedValue } from 'common/calculate-dpm' @@ -25,18 +26,18 @@ import { useSaveShares } from '../use-save-shares' import { sellShares } from 'web/lib/firebase/api-call' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { track } from 'web/lib/service/analytics' +import { formatNumericProbability } from 'common/pseudo-numeric' const BET_SIZE = 10 export function QuickBet(props: { contract: Contract; user: User }) { const { contract, user } = props - const isCpmm = contract.mechanism === 'cpmm-1' + const { mechanism, outcomeType } = contract + const isCpmm = mechanism === 'cpmm-1' const userBets = useUserContractBets(user.id, contract.id) const topAnswer = - contract.outcomeType === 'FREE_RESPONSE' - ? getTopAnswer(contract) - : undefined + outcomeType === 'FREE_RESPONSE' ? getTopAnswer(contract) : undefined // TODO: yes/no from useSaveShares doesn't work on numeric contracts const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( @@ -45,9 +46,9 @@ export function QuickBet(props: { contract: Contract; user: User }) { topAnswer?.number.toString() || undefined ) const hasUpShares = - yesFloorShares || (noFloorShares && contract.outcomeType === 'NUMERIC') + yesFloorShares || (noFloorShares && outcomeType === 'NUMERIC') const hasDownShares = - noFloorShares && yesFloorShares <= 0 && contract.outcomeType !== 'NUMERIC' + noFloorShares && yesFloorShares <= 0 && outcomeType !== 'NUMERIC' const [upHover, setUpHover] = useState(false) const [downHover, setDownHover] = useState(false) @@ -130,25 +131,6 @@ export function QuickBet(props: { contract: Contract; user: User }) { }) } - function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') { - if (contract.outcomeType === 'BINARY') { - return direction === 'UP' ? 'YES' : 'NO' - } - if (contract.outcomeType === 'FREE_RESPONSE') { - // TODO: Implement shorting of free response answers - if (direction === 'DOWN') { - throw new Error("Can't bet against free response answers") - } - return getTopAnswer(contract)?.id - } - if (contract.outcomeType === 'NUMERIC') { - // TODO: Ideally an 'UP' bet would be a uniform bet between [current, max] - throw new Error("Can't quick bet on numeric markets") - } - } - - const textColor = `text-${getColor(contract)}` - return ( <Col className={clsx( @@ -173,14 +155,14 @@ export function QuickBet(props: { contract: Contract; user: User }) { <TriangleFillIcon className={clsx( 'mx-auto h-5 w-5', - upHover ? textColor : 'text-gray-400' + upHover ? 'text-green-500' : 'text-gray-400' )} /> ) : ( <TriangleFillIcon className={clsx( 'mx-auto h-5 w-5', - upHover ? textColor : 'text-gray-200' + upHover ? 'text-green-500' : 'text-gray-200' )} /> )} @@ -189,7 +171,7 @@ export function QuickBet(props: { contract: Contract; user: User }) { <QuickOutcomeView contract={contract} previewProb={previewProb} /> {/* Down bet triangle */} - {contract.outcomeType !== 'BINARY' ? ( + {outcomeType !== 'BINARY' && outcomeType !== 'PSEUDO_NUMERIC' ? ( <div> <div className="peer absolute bottom-0 left-0 right-0 h-[50%] cursor-default"></div> <TriangleDownFillIcon @@ -254,6 +236,25 @@ export function ProbBar(props: { contract: Contract; previewProb?: number }) { ) } +function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') { + const { outcomeType } = contract + + if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { + return direction === 'UP' ? 'YES' : 'NO' + } + if (outcomeType === 'FREE_RESPONSE') { + // TODO: Implement shorting of free response answers + if (direction === 'DOWN') { + throw new Error("Can't bet against free response answers") + } + return getTopAnswer(contract)?.id + } + if (outcomeType === 'NUMERIC') { + // TODO: Ideally an 'UP' bet would be a uniform bet between [current, max] + throw new Error("Can't quick bet on numeric markets") + } +} + function QuickOutcomeView(props: { contract: Contract previewProb?: number @@ -261,9 +262,16 @@ function QuickOutcomeView(props: { }) { const { contract, previewProb, caption } = props const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + // If there's a preview prob, display that instead of the current prob const override = - previewProb === undefined ? undefined : formatPercent(previewProb) + previewProb === undefined + ? undefined + : isPseudoNumeric + ? formatNumericProbability(previewProb, contract) + : formatPercent(previewProb) + const textColor = `text-${getColor(contract)}` let display: string | undefined @@ -271,6 +279,9 @@ function QuickOutcomeView(props: { case 'BINARY': display = getBinaryProbPercent(contract) break + case 'PSEUDO_NUMERIC': + display = formatNumericProbability(getProbability(contract), contract) + break case 'NUMERIC': display = formatLargeNumber(getExpectedValue(contract)) break @@ -295,11 +306,15 @@ function QuickOutcomeView(props: { // Return a number from 0 to 1 for this contract // Resolved contracts are set to 1, for coloring purposes (even if NO) function getProb(contract: Contract) { - const { outcomeType, resolution } = contract - return resolution + const { outcomeType, resolution, resolutionProbability } = contract + return resolutionProbability + ? resolutionProbability + : resolution ? 1 : outcomeType === 'BINARY' ? getBinaryProb(contract) + : outcomeType === 'PSEUDO_NUMERIC' + ? getProbability(contract) : outcomeType === 'FREE_RESPONSE' ? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '') : outcomeType === 'NUMERIC' @@ -316,7 +331,8 @@ function getNumericScale(contract: NumericContract) { export function getColor(contract: Contract) { // TODO: Try injecting a gradient here // return 'primary' - const { resolution } = contract + const { resolution, outcomeType } = contract + if (resolution) { return ( OUTCOME_TO_COLOR[resolution as resolution] ?? @@ -325,6 +341,8 @@ export function getColor(contract: Contract) { ) } + if (outcomeType === 'PSEUDO_NUMERIC') return 'blue-400' + if ((contract.closeTime ?? Infinity) < Date.now()) { return 'gray-400' } diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index ae22b4b8..2ffdae8e 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -7,13 +7,14 @@ import { Row } from 'web/components/layout/row' import { Avatar, EmptyAvatar } from 'web/components/avatar' import clsx from 'clsx' import { UsersIcon } from '@heroicons/react/solid' -import { formatMoney } from 'common/util/format' +import { formatMoney, formatPercent } from 'common/util/format' import { OutcomeLabel } from 'web/components/outcome-label' import { RelativeTimestamp } from 'web/components/relative-timestamp' import React, { Fragment } from 'react' import { uniqBy, partition, sumBy, groupBy } from 'lodash' import { JoinSpans } from 'web/components/join-spans' import { UserLink } from '../user-page' +import { formatNumericProbability } from 'common/pseudo-numeric' export function FeedBet(props: { contract: Contract @@ -75,6 +76,8 @@ export function BetStatusText(props: { hideOutcome?: boolean }) { const { bet, contract, bettor, isSelf, hideOutcome } = props + const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const { amount, outcome, createdTime } = bet const bought = amount >= 0 ? 'bought' : 'sold' @@ -97,7 +100,10 @@ export function BetStatusText(props: { value={(bet as any).value} contract={contract} truncate="short" - /> + />{' '} + {isPseudoNumeric + ? ' than ' + formatNumericProbability(bet.probAfter, contract) + : ' at ' + formatPercent(bet.probAfter)} </> )} <RelativeTimestamp time={createdTime} /> diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index ebac68e5..cf111281 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -6,13 +6,14 @@ import { User } from 'web/lib/firebase/users' import { NumberCancelSelector } from './yes-no-selector' import { Spacer } from './layout/spacer' import { ResolveConfirmationButton } from './confirmation-button' +import { NumericContract, PseudoNumericContract } from 'common/contract' import { APIError, resolveMarket } from 'web/lib/firebase/api-call' -import { NumericContract } from 'common/contract' import { BucketInput } from './bucket-input' +import { getPseudoProbability } from 'common/pseudo-numeric' export function NumericResolutionPanel(props: { creator: User - contract: NumericContract + contract: NumericContract | PseudoNumericContract className?: string }) { useEffect(() => { @@ -21,6 +22,7 @@ export function NumericResolutionPanel(props: { }, []) const { contract, className } = props + const { min, max, outcomeType } = contract const [outcomeMode, setOutcomeMode] = useState< 'NUMBER' | 'CANCEL' | undefined @@ -32,15 +34,32 @@ export function NumericResolutionPanel(props: { const [error, setError] = useState<string | undefined>(undefined) const resolve = async () => { - const finalOutcome = outcomeMode === 'NUMBER' ? outcome : 'CANCEL' + const finalOutcome = + outcomeMode === 'CANCEL' + ? 'CANCEL' + : outcomeType === 'PSEUDO_NUMERIC' + ? 'MKT' + : 'NUMBER' if (outcomeMode === undefined || finalOutcome === undefined) return setIsSubmitting(true) + const boundedValue = Math.max(Math.min(max, value ?? 0), min) + + const probabilityInt = + 100 * + getPseudoProbability( + boundedValue, + min, + max, + outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale + ) + try { const result = await resolveMarket({ outcome: finalOutcome, value, + probabilityInt, contractId: contract.id, }) console.log('resolved', outcome, 'result:', result) @@ -77,7 +96,7 @@ export function NumericResolutionPanel(props: { {outcomeMode === 'NUMBER' && ( <BucketInput - contract={contract} + contract={contract as any} isSubmitting={isSubmitting} onBucketChange={(v, o) => (setValue(v), setOutcome(o))} /> diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index 054ebfd2..a94618e4 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -19,11 +19,15 @@ export function OutcomeLabel(props: { value?: number }) { const { outcome, contract, truncate, value } = props + const { outcomeType } = contract - if (contract.outcomeType === 'BINARY') + if (outcomeType === 'PSEUDO_NUMERIC') + return <PseudoNumericOutcomeLabel outcome={outcome as any} /> + + if (outcomeType === 'BINARY') return <BinaryOutcomeLabel outcome={outcome as any} /> - if (contract.outcomeType === 'NUMERIC') + if (outcomeType === 'NUMERIC') return ( <span className="text-blue-500"> {value ?? getValueFromBucket(outcome, contract)} @@ -49,6 +53,15 @@ export function BinaryOutcomeLabel(props: { outcome: resolution }) { return <CancelLabel /> } +export function PseudoNumericOutcomeLabel(props: { outcome: resolution }) { + const { outcome } = props + + if (outcome === 'YES') return <HigherLabel /> + if (outcome === 'NO') return <LowerLabel /> + if (outcome === 'MKT') return <ProbLabel /> + return <CancelLabel /> +} + export function BinaryContractOutcomeLabel(props: { contract: BinaryContract resolution: resolution @@ -98,6 +111,14 @@ export function YesLabel() { return <span className="text-primary">YES</span> } +export function HigherLabel() { + return <span className="text-primary">HIGHER</span> +} + +export function LowerLabel() { + return <span className="text-red-400">LOWER</span> +} + export function NoLabel() { return <span className="text-red-400">NO</span> } diff --git a/web/components/sell-button.tsx b/web/components/sell-button.tsx index 2b3734a5..51c88442 100644 --- a/web/components/sell-button.tsx +++ b/web/components/sell-button.tsx @@ -1,4 +1,4 @@ -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { User } from 'common/user' import { useUserContractBets } from 'web/hooks/use-user-bets' import { useState } from 'react' @@ -7,7 +7,7 @@ import clsx from 'clsx' import { SellSharesModal } from './sell-modal' export function SellButton(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined sharesOutcome: 'YES' | 'NO' | undefined shares: number @@ -16,7 +16,8 @@ export function SellButton(props: { const { contract, user, sharesOutcome, shares, panelClassName } = props const userBets = useUserContractBets(user?.id, contract.id) const [showSellModal, setShowSellModal] = useState(false) - const { mechanism } = contract + const { mechanism, outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' if (sharesOutcome && user && mechanism === 'cpmm-1') { return ( @@ -32,7 +33,10 @@ export function SellButton(props: { )} onClick={() => setShowSellModal(true)} > - {'Sell ' + sharesOutcome} + Sell{' '} + {isPseudoNumeric + ? { YES: 'HIGH', NO: 'LOW' }[sharesOutcome] + : sharesOutcome} </button> <div className={'mt-1 w-24 text-center text-sm text-gray-500'}> {'(' + Math.floor(shares) + ' shares)'} diff --git a/web/components/sell-modal.tsx b/web/components/sell-modal.tsx index f5a1af67..63cf79b2 100644 --- a/web/components/sell-modal.tsx +++ b/web/components/sell-modal.tsx @@ -1,4 +1,4 @@ -import { CPMMBinaryContract } from 'common/contract' +import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { Bet } from 'common/bet' import { User } from 'common/user' import { Modal } from './layout/modal' @@ -11,7 +11,7 @@ import clsx from 'clsx' export function SellSharesModal(props: { className?: string - contract: CPMMBinaryContract + contract: CPMMBinaryContract | PseudoNumericContract userBets: Bet[] shares: number sharesOutcome: 'YES' | 'NO' diff --git a/web/components/sell-row.tsx b/web/components/sell-row.tsx index 4fe2536f..a8cb2851 100644 --- a/web/components/sell-row.tsx +++ b/web/components/sell-row.tsx @@ -1,4 +1,4 @@ -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { User } from 'common/user' import { useState } from 'react' import { Col } from './layout/col' @@ -10,7 +10,7 @@ import { useSaveShares } from './use-save-shares' import { SellSharesModal } from './sell-modal' export function SellRow(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined className?: string }) { diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index d040eba9..cac7bf74 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -12,6 +12,7 @@ export function YesNoSelector(props: { btnClassName?: string replaceYesButton?: React.ReactNode replaceNoButton?: React.ReactNode + isPseudoNumeric?: boolean }) { const { selected, @@ -20,6 +21,7 @@ export function YesNoSelector(props: { btnClassName, replaceNoButton, replaceYesButton, + isPseudoNumeric, } = props const commonClassNames = @@ -41,7 +43,7 @@ export function YesNoSelector(props: { )} onClick={() => onSelect('YES')} > - Bet YES + {isPseudoNumeric ? 'HIGHER' : 'Bet YES'} </button> )} {replaceNoButton ? ( @@ -58,7 +60,7 @@ export function YesNoSelector(props: { )} onClick={() => onSelect('NO')} > - Bet NO + {isPseudoNumeric ? 'LOWER' : 'Bet NO'} </button> )} </Row> diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 413de725..2576c2e3 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -144,10 +144,12 @@ export function ContractPageContent( const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isNumeric = outcomeType === 'NUMERIC' const allowTrade = tradingAllowed(contract) const allowResolve = !isResolved && isCreator && !!user - const hasSidePanel = (isBinary || isNumeric) && (allowTrade || allowResolve) + const hasSidePanel = + (isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve) const ogCardProps = getOpenGraphProps(contract) @@ -170,7 +172,7 @@ export function ContractPageContent( <BetPanel className="hidden xl:flex" contract={contract} /> ))} {allowResolve && - (isNumeric ? ( + (isNumeric || isPseudoNumeric ? ( <NumericResolutionPanel creator={user} contract={contract} /> ) : ( <ResolutionPanel creator={user} contract={contract} /> @@ -210,10 +212,11 @@ export function ContractPageContent( )} <ContractOverview contract={contract} bets={bets} /> + {isNumeric && ( <AlertBox title="Warning" - text="Numeric markets were introduced as an experimental feature and are now deprecated." + text="Distributional numeric markets were introduced as an experimental feature and are now deprecated." /> )} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 7d645b04..c7b8f02e 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -85,8 +85,12 @@ export function NewContract(props: { const { creator, question, groupId } = props const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY') const [initialProb] = useState(50) + const [minString, setMinString] = useState('') const [maxString, setMaxString] = useState('') + const [isLogScale, setIsLogScale] = useState(false) + const [initialValueString, setInitialValueString] = useState('') + const [description, setDescription] = useState('') // const [tagText, setTagText] = useState<string>(tag ?? '') // const tags = parseWordsAsTags(tagText) @@ -129,6 +133,18 @@ export function NewContract(props: { const min = minString ? parseFloat(minString) : undefined const max = maxString ? parseFloat(maxString) : undefined + const initialValue = initialValueString + ? parseFloat(initialValueString) + : undefined + + const adjustIsLog = () => { + if (min === undefined || max === undefined) return + const lengthDiff = Math.log10(max - min) + if (lengthDiff > 2) { + setIsLogScale(true) + } + } + // get days from today until the end of this year: const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') @@ -145,13 +161,16 @@ export function NewContract(props: { // closeTime must be in the future closeTime && closeTime > Date.now() && - (outcomeType !== 'NUMERIC' || + (outcomeType !== 'PSEUDO_NUMERIC' || (min !== undefined && max !== undefined && + initialValue !== undefined && isFinite(min) && isFinite(max) && min < max && - max - min > 0.01)) + max - min > 0.01 && + min < initialValue && + initialValue < max)) function setCloseDateInDays(days: number) { const newCloseDate = dayjs().add(days, 'day').format('YYYY-MM-DD') @@ -175,6 +194,8 @@ export function NewContract(props: { closeTime, min, max, + initialValue, + isLogScale: (min ?? 0) < 0 ? false : isLogScale, groupId: selectedGroup?.id, tags: category ? [category] : undefined, }) @@ -220,6 +241,7 @@ export function NewContract(props: { choicesMap={{ 'Yes / No': 'BINARY', 'Free response': 'FREE_RESPONSE', + Numeric: 'PSEUDO_NUMERIC', }} isSubmitting={isSubmitting} className={'col-span-4'} @@ -232,38 +254,89 @@ export function NewContract(props: { <Spacer h={6} /> - {outcomeType === 'NUMERIC' && ( - <div className="form-control items-start"> - <label className="label gap-2"> - <span className="mb-1">Range</span> - <InfoTooltip text="The minimum and maximum numbers across the numeric range." /> - </label> + {outcomeType === 'PSEUDO_NUMERIC' && ( + <> + <div className="form-control mb-2 items-start"> + <label className="label gap-2"> + <span className="mb-1">Range</span> + <InfoTooltip text="The minimum and maximum numbers across the numeric range." /> + </label> - <Row className="gap-2"> - <input - type="number" - className="input input-bordered" - placeholder="MIN" - onClick={(e) => e.stopPropagation()} - onChange={(e) => setMinString(e.target.value)} - min={Number.MIN_SAFE_INTEGER} - max={Number.MAX_SAFE_INTEGER} - disabled={isSubmitting} - value={minString ?? ''} - /> - <input - type="number" - className="input input-bordered" - placeholder="MAX" - onClick={(e) => e.stopPropagation()} - onChange={(e) => setMaxString(e.target.value)} - min={Number.MIN_SAFE_INTEGER} - max={Number.MAX_SAFE_INTEGER} - disabled={isSubmitting} - value={maxString} - /> - </Row> - </div> + <Row className="gap-2"> + <input + type="number" + className="input input-bordered" + placeholder="MIN" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setMinString(e.target.value)} + onBlur={adjustIsLog} + min={Number.MIN_SAFE_INTEGER} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={minString ?? ''} + /> + <input + type="number" + className="input input-bordered" + placeholder="MAX" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setMaxString(e.target.value)} + onBlur={adjustIsLog} + min={Number.MIN_SAFE_INTEGER} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={maxString} + /> + </Row> + + {!(min !== undefined && min < 0) && ( + <Row className="mt-1 ml-2 mb-2 items-center"> + <span className="mr-2 text-sm">Log scale</span>{' '} + <input + type="checkbox" + checked={isLogScale} + onChange={() => setIsLogScale(!isLogScale)} + disabled={isSubmitting} + /> + </Row> + )} + + {min !== undefined && max !== undefined && min >= max && ( + <div className="mt-2 mb-2 text-sm text-red-500"> + The maximum value must be greater than the minimum. + </div> + )} + </div> + <div className="form-control mb-2 items-start"> + <label className="label gap-2"> + <span className="mb-1">Initial value</span> + <InfoTooltip text="The starting value for this market. Should be in between min and max values." /> + </label> + + <Row className="gap-2"> + <input + type="number" + className="input input-bordered" + placeholder="Initial value" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setInitialValueString(e.target.value)} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={initialValueString ?? ''} + /> + </Row> + + {initialValue !== undefined && + min !== undefined && + max !== undefined && + min < max && + (initialValue <= min || initialValue >= max) && ( + <div className="mt-2 mb-2 text-sm text-red-500"> + Initial value must be in between {min} and {max}.{' '} + </div> + )} + </div> + </> )} <div className="form-control max-w-[265px] items-start"> diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 98bf37b2..93439be7 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -7,6 +7,7 @@ import { BinaryResolutionOrChance, FreeResponseResolutionOrChance, NumericResolutionOrExpectation, + PseudoNumericResolutionOrExpectation, } from 'web/components/contract/contract-card' import { ContractDetails } from 'web/components/contract/contract-details' import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' @@ -79,6 +80,7 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const { question, outcomeType } = contract const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const href = `https://${DOMAIN}${contractPath(contract)}` @@ -110,13 +112,18 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { {isBinary && ( <Row className="items-center gap-4"> - {/* this fails typechecking, but it doesn't explode because we will - never */} - <BetRow contract={contract as any} betPanelClassName="scale-75" /> + <BetRow contract={contract} betPanelClassName="scale-75" /> <BinaryResolutionOrChance contract={contract} /> </Row> )} + {isPseudoNumeric && ( + <Row className="items-center gap-4"> + <BetRow contract={contract} betPanelClassName="scale-75" /> + <PseudoNumericResolutionOrExpectation contract={contract} /> + </Row> + )} + {outcomeType === 'FREE_RESPONSE' && ( <FreeResponseResolutionOrChance contract={contract} @@ -133,7 +140,7 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { </div> <div className="mx-1" style={{ paddingBottom }}> - {isBinary && ( + {(isBinary || isPseudoNumeric) && ( <ContractProbGraph contract={contract} bets={bets} From 218b18254cf33577369a3b2dbb2ee75145789ae2 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 2 Jul 2022 15:46:32 -0400 Subject: [PATCH 069/220] add liquidity: support pseudo numeric markets --- functions/src/add-liquidity.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/functions/src/add-liquidity.ts b/functions/src/add-liquidity.ts index 34d3f7c6..eca0a056 100644 --- a/functions/src/add-liquidity.ts +++ b/functions/src/add-liquidity.ts @@ -39,7 +39,8 @@ export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall( const contract = contractSnap.data() as Contract if ( contract.mechanism !== 'cpmm-1' || - contract.outcomeType !== 'BINARY' + (contract.outcomeType !== 'BINARY' && + contract.outcomeType !== 'PSEUDO_NUMERIC') ) return { status: 'error', message: 'Invalid contract' } From 18b87581916ace525dc256a176b12b0d9c96b886 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 2 Jul 2022 13:26:42 -0700 Subject: [PATCH 070/220] Remove code for obsolete feed updater backend jobs (#607) * Remove code for obsolete feed updater backend jobs * Kill two more obsolete guys --- functions/package.json | 1 - functions/src/call-cloud-function.ts | 17 -- functions/src/fetch.ts | 9 - functions/src/keep-awake.ts | 25 --- functions/src/scripts/update-feed.ts | 53 ------ functions/src/update-feed.ts | 220 ------------------------ functions/src/update-recommendations.ts | 70 -------- yarn.lock | 29 +--- 8 files changed, 1 insertion(+), 423 deletions(-) delete mode 100644 functions/src/call-cloud-function.ts delete mode 100644 functions/src/fetch.ts delete mode 100644 functions/src/keep-awake.ts delete mode 100644 functions/src/scripts/update-feed.ts delete mode 100644 functions/src/update-feed.ts delete mode 100644 functions/src/update-recommendations.ts diff --git a/functions/package.json b/functions/package.json index eb6c7151..ed12b4e7 100644 --- a/functions/package.json +++ b/functions/package.json @@ -23,7 +23,6 @@ "main": "functions/src/index.js", "dependencies": { "@amplitude/node": "1.10.0", - "fetch": "1.1.0", "firebase-admin": "10.0.0", "firebase-functions": "3.21.2", "lodash": "4.17.21", diff --git a/functions/src/call-cloud-function.ts b/functions/src/call-cloud-function.ts deleted file mode 100644 index 35191343..00000000 --- a/functions/src/call-cloud-function.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as admin from 'firebase-admin' - -import fetch from './fetch' - -export const callCloudFunction = (functionName: string, data: unknown = {}) => { - const projectId = admin.instanceId().app.options.projectId - - const url = `https://us-central1-${projectId}.cloudfunctions.net/${functionName}` - - return fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ data }), - }).then((response) => response.json()) -} diff --git a/functions/src/fetch.ts b/functions/src/fetch.ts deleted file mode 100644 index 1b54dc6c..00000000 --- a/functions/src/fetch.ts +++ /dev/null @@ -1,9 +0,0 @@ -let fetchRequest: typeof fetch - -try { - fetchRequest = fetch -} catch { - fetchRequest = require('node-fetch') -} - -export default fetchRequest diff --git a/functions/src/keep-awake.ts b/functions/src/keep-awake.ts deleted file mode 100644 index 00799e65..00000000 --- a/functions/src/keep-awake.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as functions from 'firebase-functions' - -import { callCloudFunction } from './call-cloud-function' - -export const keepAwake = functions.pubsub - .schedule('every 1 minutes') - .onRun(async () => { - await Promise.all([ - callCloudFunction('placeBet'), - callCloudFunction('resolveMarket'), - callCloudFunction('sellBet'), - ]) - - await sleep(30) - - await Promise.all([ - callCloudFunction('placeBet'), - callCloudFunction('resolveMarket'), - callCloudFunction('sellBet'), - ]) - }) - -const sleep = (seconds: number) => { - return new Promise((resolve) => setTimeout(resolve, seconds * 1000)) -} diff --git a/functions/src/scripts/update-feed.ts b/functions/src/scripts/update-feed.ts deleted file mode 100644 index c5cba142..00000000 --- a/functions/src/scripts/update-feed.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as admin from 'firebase-admin' - -import { initAdmin } from './script-init' -initAdmin() - -import { getValues } from '../utils' -import { User } from '../../../common/user' -import { batchedWaitAll } from '../../../common/util/promise' -import { Contract } from '../../../common/contract' -import { updateWordScores } from '../update-recommendations' -import { computeFeed } from '../update-feed' -import { getFeedContracts, getTaggedContracts } from '../get-feed-data' -import { CATEGORY_LIST } from '../../../common/categories' - -const firestore = admin.firestore() - -async function updateFeed() { - console.log('Updating feed') - - const contracts = await getValues<Contract>(firestore.collection('contracts')) - const feedContracts = await getFeedContracts() - const users = await getValues<User>( - firestore.collection('users').where('username', '==', 'JamesGrugett') - ) - - await batchedWaitAll( - users.map((user) => async () => { - console.log('Updating recs for', user.username) - await updateWordScores(user, contracts) - console.log('Updating feed for', user.username) - await computeFeed(user, feedContracts) - }) - ) - - console.log('Updating feed categories!') - - await batchedWaitAll( - users.map((user) => async () => { - for (const category of CATEGORY_LIST) { - const contracts = await getTaggedContracts(category) - const feed = await computeFeed(user, contracts) - await firestore - .collection(`private-users/${user.id}/cache`) - .doc(`feed-${category}`) - .set({ feed }) - } - }) - ) -} - -if (require.main === module) { - updateFeed().then(() => process.exit()) -} diff --git a/functions/src/update-feed.ts b/functions/src/update-feed.ts deleted file mode 100644 index f19fda92..00000000 --- a/functions/src/update-feed.ts +++ /dev/null @@ -1,220 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' -import { flatten, shuffle, sortBy, uniq, zip, zipObject } from 'lodash' - -import { getValue, getValues } from './utils' -import { Contract } from '../../common/contract' -import { logInterpolation } from '../../common/util/math' -import { DAY_MS } from '../../common/util/time' -import { - getProbability, - getOutcomeProbability, - getTopAnswer, -} from '../../common/calculate' -import { User } from '../../common/user' -import { - getContractScore, - MAX_FEED_CONTRACTS, -} from '../../common/recommended-contracts' -import { callCloudFunction } from './call-cloud-function' -import { - getFeedContracts, - getRecentBetsAndComments, - getTaggedContracts, -} from './get-feed-data' -import { CATEGORY_LIST } from '../../common/categories' - -const firestore = admin.firestore() - -const BATCH_SIZE = 30 -const MAX_BATCHES = 50 - -const getUserBatches = async () => { - const users = shuffle(await getValues<User>(firestore.collection('users'))) - const userBatches: User[][] = [] - for (let i = 0; i < users.length; i += BATCH_SIZE) { - userBatches.push(users.slice(i, i + BATCH_SIZE)) - } - - console.log('updating feed batches', MAX_BATCHES, 'of', userBatches.length) - - return userBatches.slice(0, MAX_BATCHES) -} - -export const updateFeed = functions.pubsub - .schedule('every 60 minutes') - .onRun(async () => { - const userBatches = await getUserBatches() - - await Promise.all( - userBatches.map((users) => - callCloudFunction('updateFeedBatch', { users }) - ) - ) - - console.log('updating category feed') - - await Promise.all( - CATEGORY_LIST.map((category) => - callCloudFunction('updateCategoryFeed', { - category, - }) - ) - ) - }) - -export const updateFeedBatch = functions.https.onCall( - async (data: { users: User[] }) => { - const { users } = data - const contracts = await getFeedContracts() - const feeds = await getNewFeeds(users, contracts) - await Promise.all( - zip(users, feeds).map(([user, feed]) => - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - getUserCacheCollection(user!).doc('feed').set({ feed }) - ) - ) - } -) - -export const updateCategoryFeed = functions.https.onCall( - async (data: { category: string }) => { - const { category } = data - const userBatches = await getUserBatches() - - await Promise.all( - userBatches.map(async (users) => { - await callCloudFunction('updateCategoryFeedBatch', { - users, - category, - }) - }) - ) - } -) - -export const updateCategoryFeedBatch = functions.https.onCall( - async (data: { users: User[]; category: string }) => { - const { users, category } = data - const contracts = await getTaggedContracts(category) - const feeds = await getNewFeeds(users, contracts) - await Promise.all( - zip(users, feeds).map(([user, feed]) => - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - getUserCacheCollection(user!).doc(`feed-${category}`).set({ feed }) - ) - ) - } -) - -const getNewFeeds = async (users: User[], contracts: Contract[]) => { - const feeds = await Promise.all(users.map((u) => computeFeed(u, contracts))) - const contractIds = uniq(flatten(feeds).map((c) => c.id)) - const data = await Promise.all(contractIds.map(getRecentBetsAndComments)) - const dataByContractId = zipObject(contractIds, data) - return feeds.map((feed) => - feed.map((contract) => { - return { contract, ...dataByContractId[contract.id] } - }) - ) -} - -const getUserCacheCollection = (user: User) => - firestore.collection(`private-users/${user.id}/cache`) - -export const computeFeed = async (user: User, contracts: Contract[]) => { - const userCacheCollection = getUserCacheCollection(user) - - const [wordScores, lastViewedTime] = await Promise.all([ - getValue<{ [word: string]: number }>(userCacheCollection.doc('wordScores')), - getValue<{ [contractId: string]: number }>( - userCacheCollection.doc('lastViewTime') - ), - ]).then((dicts) => dicts.map((dict) => dict ?? {})) - - const scoredContracts = contracts.map((contract) => { - const score = scoreContract( - contract, - wordScores, - lastViewedTime[contract.id] - ) - return [contract, score] as [Contract, number] - }) - - const sortedContracts = sortBy( - scoredContracts, - ([_, score]) => score - ).reverse() - - // console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score)) - - return sortedContracts.slice(0, MAX_FEED_CONTRACTS).map(([c]) => c) -} - -function scoreContract( - contract: Contract, - wordScores: { [word: string]: number }, - viewTime: number | undefined -) { - const recommendationScore = getContractScore(contract, wordScores) - const activityScore = getActivityScore(contract, viewTime) - // const lastViewedScore = getLastViewedScore(viewTime) - return recommendationScore * activityScore -} - -function getActivityScore(contract: Contract, viewTime: number | undefined) { - const { createdTime, lastBetTime, lastCommentTime, outcomeType } = contract - const hasNewComments = - lastCommentTime && (!viewTime || lastCommentTime > viewTime) - const newCommentScore = hasNewComments ? 1 : 0.5 - - const timeSinceLastComment = Date.now() - (lastCommentTime ?? createdTime) - const commentDaysAgo = timeSinceLastComment / DAY_MS - const commentTimeScore = - 0.25 + 0.75 * (1 - logInterpolation(0, 3, commentDaysAgo)) - - const timeSinceLastBet = Date.now() - (lastBetTime ?? createdTime) - const betDaysAgo = timeSinceLastBet / DAY_MS - const betTimeScore = 0.5 + 0.5 * (1 - logInterpolation(0, 3, betDaysAgo)) - - let prob = 0.5 - if (outcomeType === 'BINARY') { - prob = getProbability(contract) - } else if (outcomeType === 'FREE_RESPONSE') { - const topAnswer = getTopAnswer(contract) - if (topAnswer) - prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id)) - } - const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25 - const probScore = 0.5 + frac * 0.5 - - const { volume24Hours, volume7Days } = contract - const combinedVolume = Math.log(volume24Hours + 1) + Math.log(volume7Days + 1) - const volumeScore = 0.5 + 0.5 * logInterpolation(4, 20, combinedVolume) - - const score = - newCommentScore * commentTimeScore * betTimeScore * probScore * volumeScore - - // Map score to [0.5, 1] since no recent activty is not a deal breaker. - const mappedScore = 0.5 + 0.5 * score - const newMappedScore = 0.7 + 0.3 * score - - const isNew = Date.now() < contract.createdTime + DAY_MS - return isNew ? newMappedScore : mappedScore -} - -// function getLastViewedScore(viewTime: number | undefined) { -// if (viewTime === undefined) { -// return 1 -// } - -// const daysAgo = (Date.now() - viewTime) / DAY_MS - -// if (daysAgo < 0.5) { -// const frac = logInterpolation(0, 0.5, daysAgo) -// return 0.5 + 0.25 * frac -// } - -// const frac = logInterpolation(0.5, 14, daysAgo) -// return 0.75 + 0.25 * frac -// } diff --git a/functions/src/update-recommendations.ts b/functions/src/update-recommendations.ts deleted file mode 100644 index bc82291c..00000000 --- a/functions/src/update-recommendations.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' - -import { getValue, getValues } from './utils' -import { Contract } from '../../common/contract' -import { Bet } from '../../common/bet' -import { User } from '../../common/user' -import { ClickEvent } from '../../common/tracking' -import { getWordScores } from '../../common/recommended-contracts' -import { batchedWaitAll } from '../../common/util/promise' -import { callCloudFunction } from './call-cloud-function' - -const firestore = admin.firestore() - -export const updateRecommendations = functions.pubsub - .schedule('every 24 hours') - .onRun(async () => { - const users = await getValues<User>(firestore.collection('users')) - - const batchSize = 100 - const userBatches: User[][] = [] - for (let i = 0; i < users.length; i += batchSize) { - userBatches.push(users.slice(i, i + batchSize)) - } - - await Promise.all( - userBatches.map((batch) => - callCloudFunction('updateRecommendationsBatch', { users: batch }) - ) - ) - }) - -export const updateRecommendationsBatch = functions.https.onCall( - async (data: { users: User[] }) => { - const { users } = data - - const contracts = await getValues<Contract>( - firestore.collection('contracts') - ) - - await batchedWaitAll( - users.map((user) => () => updateWordScores(user, contracts)) - ) - } -) - -export const updateWordScores = async (user: User, contracts: Contract[]) => { - const [bets, viewCounts, clicks] = await Promise.all([ - getValues<Bet>( - firestore.collectionGroup('bets').where('userId', '==', user.id) - ), - - getValue<{ [contractId: string]: number }>( - firestore.doc(`private-users/${user.id}/cache/viewCounts`) - ), - - getValues<ClickEvent>( - firestore - .collection(`private-users/${user.id}/events`) - .where('type', '==', 'click') - ), - ]) - - const wordScores = getWordScores(contracts, viewCounts ?? {}, clicks, bets) - - const cachedCollection = firestore.collection( - `private-users/${user.id}/cache` - ) - await cachedCollection.doc('wordScores').set(wordScores) -} diff --git a/yarn.lock b/yarn.lock index 15cd3c51..c07d548f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3875,13 +3875,6 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== -biskviit@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/biskviit/-/biskviit-1.0.1.tgz#037a0cd4b71b9e331fd90a1122de17dc49e420a7" - integrity sha512-VGCXdHbdbpEkFgtjkeoBN8vRlbj1ZRX2/mxhE8asCCRalUx2nBzOomLJv8Aw/nRt5+ccDb+tPKidg4XxcfGW4w== - dependencies: - psl "^1.1.7" - bluebird@^3.7.1: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -5237,13 +5230,6 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= -encoding@0.1.12: - version "0.1.12" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" - integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s= - dependencies: - iconv-lite "~0.4.13" - end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -5817,14 +5803,6 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" -fetch@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/fetch/-/fetch-1.1.0.tgz#0a8279f06be37f9f0ebb567560a30a480da59a2e" - integrity sha1-CoJ58Gvjf58Ou1Z1YKMKSA2lmi4= - dependencies: - biskviit "1.0.1" - encoding "0.1.12" - file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -6782,7 +6760,7 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -iconv-lite@0.4.24, iconv-lite@~0.4.13: +iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -9151,11 +9129,6 @@ pseudomap@^1.0.1: resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= -psl@^1.1.7: - version "1.8.0" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" - integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== - pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" From 90d7f55c6d7b80cb6395276b157f93fd4451e99c Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 2 Jul 2022 13:27:06 -0700 Subject: [PATCH 071/220] Fix backup DB job to actually backup most things, refactor (#605) * Make backup manually invokable and thereby testable * Add a shitload of missing stuff to our backups * Also backup follows as per James --- functions/src/backup-db.ts | 91 +++++++++++++++++----------- functions/src/scripts/backup-db.ts | 16 +++++ functions/src/scripts/script-init.ts | 19 +++--- 3 files changed, 81 insertions(+), 45 deletions(-) create mode 100644 functions/src/scripts/backup-db.ts diff --git a/functions/src/backup-db.ts b/functions/src/backup-db.ts index 5174f595..227c89e4 100644 --- a/functions/src/backup-db.ts +++ b/functions/src/backup-db.ts @@ -18,46 +18,63 @@ import * as functions from 'firebase-functions' import * as firestore from '@google-cloud/firestore' -const client = new firestore.v1.FirestoreAdminClient() +import { FirestoreAdminClient } from '@google-cloud/firestore/types/v1/firestore_admin_client' -const bucket = 'gs://manifold-firestore-backup' +export const backupDbCore = async ( + client: FirestoreAdminClient, + project: string, + bucket: string +) => { + const name = client.databasePath(project, '(default)') + const outputUriPrefix = `gs://${bucket}` + // Leave collectionIds empty to export all collections + // or set to a list of collection IDs to export, + // collectionIds: ['users', 'posts'] + // NOTE: Subcollections are not backed up by default + const collectionIds = [ + 'contracts', + 'groups', + 'private-users', + 'stripe-transactions', + 'transactions', + 'users', + 'bets', + 'comments', + 'follows', + 'followers', + 'answers', + 'txns', + 'manalinks', + 'liquidity', + 'stats', + 'cache', + 'latency', + 'views', + 'notifications', + 'portfolioHistory', + 'folds', + ] + return await client.exportDocuments({ name, outputUriPrefix, collectionIds }) +} export const backupDb = functions.pubsub .schedule('every 24 hours') - .onRun((_context) => { - const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT - if (projectId == null) { - throw new Error('No project ID environment variable set.') + .onRun(async (_context) => { + try { + const client = new firestore.v1.FirestoreAdminClient() + const project = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT + if (project == null) { + throw new Error('No project ID environment variable set.') + } + const responses = await backupDbCore( + client, + project, + 'manifold-firestore-backup' + ) + const response = responses[0] + console.log(`Operation Name: ${response['name']}`) + } catch (err) { + console.error(err) + throw new Error('Export operation failed') } - const databaseName = client.databasePath(projectId, '(default)') - - return client - .exportDocuments({ - name: databaseName, - outputUriPrefix: bucket, - // Leave collectionIds empty to export all collections - // or set to a list of collection IDs to export, - // collectionIds: ['users', 'posts'] - // NOTE: Subcollections are not backed up by default - collectionIds: [ - 'contracts', - 'groups', - 'private-users', - 'stripe-transactions', - 'users', - 'bets', - 'comments', - 'followers', - 'answers', - 'txns', - ], - }) - .then((responses) => { - const response = responses[0] - console.log(`Operation Name: ${response['name']}`) - }) - .catch((err) => { - console.error(err) - throw new Error('Export operation failed') - }) }) diff --git a/functions/src/scripts/backup-db.ts b/functions/src/scripts/backup-db.ts new file mode 100644 index 00000000..04c66438 --- /dev/null +++ b/functions/src/scripts/backup-db.ts @@ -0,0 +1,16 @@ +import * as firestore from '@google-cloud/firestore' +import { getServiceAccountCredentials } from './script-init' +import { backupDbCore } from '../backup-db' + +async function backupDb() { + const credentials = getServiceAccountCredentials() + const projectId = credentials.project_id + const client = new firestore.v1.FirestoreAdminClient({ credentials }) + const bucket = 'manifold-firestore-backup' + const resp = await backupDbCore(client, projectId, bucket) + console.log(`Operation: ${resp[0]['name']}`) +} + +if (require.main === module) { + backupDb().then(() => process.exit()) +} diff --git a/functions/src/scripts/script-init.ts b/functions/src/scripts/script-init.ts index 8f65e4be..cc17a620 100644 --- a/functions/src/scripts/script-init.ts +++ b/functions/src/scripts/script-init.ts @@ -47,26 +47,29 @@ const getFirebaseActiveProject = (cwd: string) => { } } -export const initAdmin = (env?: string) => { +export const getServiceAccountCredentials = (env?: string) => { env = env || getFirebaseActiveProject(process.cwd()) if (env == null) { - console.error( + throw new Error( "Couldn't find active Firebase project; did you do `firebase use <alias>?`" ) - return } const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}` const keyPath = process.env[envVar] if (keyPath == null) { - console.error( + throw new Error( `Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.` ) - return } - console.log(`Initializing connection to ${env} Firebase...`) /* eslint-disable-next-line @typescript-eslint/no-var-requires */ - const serviceAccount = require(keyPath) - admin.initializeApp({ + return require(keyPath) +} + +export const initAdmin = (env?: string) => { + const serviceAccount = getServiceAccountCredentials(env) + console.log(`Initializing connection to ${serviceAccount.project_id}...`) + return admin.initializeApp({ + projectId: serviceAccount.project_id, credential: admin.credential.cert(serviceAccount), }) } From 7dea9cbfa89336bcb2672800e4ac3418ac807280 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 2 Jul 2022 16:24:03 -0700 Subject: [PATCH 072/220] Use `getAll` Firestore technology to improve some code (#612) --- functions/src/place-bet.ts | 5 +---- functions/src/sell-bet.ts | 10 +++++----- functions/src/sell-shares.ts | 5 ++--- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index b6c7d267..43906f3c 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -41,10 +41,7 @@ export const placebet = newEndpoint({}, async (req, auth) => { log('Inside main transaction.') const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) - const [contractSnap, userSnap] = await Promise.all([ - trans.get(contractDoc), - trans.get(userDoc), - ]) + const [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') log('Loaded user and contract snapshots.') diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index b3362159..18df4536 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -21,11 +21,11 @@ export const sellbet = newEndpoint({}, async (req, auth) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`) - const [contractSnap, userSnap, betSnap] = await Promise.all([ - transaction.get(contractDoc), - transaction.get(userDoc), - transaction.get(betDoc), - ]) + const [contractSnap, userSnap, betSnap] = await transaction.getAll( + contractDoc, + userDoc, + betDoc + ) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') if (!betSnap.exists) throw new APIError(400, 'Bet not found.') diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 26374a16..a0c19f2c 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -24,9 +24,8 @@ 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, userBets] = await Promise.all([ - transaction.get(contractDoc), - transaction.get(userDoc), + const [[contractSnap, userSnap], userBets] = await Promise.all([ + transaction.getAll(contractDoc, userDoc), getValues<Bet>(betsQ), // TODO: why is this not in the transaction?? ]) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') From 960f8a1b3d20e2c6a2829bbd97fb5ba56dad64e0 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Sun, 3 Jul 2022 20:18:12 +0100 Subject: [PATCH 073/220] Toggle weekly leaderboard and daily/weekly/alltime portfolio graph (#616) * Toggle weekly leaderboard and daily/weekly/alltime portfolio graph * Formatmoney for tooltip value --- .../portfolio/portfolio-value-graph.tsx | 3 ++- .../portfolio/portfolio-value-section.tsx | 15 +++++++++++---- web/components/user-page.tsx | 11 +++++++---- web/pages/leaderboards.tsx | 4 +++- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/web/components/portfolio/portfolio-value-graph.tsx b/web/components/portfolio/portfolio-value-graph.tsx index 558fc5f6..50a6b59a 100644 --- a/web/components/portfolio/portfolio-value-graph.tsx +++ b/web/components/portfolio/portfolio-value-graph.tsx @@ -52,7 +52,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { margin={{ top: 20, right: 28, bottom: 22, left: 60 }} xScale={{ type: 'time', - min: points[0].x, + min: points[0]?.x, max: endDate, }} yScale={{ @@ -77,6 +77,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { enableGridY={true} enableSlices="x" animate={false} + yFormat={(value) => formatMoney(+value)} ></ResponsiveLine> </div> ) diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index a992e87e..55260bb5 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -13,7 +13,7 @@ export const PortfolioValueSection = memo( }) { const { portfolioHistory } = props const lastPortfolioMetrics = last(portfolioHistory) - const [portfolioPeriod] = useState<Period>('allTime') + const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime') if (portfolioHistory.length === 0 || !lastPortfolioMetrics) { return <div> No portfolio history data yet </div> @@ -33,9 +33,16 @@ export const PortfolioValueSection = memo( </div> </Col> </div> - { - //TODO: enable day/week/monthly as data becomes available - } + <select + className="select select-bordered self-start" + onChange={(e) => { + setPortfolioPeriod(e.target.value as Period) + }} + > + <option value="allTime">All time</option> + <option value="weekly">Weekly</option> + <option value="daily">Daily</option> + </select> </Row> <PortfolioValueGraph portfolioHistory={portfolioHistory} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index ccacca04..d72a2a16 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -38,6 +38,7 @@ import { FollowButton } from './follow-button' import { PortfolioMetrics } from 'common/user' import { ReferralsButton } from 'web/components/referrals-button' import { GroupsButton } from 'web/components/groups/groups-button' +import { PortfolioValueSection } from './portfolio/portfolio-value-section' export function UserLink(props: { name: string @@ -75,7 +76,9 @@ export function UserPage(props: { 'loading' ) const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading') - const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([]) + const [portfolioHistory, setUsersPortfolioHistory] = useState< + PortfolioMetrics[] + >([]) const [commentsByContract, setCommentsByContract] = useState< Map<Contract, Comment[]> | 'loading' >('loading') @@ -297,9 +300,9 @@ export function UserPage(props: { title: 'Bets', content: ( <div> - { - // TODO: add portfolio-value-section here - } + <PortfolioValueSection + portfolioHistory={portfolioHistory} + /> <BetsList user={user} hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022} diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 44c0a65b..f306493b 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -67,7 +67,9 @@ export default function Leaderboards(props: { <Col className="mx-4 items-center gap-10 lg:flex-row"> {!isLoading ? ( <> - {period === 'allTime' || period === 'daily' ? ( //TODO: show other periods once they're available + {period === 'allTime' || + period == 'weekly' || + period === 'daily' ? ( //TODO: show other periods once they're available <Leaderboard title="🏅 Top bettors" users={topTradersState} From 8fdc44f7f3bd2eaa28bd3448b98c421b02abeda3 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 3 Jul 2022 15:37:22 -0400 Subject: [PATCH 074/220] Switch to firebase dev before serving firebase emulators --- functions/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/package.json b/functions/package.json index ed12b4e7..ee7bc92d 100644 --- a/functions/package.json +++ b/functions/package.json @@ -12,7 +12,7 @@ "start": "yarn shell", "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", - "serve": "yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", + "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export", "db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", From 9839b7b5a40fd349802145eb932f98b8864dbd3a Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 3 Jul 2022 16:45:52 -0700 Subject: [PATCH 075/220] Allow customizing starting balance & antes --- common/antes.ts | 7 ++----- common/user.ts | 7 +++++-- web/pages/create.tsx | 3 +-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/common/antes.ts b/common/antes.ts index becc9b7e..d4cb2ff9 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -10,12 +10,9 @@ import { import { User } from './user' import { LiquidityProvision } from './liquidity-provision' import { noFees } from './fees' +import { ENV_CONFIG } from './envs/constants' -export const FIXED_ANTE = 100 - -// deprecated -export const PHANTOM_ANTE = 0.001 -export const MINIMUM_ANTE = 50 +export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100 export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id diff --git a/common/user.ts b/common/user.ts index 0a8565dd..d5dd0373 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,3 +1,5 @@ +import { ENV_CONFIG } from './envs/constants' + export type User = { id: string createdTime: number @@ -38,8 +40,9 @@ export type User = { referredByContractId?: string } -export const STARTING_BALANCE = 1000 -export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person +export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 +// for sus users, i.e. multiple sign ups for same person +export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10 export const REFERRAL_AMOUNT = 500 export type PrivateUser = { id: string // same as User.id diff --git a/web/pages/create.tsx b/web/pages/create.tsx index c7b8f02e..6a5f96ae 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -7,7 +7,7 @@ import { Spacer } from 'web/components/layout/spacer' import { useUser } from 'web/hooks/use-user' import { Contract, contractPath } from 'web/lib/firebase/contracts' import { createMarket } from 'web/lib/firebase/api-call' -import { FIXED_ANTE, MINIMUM_ANTE } from 'common/antes' +import { FIXED_ANTE } from 'common/antes' import { InfoTooltip } from 'web/components/info-tooltip' import { Page } from 'web/components/page' import { Row } from 'web/components/layout/row' @@ -156,7 +156,6 @@ export function NewContract(props: { question.length > 0 && ante !== undefined && ante !== null && - ante >= MINIMUM_ANTE && ante <= balance && // closeTime must be in the future closeTime && From 579dcd81dc5e2b8b26b3442ad928be72036b6a41 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 1 Jul 2022 11:01:36 -0700 Subject: [PATCH 076/220] Update env config template --- common/envs/prod.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/common/envs/prod.ts b/common/envs/prod.ts index f5a0e55e..f8aaf4cc 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -18,13 +18,17 @@ export type EnvConfig = { faviconPath?: string // Should be a file in /public navbarLogoPath?: string newQuestionPlaceholders: string[] + + // Currency controls + fixedAnte?: number + startingBalance?: number } type FirebaseConfig = { apiKey: string authDomain: string projectId: string - region: string + region?: string storageBucket: string messagingSenderId: string appId: string From d78bbcb3df761eca0e42362577bbb1ebcc16a80a Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 3 Jul 2022 23:43:18 -0400 Subject: [PATCH 077/220] fix navbar tracking --- web/components/nav/nav-bar.tsx | 4 +++- web/components/nav/sidebar.tsx | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 5a997b46..9f0f8ddd 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -63,6 +63,7 @@ export function BottomNavBar() { currentPage={currentPage} item={{ name: formatMoney(user.balance), + trackingEventName: 'profile', href: `/${user.username}?tab=bets`, icon: () => ( <Avatar @@ -94,6 +95,7 @@ export function BottomNavBar() { function NavBarItem(props: { item: Item; currentPage: string }) { const { item, currentPage } = props + const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`) return ( <Link href={item.href}> @@ -102,7 +104,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) { 'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700', currentPage === item.href && 'bg-gray-200 text-indigo-700' )} - onClick={trackCallback('navbar: ' + item.name)} + onClick={track} > {item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />} {item.name} diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 8c3ceb02..5ce9e239 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -120,6 +120,7 @@ function getMoreMobileNav() { export type Item = { name: string + trackingEventName?: string href: string icon?: React.ComponentType<{ className?: string }> } From e712ad82891e2d14d685442ae7689c7d51fea4e6 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 4 Jul 2022 07:49:41 -0600 Subject: [PATCH 078/220] Allow users to choose who referred them (#611) * Allow users to choose who referred them * Refactor * Rewording * Match list styles * Match empty text styles --- firestore.rules | 19 ++- web/components/filter-select-users.tsx | 194 +++++++++++++++---------- web/components/referrals-button.tsx | 96 +++++++++++- web/components/user-page.tsx | 2 +- web/pages/account.tsx | 41 ------ web/pages/link/[slug].tsx | 2 +- 6 files changed, 221 insertions(+), 133 deletions(-) delete mode 100644 web/pages/account.tsx diff --git a/firestore.rules b/firestore.rules index 4645343d..28ff4485 100644 --- a/firestore.rules +++ b/firestore.rules @@ -21,16 +21,15 @@ service cloud.firestore { allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']); - allow update: if resource.data.id == request.auth.uid - && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['referredByUserId']) - // only one referral allowed per user - && !("referredByUserId" in resource.data) - // user can't refer themselves - && (resource.data.id != request.resource.data.referredByUserId) - // user can't refer someone who referred them quid pro quo - && get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId != resource.data.id; - + allow update: if resource.data.id == request.auth.uid + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['referredByUserId']) + // only one referral allowed per user + && !("referredByUserId" in resource.data) + // user can't refer themselves + && !(resource.data.id == request.resource.data.referredByUserId); + // quid pro quos enabled (only once though so nbd) - bc I can't make this work: + // && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id); } match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { diff --git a/web/components/filter-select-users.tsx b/web/components/filter-select-users.tsx index 93badf20..8d2dbbae 100644 --- a/web/components/filter-select-users.tsx +++ b/web/components/filter-select-users.tsx @@ -1,4 +1,4 @@ -import { UserIcon } from '@heroicons/react/outline' +import { UserIcon, XIcon } from '@heroicons/react/outline' import { useUsers } from 'web/hooks/use-users' import { User } from 'common/user' import { Fragment, useMemo, useState } from 'react' @@ -6,13 +6,24 @@ import clsx from 'clsx' import { Menu, Transition } from '@headlessui/react' import { Avatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' +import { UserLink } from 'web/components/user-page' export function FilterSelectUsers(props: { setSelectedUsers: (users: User[]) => void selectedUsers: User[] ignoreUserIds: string[] + showSelectedUsersTitle?: boolean + selectedUsersClassName?: string + maxUsers?: number }) { - const { ignoreUserIds, selectedUsers, setSelectedUsers } = props + const { + ignoreUserIds, + selectedUsers, + setSelectedUsers, + showSelectedUsersTitle, + selectedUsersClassName, + maxUsers, + } = props const users = useUsers() const [query, setQuery] = useState('') const [filteredUsers, setFilteredUsers] = useState<User[]>([]) @@ -29,89 +40,118 @@ export function FilterSelectUsers(props: { }) ) }, [beginQuerying, users, selectedUsers, ignoreUserIds, query]) - + const shouldShow = maxUsers ? selectedUsers.length < maxUsers : true return ( <div> - <div className="relative mt-1 rounded-md"> - <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 - 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 " - placeholder="Austin Chen" - /> - </div> - <Menu - as="div" - className={clsx( - 'relative inline-block w-full overflow-y-scroll text-right', - beginQuerying && 'h-36' - )} - > - {({}) => ( - <Transition - show={beginQuerying} - as={Fragment} - enter="transition ease-out duration-100" - enterFrom="transform opacity-0 scale-95" - enterTo="transform opacity-100 scale-100" - leave="transition ease-in duration-75" - leaveFrom="transform opacity-100 scale-100" - leaveTo="transform opacity-0 scale-95" + {shouldShow && ( + <> + <div className="relative mt-1 rounded-md"> + <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 + 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 " + placeholder="Austin Chen" + /> + </div> + <Menu + as="div" + className={clsx( + 'relative inline-block w-full overflow-y-scroll text-right', + beginQuerying && 'h-36' + )} > - <Menu.Items - static={true} - className="absolute right-0 mt-2 w-full origin-top-right cursor-pointer divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" - > - <div className="py-1"> - {filteredUsers.map((user: User) => ( - <Menu.Item key={user.id}> - {({ active }) => ( - <span - className={clsx( - active - ? 'bg-gray-100 text-gray-900' - : 'text-gray-700', - 'group flex items-center px-4 py-2 text-sm' + {({}) => ( + <Transition + show={beginQuerying} + as={Fragment} + enter="transition ease-out duration-100" + enterFrom="transform opacity-0 scale-95" + enterTo="transform opacity-100 scale-100" + leave="transition ease-in duration-75" + leaveFrom="transform opacity-100 scale-100" + leaveTo="transform opacity-0 scale-95" + > + <Menu.Items + static={true} + className="absolute right-0 mt-2 w-full origin-top-right cursor-pointer divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" + > + <div className="py-1"> + {filteredUsers.map((user: User) => ( + <Menu.Item key={user.id}> + {({ active }) => ( + <span + className={clsx( + active + ? 'bg-gray-100 text-gray-900' + : 'text-gray-700', + 'group flex items-center px-4 py-2 text-sm' + )} + onClick={() => { + setQuery('') + setSelectedUsers([...selectedUsers, user]) + }} + > + <Avatar + username={user.username} + avatarUrl={user.avatarUrl} + size={'xs'} + className={'mr-2'} + /> + {user.name} + </span> )} - onClick={() => { - setQuery('') - setSelectedUsers([...selectedUsers, user]) - }} - > - <Avatar - username={user.username} - avatarUrl={user.avatarUrl} - size={'xs'} - className={'mr-2'} - /> - {user.name} - </span> - )} - </Menu.Item> - ))} - </div> - </Menu.Items> - </Transition> - )} - </Menu> + </Menu.Item> + ))} + </div> + </Menu.Items> + </Transition> + )} + </Menu> + </> + )} {selectedUsers.length > 0 && ( <> - <div className={'mb-2'}>Added members:</div> - <Row className="mt-0 grid grid-cols-6 gap-2"> + <div className={'mb-2'}> + {showSelectedUsersTitle && 'Added members:'} + </div> + <Row + className={clsx( + 'mt-0 grid grid-cols-6 gap-2', + selectedUsersClassName + )} + > {selectedUsers.map((user: User) => ( - <div key={user.id} className="col-span-2 flex items-center"> - <Avatar - username={user.username} - avatarUrl={user.avatarUrl} - size={'sm'} + <div + key={user.id} + className="col-span-2 flex flex-row items-center justify-between" + > + <Row className={'items-center'}> + <Avatar + username={user.username} + avatarUrl={user.avatarUrl} + size={'sm'} + /> + <UserLink + username={user.username} + className="ml-2" + name={user.name} + /> + </Row> + <XIcon + onClick={() => + setSelectedUsers([ + ...selectedUsers.filter((u) => u.id != user.id), + ]) + } + className=" h-5 w-5 cursor-pointer text-gray-400" + aria-hidden="true" /> - <span className="ml-2">{user.name}</span> </div> ))} </Row> diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx index c23958fc..bb9e53cb 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -10,9 +10,11 @@ import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' import { UserLink } from 'web/components/user-page' import { useReferrals } from 'web/hooks/use-referrals' +import { FilterSelectUsers } from 'web/components/filter-select-users' +import { getUser, updateUser } from 'web/lib/firebase/users' -export function ReferralsButton(props: { user: User }) { - const { user } = props +export function ReferralsButton(props: { user: User; currentUser?: User }) { + const { user, currentUser } = props const [isOpen, setIsOpen] = useState(false) const referralIds = useReferrals(user.id) @@ -28,6 +30,7 @@ export function ReferralsButton(props: { user: User }) { referralIds={referralIds ?? []} isOpen={isOpen} setIsOpen={setIsOpen} + currentUser={currentUser} /> </> ) @@ -38,8 +41,26 @@ function ReferralsDialog(props: { referralIds: string[] isOpen: boolean setIsOpen: (isOpen: boolean) => void + currentUser?: User }) { - const { user, referralIds, isOpen, setIsOpen } = props + const { user, referralIds, isOpen, setIsOpen, currentUser } = props + const [referredBy, setReferredBy] = useState<User[]>([]) + const [isSubmitting, setIsSubmitting] = useState(false) + const [errorText, setErrorText] = useState('') + + const [referredByUser, setReferredByUser] = useState<User | null>() + useEffect(() => { + if ( + isOpen && + !referredByUser && + currentUser?.referredByUserId && + currentUser.id === user.id + ) { + getUser(currentUser.referredByUserId).then((user) => { + setReferredByUser(user) + }) + } + }, [currentUser, isOpen, referredByUser, user.id]) useEffect(() => { prefetchUsers(referralIds) @@ -56,6 +77,75 @@ function ReferralsDialog(props: { title: 'Referrals', content: <ReferralsList userIds={referralIds} />, }, + { + title: 'Referred by', + content: ( + <> + {user.id === currentUser?.id && !referredByUser ? ( + <> + <FilterSelectUsers + setSelectedUsers={setReferredBy} + selectedUsers={referredBy} + ignoreUserIds={[currentUser.id]} + showSelectedUsersTitle={false} + selectedUsersClassName={'grid-cols-2 '} + maxUsers={1} + /> + <Row className={'mt-0 justify-end'}> + <button + className={ + referredBy.length === 0 + ? 'hidden' + : 'btn btn-primary btn-md my-2 w-24 normal-case' + } + disabled={referredBy.length === 0 || isSubmitting} + onClick={() => { + setIsSubmitting(true) + updateUser(currentUser.id, { + referredByUserId: referredBy[0].id, + }) + .then(async () => { + setErrorText('') + setIsSubmitting(false) + setReferredBy([]) + setIsOpen(false) + }) + .catch((error) => { + setIsSubmitting(false) + setErrorText(error.message) + }) + }} + > + Save + </button> + </Row> + <span className={'text-warning'}> + {referredBy.length > 0 && + 'Careful: you can only set who referred you once!'} + </span> + <span className={'text-error'}>{errorText}</span> + </> + ) : ( + <div className="justify-center text-gray-700"> + {referredByUser ? ( + <Row className={'items-center gap-2 p-2'}> + <Avatar + username={referredByUser.username} + avatarUrl={referredByUser.avatarUrl} + /> + <UserLink + username={referredByUser.username} + name={referredByUser.name} + /> + </Row> + ) : ( + <span className={'text-gray-500'}>No one...</span> + )} + </div> + )} + </> + ), + }, ]} /> </Col> diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index d72a2a16..0a1366c4 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -202,7 +202,7 @@ export function UserPage(props: { <Row className="gap-4"> <FollowingButton user={user} /> <FollowersButton user={user} /> - <ReferralsButton user={user} /> + <ReferralsButton user={user} currentUser={currentUser} /> <GroupsButton user={user} /> </Row> diff --git a/web/pages/account.tsx b/web/pages/account.tsx deleted file mode 100644 index 59d938c3..00000000 --- a/web/pages/account.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { Page } from 'web/components/page' -import { UserPage } from 'web/components/user-page' -import { useUser } from 'web/hooks/use-user' -import { firebaseLogin } from 'web/lib/firebase/users' - -function SignInCard() { - return ( - <div className="card glass sm:card-side text-neutral-content mx-4 my-12 max-w-sm bg-green-600 shadow-xl transition-all hover:bg-green-600 hover:shadow-xl sm:mx-auto"> - <div className="p-4"> - <img - src="/logo-bg-white.png" - className="h-20 w-20 rounded-lg shadow-lg" - /> - </div> - <div className="card-body max-w-md"> - <h2 className="card-title font-major-mono">Welcome!</h2> - <p>Sign in to get started</p> - <div className="card-actions"> - <button - className="btn glass rounded-full hover:bg-green-500" - onClick={firebaseLogin} - > - Sign in with Google - </button> - </div> - </div> - </div> - ) -} - -export default function Account() { - const user = useUser() - return user ? ( - <UserPage user={user} currentUser={user} /> - ) : ( - <Page> - <SignInCard /> - </Page> - ) -} diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index 60966756..eed68e1a 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -46,7 +46,7 @@ export default function ClaimPage() { if (result.data.status == 'error') { throw new Error(result.data.message) } - router.push('/account?claimed-mana=yes') + user && router.push(`/${user.username}?claimed-mana=yes`) } catch (e) { console.log(e) const message = From 22f917e250cdb02de9846e19bbbe022449aa7a82 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 4 Jul 2022 08:32:51 -0600 Subject: [PATCH 079/220] Avatar sizes to 24, size 20 is broken --- web/components/user-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 0a1366c4..07f722d7 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -159,7 +159,7 @@ export function UserPage(props: { <Avatar username={user.username} avatarUrl={user.avatarUrl} - size={20} + size={24} className="bg-white ring-4 ring-white" /> </div> From 790fdad1e3efa07bc214dab7d8fcedd4727e7088 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 4 Jul 2022 09:18:01 -0600 Subject: [PATCH 080/220] Display refered by publicly --- web/components/referrals-button.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx index bb9e53cb..74fc113d 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -50,17 +50,12 @@ function ReferralsDialog(props: { const [referredByUser, setReferredByUser] = useState<User | null>() useEffect(() => { - if ( - isOpen && - !referredByUser && - currentUser?.referredByUserId && - currentUser.id === user.id - ) { - getUser(currentUser.referredByUserId).then((user) => { + if (isOpen && !referredByUser && user?.referredByUserId) { + getUser(user.referredByUserId).then((user) => { setReferredByUser(user) }) } - }, [currentUser, isOpen, referredByUser, user.id]) + }, [isOpen, referredByUser, user.referredByUserId]) useEffect(() => { prefetchUsers(referralIds) From af2b148b3415e863e1bcc4fc7a60a0e6be95b7a4 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Mon, 4 Jul 2022 13:25:44 -0700 Subject: [PATCH 081/220] show names on admin user table --- web/pages/admin.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/pages/admin.tsx b/web/pages/admin.tsx index db24996d..e709e875 100644 --- a/web/pages/admin.tsx +++ b/web/pages/admin.tsx @@ -62,13 +62,19 @@ function UsersTable() { class="hover:underline hover:decoration-indigo-400 hover:decoration-2" href="/${cell}">@${cell}</a>`), }, + { + id: 'name', + name: 'Name', + formatter: (cell) => + html(`<span class="whitespace-nowrap">${cell}</span>`), + }, { id: 'email', name: 'Email', }, { id: 'createdTime', - name: 'Created Time', + name: 'Created', formatter: (cell) => html( `<span class="whitespace-nowrap">${dayjs(cell as number).format( From c39e3aedfa024416d2135cc9915bd5bfee956fbf Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 4 Jul 2022 16:04:05 -0700 Subject: [PATCH 082/220] Also send .env file when deploy functions --- functions/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/package.json b/functions/package.json index ee7bc92d..93bea621 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,7 @@ "firestore": "dev-mantic-markets.appspot.com" }, "scripts": { - "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist", + "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist", "compile": "tsc -b", "watch": "tsc -w", "shell": "yarn build && firebase functions:shell", From 53b4a2894453a8e5a411e3bc29d4fefea03e14ee Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 4 Jul 2022 16:21:59 -0700 Subject: [PATCH 083/220] Check in .env to git --- functions/.env | 3 +++ functions/.gitignore | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 functions/.env diff --git a/functions/.env b/functions/.env new file mode 100644 index 00000000..0c4303df --- /dev/null +++ b/functions/.env @@ -0,0 +1,3 @@ +# This sets which EnvConfig is deployed to Firebase Cloud Functions + +NEXT_PUBLIC_FIREBASE_ENV=PROD diff --git a/functions/.gitignore b/functions/.gitignore index 2aeae30c..58f30dcb 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -1,5 +1,4 @@ # Secrets -.env* .runtimeconfig.json # GCP deployment artifact From b26648c1cec22af62b43a319e8f1fabb2cb1fc12 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 5 Jul 2022 11:29:26 -0600 Subject: [PATCH 084/220] Daily trading bonuses (#618) * first commit, WIP * Give trading bonuses & paginate notifications * Move read & update into transaction * Move request bonus logic to notifs icon --- common/notification.ts | 2 + common/numeric-constants.ts | 1 + common/txn.ts | 11 +- common/user.ts | 1 + functions/src/create-notification.ts | 15 + functions/src/get-daily-bonuses.ts | 139 ++++++++ functions/src/index.ts | 1 + web/components/nav/sidebar.tsx | 1 - web/components/notifications-icon.tsx | 20 +- web/hooks/use-notifications.ts | 39 ++- web/lib/firebase/api-call.ts | 4 + web/pages/notifications.tsx | 448 ++++++++++++++++++-------- 12 files changed, 525 insertions(+), 157 deletions(-) create mode 100644 functions/src/get-daily-bonuses.ts diff --git a/common/notification.ts b/common/notification.ts index 64a00a36..e90624a4 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -34,6 +34,7 @@ export type notification_source_types = | 'admin_message' | 'group' | 'user' + | 'bonus' export type notification_source_update_types = | 'created' @@ -56,3 +57,4 @@ export type notification_reason_types = | 'added_you_to_group' | 'you_referred_user' | 'user_joined_to_bet_on_your_market' + | 'unique_bettors_on_your_contract' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index ef364b74..46885668 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -3,3 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005 export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_TEXT_COLOR = 'text-blue-500' +export const UNIQUE_BETTOR_BONUS_AMOUNT = 5 diff --git a/common/txn.ts b/common/txn.ts index 0e772e0d..53b08501 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -1,6 +1,6 @@ // A txn (pronounced "texan") respresents a payment between two ids on Manifold // Shortened from "transaction" to distinguish from Firebase transactions (and save chars) -type AnyTxnType = Donation | Tip | Manalink | Referral +type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn<T extends AnyTxnType = AnyTxnType> = { @@ -16,7 +16,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' // | 'BET' + category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' + // Any extra data data?: { [key: string]: any } @@ -52,6 +53,12 @@ type Referral = { category: 'REFERRAL' } +type Bonus = { + fromType: 'BANK' + toType: 'USER' + category: 'UNIQUE_BETTOR_BONUS' +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink diff --git a/common/user.ts b/common/user.ts index d5dd0373..477139fd 100644 --- a/common/user.ts +++ b/common/user.ts @@ -57,6 +57,7 @@ export type PrivateUser = { initialIpAddress?: string apiKey?: string notificationPreferences?: notification_subscribe_types + lastTimeCheckedBonuses?: number } export type notification_subscribe_types = 'all' | 'less' | 'none' diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index a32ed3bc..b63958f0 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -267,6 +267,15 @@ export const createNotification = async ( } } + const notifyContractCreatorOfUniqueBettorsBonus = async ( + userToReasonTexts: user_to_reason_texts, + userId: string + ) => { + userToReasonTexts[userId] = { + reason: 'unique_bettors_on_your_contract', + } + } + const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -309,6 +318,12 @@ export const createNotification = async ( }) } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { await notifyContractCreator(userToReasonTexts, sourceContract) + } else if (sourceType === 'bonus' && sourceUpdateType === 'created') { + // Note: the daily bonus won't have a contract attached to it + await notifyContractCreatorOfUniqueBettorsBonus( + userToReasonTexts, + sourceContract.creatorId + ) } return userToReasonTexts } diff --git a/functions/src/get-daily-bonuses.ts b/functions/src/get-daily-bonuses.ts new file mode 100644 index 00000000..c5c1a1b3 --- /dev/null +++ b/functions/src/get-daily-bonuses.ts @@ -0,0 +1,139 @@ +import { APIError, newEndpoint } from './api' +import { log } from './utils' +import * as admin from 'firebase-admin' +import { PrivateUser } from '../../common/lib/user' +import { uniq } from 'lodash' +import { Bet } from '../../common/lib/bet' +const firestore = admin.firestore() +import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' +import { runTxn, TxnData } from './transact' +import { createNotification } from './create-notification' +import { User } from '../../common/lib/user' +import { Contract } from '../../common/lib/contract' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants' + +const BONUS_START_DATE = new Date('2022-07-01T00:00:00.000Z').getTime() +const QUERY_LIMIT_SECONDS = 60 + +export const getdailybonuses = newEndpoint({}, async (req, auth) => { + const { user, lastTimeCheckedBonuses } = await firestore.runTransaction( + async (trans) => { + const userSnap = await trans.get( + firestore.doc(`private-users/${auth.uid}`) + ) + if (!userSnap.exists) throw new APIError(400, 'User not found.') + const user = userSnap.data() as PrivateUser + const lastTimeCheckedBonuses = user.lastTimeCheckedBonuses ?? 0 + if (Date.now() - lastTimeCheckedBonuses < QUERY_LIMIT_SECONDS * 1000) + throw new APIError( + 400, + `Limited to one query per user per ${QUERY_LIMIT_SECONDS} seconds.` + ) + await trans.update(userSnap.ref, { + lastTimeCheckedBonuses: Date.now(), + }) + return { + user, + lastTimeCheckedBonuses, + } + } + ) + // TODO: switch to prod id + // const fromUserId = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // dev manifold account + const fromUserId = HOUSE_LIQUIDITY_PROVIDER_ID // prod manifold account + const fromSnap = await firestore.doc(`users/${fromUserId}`).get() + if (!fromSnap.exists) throw new APIError(400, 'From user not found.') + const fromUser = fromSnap.data() as User + // Get all users contracts made since implementation time + const userContractsSnap = await firestore + .collection(`contracts`) + .where('creatorId', '==', user.id) + .where('createdTime', '>=', BONUS_START_DATE) + .get() + const userContracts = userContractsSnap.docs.map( + (doc) => doc.data() as Contract + ) + const nullReturn = { status: 'no bets', txn: null } + for (const contract of userContracts) { + const result = await firestore.runTransaction(async (trans) => { + const contractId = contract.id + // Get all bets made on user's contracts + const bets = ( + await firestore + .collection(`contracts/${contractId}/bets`) + .where('userId', '!=', user.id) + .get() + ).docs.map((bet) => bet.ref) + if (bets.length === 0) { + return nullReturn + } + const contractBetsSnap = await trans.getAll(...bets) + const contractBets = contractBetsSnap.map((doc) => doc.data() as Bet) + + const uniqueBettorIdsBeforeLastResetTime = uniq( + contractBets + .filter((bet) => bet.createdTime < lastTimeCheckedBonuses) + .map((bet) => bet.userId) + ) + + // Filter users for ONLY those that have made bets since the last daily bonus received time + const uniqueBettorIdsWithBetsAfterLastResetTime = uniq( + contractBets + .filter((bet) => bet.createdTime > lastTimeCheckedBonuses) + .map((bet) => bet.userId) + ) + + // Filter for users only present in the above list + const newUniqueBettorIds = + uniqueBettorIdsWithBetsAfterLastResetTime.filter( + (userId) => !uniqueBettorIdsBeforeLastResetTime.includes(userId) + ) + newUniqueBettorIds.length > 0 && + log( + `Got ${newUniqueBettorIds.length} new unique bettors since last bonus` + ) + if (newUniqueBettorIds.length === 0) { + return nullReturn + } + // Create combined txn for all unique bettors + const bonusTxnDetails = { + contractId: contractId, + uniqueBettors: newUniqueBettorIds.length, + } + const bonusTxn: TxnData = { + fromId: fromUser.id, + fromType: 'BANK', + toId: user.id, + toType: 'USER', + amount: UNIQUE_BETTOR_BONUS_AMOUNT * newUniqueBettorIds.length, + token: 'M$', + category: 'UNIQUE_BETTOR_BONUS', + description: JSON.stringify(bonusTxnDetails), + } + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + result.status != nullReturn.status && + log(`No bonus for user: ${user.id} - reason:`, result.status) + } else { + log(`Bonus txn for user: ${user.id} completed:`, result.txn?.id) + await createNotification( + result.txn.id, + 'bonus', + 'created', + fromUser, + result.txn.id, + result.txn.amount + '', + contract, + undefined, + // No need to set the user id, we'll use the contract creator id + undefined, + contract.slug, + contract.question + ) + } + } + + return { userId: user.id, message: 'success' } +}) diff --git a/functions/src/index.ts b/functions/src/index.ts index b643ff5e..e4a30761 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -38,3 +38,4 @@ export * from './create-contract' export * from './withdraw-liquidity' export * from './create-group' export * from './resolve-market' +export * from './get-daily-bonuses' diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 5ce9e239..ba46bd80 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -182,7 +182,6 @@ export default function Sidebar(props: { className?: string }) { const { className } = props const router = useRouter() const currentPage = router.pathname - const user = useUser() const navigationOptions = !user ? signedOutNavigation : getNavigation() const mobileNavigationOptions = !user diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index e2618870..ac4d772f 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -2,17 +2,29 @@ import { BellIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { Row } from 'web/components/layout/row' import { useEffect, useState } from 'react' -import { useUser } from 'web/hooks/use-user' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import { useRouter } from 'next/router' import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications' +import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' +import { requestBonuses } from 'web/lib/firebase/api-call' export default function NotificationsIcon(props: { className?: string }) { const user = useUser() - const notifications = usePreferredGroupedNotifications(user?.id, { + const privateUser = usePrivateUser(user?.id) + const notifications = usePreferredGroupedNotifications(privateUser?.id, { unseenOnly: true, }) const [seen, setSeen] = useState(false) + useEffect(() => { + if (!privateUser) return + + if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 60 * 1000) + requestBonuses({}).catch((error) => { + console.log("couldn't get bonuses:", error.message) + }) + }, [privateUser]) + const router = useRouter() useEffect(() => { if (router.pathname.endsWith('notifications')) return setSeen(true) @@ -24,7 +36,9 @@ export default function NotificationsIcon(props: { className?: string }) { <div className={'relative'}> {!seen && notifications && notifications.length > 0 && ( <div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2"> - {notifications.length} + {notifications.length > NOTIFICATIONS_PER_PAGE + ? `${NOTIFICATIONS_PER_PAGE}+` + : notifications.length} </div> )} <BellIcon className={clsx(props.className)} /> diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index c947e8d0..0a15754d 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -7,9 +7,10 @@ import { groupBy, map } from 'lodash' export type NotificationGroup = { notifications: Notification[] - sourceContractId: string + groupedById: string isSeen: boolean timePeriod: string + type: 'income' | 'normal' } export function usePreferredGroupedNotifications( @@ -37,25 +38,43 @@ export function groupNotifications(notifications: Notification[]) { new Date(notification.createdTime).toDateString() ) Object.keys(notificationGroupsByDay).forEach((day) => { - // Group notifications by contract: + const notificationsGroupedByDay = notificationGroupsByDay[day] + const bonusNotifications = notificationsGroupedByDay.filter( + (notification) => notification.sourceType === 'bonus' + ) + const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter( + (notification) => notification.sourceType !== 'bonus' + ) + if (bonusNotifications.length > 0) { + notificationGroups = notificationGroups.concat({ + notifications: bonusNotifications, + groupedById: 'income' + day, + isSeen: bonusNotifications[0].isSeen, + timePeriod: day, + type: 'income', + }) + } + // Group notifications by contract, filtering out bonuses: const groupedNotificationsByContractId = groupBy( - notificationGroupsByDay[day], + normalNotificationsGroupedByDay, (notification) => { return notification.sourceContractId } ) notificationGroups = notificationGroups.concat( map(groupedNotificationsByContractId, (notifications, contractId) => { + const notificationsForContractId = groupedNotificationsByContractId[ + contractId + ].sort((a, b) => { + return b.createdTime - a.createdTime + }) // Create a notification group for each contract within each day const notificationGroup: NotificationGroup = { - notifications: groupedNotificationsByContractId[contractId].sort( - (a, b) => { - return b.createdTime - a.createdTime - } - ), - sourceContractId: contractId, - isSeen: groupedNotificationsByContractId[contractId][0].isSeen, + notifications: notificationsForContractId, + groupedById: contractId, + isSeen: notificationsForContractId[0].isSeen, timePeriod: day, + type: 'normal', } return notificationGroup }) diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index e02872ae..db41e592 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -73,3 +73,7 @@ export function sellBet(params: any) { export function createGroup(params: any) { return call(getFunctionUrl('creategroup'), 'POST', params) } + +export function requestBonuses(params: any) { + return call(getFunctionUrl('getdailybonuses'), 'POST', params) +} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index f3512c56..229e8c8d 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,12 +1,7 @@ import { Tabs } from 'web/components/layout/tabs' import { useUser } from 'web/hooks/use-user' import React, { useEffect, useState } from 'react' -import { - Notification, - notification_reason_types, - notification_source_types, - notification_source_update_types, -} from 'common/notification' +import { Notification } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' @@ -31,47 +26,40 @@ import { ProbPercentLabel, } from 'web/components/outcome-label' import { - groupNotifications, NotificationGroup, usePreferredGroupedNotifications, } from 'web/hooks/use-notifications' -import { CheckIcon, XIcon } from '@heroicons/react/outline' +import { CheckIcon, TrendingUpIcon, XIcon } from '@heroicons/react/outline' import toast from 'react-hot-toast' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' +import { groupBy } from 'lodash' + +export const NOTIFICATIONS_PER_PAGE = 30 +export const HIGHLIGHT_DURATION = 30 * 1000 export default function Notifications() { const user = useUser() - const [unseenNotificationGroups, setUnseenNotificationGroups] = useState< - NotificationGroup[] | undefined - >(undefined) - const allNotificationGroups = usePreferredGroupedNotifications(user?.id, { + const [page, setPage] = useState(1) + + const groupedNotifications = usePreferredGroupedNotifications(user?.id, { unseenOnly: false, }) - + const [paginatedNotificationGroups, setPaginatedNotificationGroups] = + useState<NotificationGroup[]>([]) useEffect(() => { - if (!allNotificationGroups) return - // Don't re-add notifications that are visible right now or have been seen already. - const currentlyVisibleUnseenNotificationIds = Object.values( - unseenNotificationGroups ?? [] - ) - .map((n) => n.notifications.map((n) => n.id)) - .flat() - const unseenGroupedNotifications = groupNotifications( - allNotificationGroups - .map((notification: NotificationGroup) => notification.notifications) - .flat() - .filter( - (notification: Notification) => - !notification.isSeen || - currentlyVisibleUnseenNotificationIds.includes(notification.id) - ) - ) - setUnseenNotificationGroups(unseenGroupedNotifications) - - // We don't want unseenNotificationsGroup to be in the dependencies as we update it here. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allNotificationGroups]) + if (!groupedNotifications) return + const start = (page - 1) * NOTIFICATIONS_PER_PAGE + const end = start + NOTIFICATIONS_PER_PAGE + const maxNotificationsToShow = groupedNotifications.slice(start, end) + const remainingNotification = groupedNotifications.slice(end) + for (const notification of remainingNotification) { + if (notification.isSeen) break + else setNotificationsAsSeen(notification.notifications) + } + setPaginatedNotificationGroups(maxNotificationsToShow) + }, [groupedNotifications, page]) if (user === undefined) { return <LoadingIndicator /> @@ -80,7 +68,6 @@ export default function Notifications() { return <Custom404 /> } - // TODO: use infinite scroll return ( <Page> <div className={'p-2 sm:p-4'}> @@ -90,53 +77,74 @@ export default function Notifications() { defaultIndex={0} tabs={[ { - title: 'New Notifications', - content: unseenNotificationGroups ? ( + title: 'Notifications', + content: groupedNotifications ? ( <div className={''}> - {unseenNotificationGroups.length === 0 && - "You don't have any new notifications."} - {unseenNotificationGroups.map((notification) => + {paginatedNotificationGroups.length === 0 && + "You don't have any notifications. Try changing your settings to see more."} + {paginatedNotificationGroups.map((notification) => notification.notifications.length === 1 ? ( <NotificationItem notification={notification.notifications[0]} key={notification.notifications[0].id} /> + ) : notification.type === 'income' ? ( + <IncomeNotificationGroupItem + notificationGroup={notification} + key={notification.groupedById + notification.timePeriod} + /> ) : ( <NotificationGroupItem notificationGroup={notification} - key={ - notification.sourceContractId + - notification.timePeriod - } + key={notification.groupedById + notification.timePeriod} /> ) )} - </div> - ) : ( - <LoadingIndicator /> - ), - }, - { - title: 'All Notifications', - content: allNotificationGroups ? ( - <div className={''}> - {allNotificationGroups.length === 0 && - "You don't have any notifications. Try changing your settings to see more."} - {allNotificationGroups.map((notification) => - notification.notifications.length === 1 ? ( - <NotificationItem - notification={notification.notifications[0]} - key={notification.notifications[0].id} - /> - ) : ( - <NotificationGroupItem - notificationGroup={notification} - key={ - notification.sourceContractId + - notification.timePeriod - } - /> - ) + {groupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( + <nav + className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" + aria-label="Pagination" + > + <div className="hidden sm:block"> + <p className="text-sm text-gray-700"> + Showing{' '} + <span className="font-medium"> + {page === 1 + ? page + : (page - 1) * NOTIFICATIONS_PER_PAGE} + </span>{' '} + to{' '} + <span className="font-medium"> + {page * NOTIFICATIONS_PER_PAGE} + </span>{' '} + of{' '} + <span className="font-medium"> + {groupedNotifications.length} + </span>{' '} + results + </p> + </div> + <div className="flex flex-1 justify-between sm:justify-end"> + <a + href="#" + className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => page > 1 && setPage(page - 1)} + > + Previous + </a> + <a + href="#" + className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => + page < + groupedNotifications?.length / + NOTIFICATIONS_PER_PAGE && setPage(page + 1) + } + > + Next + </a> + </div> + </nav> )} </div> ) : ( @@ -164,7 +172,6 @@ const setNotificationsAsSeen = (notifications: Notification[]) => { updateDoc( doc(db, `users/${notification.userId}/notifications/`, notification.id), { - ...notification, isSeen: true, viewTime: new Date(), } @@ -173,6 +180,152 @@ const setNotificationsAsSeen = (notifications: Notification[]) => { return notifications } +function IncomeNotificationGroupItem(props: { + notificationGroup: NotificationGroup + className?: string +}) { + const { notificationGroup, className } = props + const { notifications } = notificationGroup + const numSummaryLines = 3 + + const [expanded, setExpanded] = useState(false) + const [highlighted, setHighlighted] = useState(false) + useEffect(() => { + if (notifications.some((n) => !n.isSeen)) { + setHighlighted(true) + setTimeout(() => { + setHighlighted(false) + }, HIGHLIGHT_DURATION) + } + setNotificationsAsSeen(notifications) + }, [notifications]) + + useEffect(() => { + if (expanded) setHighlighted(false) + }, [expanded]) + + const totalIncome = notifications.reduce( + (acc, notification) => + acc + + (notification.sourceType && + notification.sourceText && + notification.sourceType === 'bonus' + ? parseInt(notification.sourceText) + : 0), + 0 + ) + // loop through the contracts and combine the notification items into one + function combineNotificationsByAddingSourceTextsAndReturningTheRest( + notifications: Notification[] + ) { + const newNotifications = [] + const groupedNotificationsByContractId = groupBy( + notifications, + (notification) => { + return notification.sourceContractId + } + ) + for (const contractId in groupedNotificationsByContractId) { + const notificationsForContractId = + groupedNotificationsByContractId[contractId] + let sum = 0 + notificationsForContractId.forEach( + (notification) => + notification.sourceText && + (sum = parseInt(notification.sourceText) + sum) + ) + + const newNotification = + notificationsForContractId.length === 1 + ? notificationsForContractId[0] + : { + ...notificationsForContractId[0], + sourceText: sum.toString(), + } + newNotifications.push(newNotification) + } + return newNotifications + } + + const combinedNotifs = + combineNotificationsByAddingSourceTextsAndReturningTheRest(notifications) + + return ( + <div + className={clsx( + 'relative cursor-pointer bg-white px-2 pt-6 text-sm', + className, + !expanded ? 'hover:bg-gray-100' : '', + highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : '' + )} + onClick={() => setExpanded(!expanded)} + > + {expanded && ( + <span + className="absolute top-14 left-6 -ml-px h-[calc(100%-5rem)] w-0.5 bg-gray-200" + aria-hidden="true" + /> + )} + <Row className={'items-center text-gray-500 sm:justify-start'}> + <TrendingUpIcon className={'text-primary h-7 w-7'} /> + <div className={'flex-1 overflow-hidden pl-2 sm:flex'}> + <div + onClick={() => setExpanded(!expanded)} + className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'} + > + <span> + {'Daily Income Summary: '} + <span className={'text-primary'}>{formatMoney(totalIncome)}</span> + </span> + </div> + <RelativeTimestamp time={notifications[0].createdTime} /> + </div> + </Row> + <div> + <div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}> + {' '} + <div className={'line-clamp-4 mt-1 ml-1 gap-1 whitespace-pre-line'}> + {!expanded ? ( + <> + {combinedNotifs + .slice(0, numSummaryLines) + .map((notification) => { + return ( + <NotificationItem + notification={notification} + justSummary={true} + key={notification.id} + /> + ) + })} + <div className={'text-sm text-gray-500 hover:underline '}> + {combinedNotifs.length - numSummaryLines > 0 + ? 'And ' + + (combinedNotifs.length - numSummaryLines) + + ' more...' + : ''} + </div> + </> + ) : ( + <> + {combinedNotifs.map((notification) => ( + <NotificationItem + notification={notification} + key={notification.id} + justSummary={false} + /> + ))} + </> + )} + </div> + </div> + + <div className={'mt-6 border-b border-gray-300'} /> + </div> + </div> + ) +} + function NotificationGroupItem(props: { notificationGroup: NotificationGroup className?: string @@ -187,17 +340,28 @@ function NotificationGroupItem(props: { const numSummaryLines = 3 const [expanded, setExpanded] = useState(false) - + const [highlighted, setHighlighted] = useState(false) useEffect(() => { + if (notifications.some((n) => !n.isSeen)) { + setHighlighted(true) + setTimeout(() => { + setHighlighted(false) + }, HIGHLIGHT_DURATION) + } setNotificationsAsSeen(notifications) }, [notifications]) + useEffect(() => { + if (expanded) setHighlighted(false) + }, [expanded]) + return ( <div className={clsx( 'relative cursor-pointer bg-white px-2 pt-6 text-sm', className, - !expanded ? 'hover:bg-gray-100' : '' + !expanded ? 'hover:bg-gray-100' : '', + highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : '' )} onClick={() => setExpanded(!expanded)} > @@ -432,7 +596,7 @@ function NotificationSettings() { /> <NotificationSettingLine highlight={notificationSettings !== 'none'} - label={"Referral bonuses you've received"} + label={"Income & referral bonuses you've received"} /> <NotificationSettingLine label={"Activity on questions you've ever bet or commented on"} @@ -476,17 +640,6 @@ function NotificationSettings() { ) } -function isNotificationAboutContractResolution( - sourceType: notification_source_types | undefined, - sourceUpdateType: notification_source_update_types | undefined, - contract: Contract | null | undefined -) { - return ( - (sourceType === 'contract' && sourceUpdateType === 'resolved') || - (sourceType === 'contract' && !sourceUpdateType && contract?.resolution) - ) -} - function NotificationItem(props: { notification: Notification justSummary?: boolean @@ -522,6 +675,16 @@ function NotificationItem(props: { } }, [reasonText, sourceText]) + const [highlighted, setHighlighted] = useState(false) + useEffect(() => { + if (!notification.isSeen) { + setHighlighted(true) + setTimeout(() => { + setHighlighted(false) + }, HIGHLIGHT_DURATION) + } + }, [notification.isSeen]) + useEffect(() => { setNotificationsAsSeen([notification]) }, [notification]) @@ -559,22 +722,21 @@ function NotificationItem(props: { <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> <div className={'flex pl-1 sm:pl-0'}> - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - /> + {sourceType != 'bonus' && ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-0 flex-shrink-0'} + /> + )} <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> <span className={'flex-shrink-0'}> {sourceType && reason && - getReasonForShowingNotification( - sourceType, - reason, - sourceUpdateType, - undefined, - true - ).replace(' on', '')} + getReasonForShowingNotification(notification, true).replace( + ' on', + '' + )} </span> <div className={'ml-1 text-black'}> <NotificationTextLabel @@ -593,37 +755,41 @@ function NotificationItem(props: { } return ( - <div className={'bg-white px-2 pt-6 text-sm sm:px-4'}> + <div + className={clsx( + 'bg-white px-2 pt-6 text-sm sm:px-4', + highlighted && 'bg-indigo-200' + )} + > <a href={getSourceUrl()}> <Row className={'items-center text-gray-500 sm:justify-start'}> - <Avatar - avatarUrl={sourceUserAvatarUrl} - size={'sm'} - className={'mr-2'} - username={sourceUserName} - /> + {sourceType != 'bonus' ? ( + <Avatar + avatarUrl={sourceUserAvatarUrl} + size={'sm'} + className={'mr-2'} + username={sourceUserName} + /> + ) : ( + <TrendingUpIcon className={'text-primary h-7 w-7'} /> + )} <div className={'flex-1 overflow-hidden sm:flex'}> <div className={ 'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0' } > - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - /> + {sourceType != 'bonus' && sourceUpdateType != 'closed' && ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-0 flex-shrink-0'} + /> + )} <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> {sourceType && reason && ( <div className={'inline truncate'}> - {getReasonForShowingNotification( - sourceType, - reason, - sourceUpdateType, - undefined, - false, - sourceSlug - )} + {getReasonForShowingNotification(notification, false)} <a href={ sourceContractCreatorUsername @@ -684,13 +850,7 @@ function NotificationTextLabel(props: { return <span>{contract?.question || sourceContractTitle}</span> if (!sourceText) return <div /> // Resolved contracts - if ( - isNotificationAboutContractResolution( - sourceType, - sourceUpdateType, - contract - ) - ) { + if (sourceType === 'contract' && sourceUpdateType === 'resolved') { { if (sourceText === 'YES' || sourceText == 'NO') { return <BinaryOutcomeLabel outcome={sourceText as any} /> @@ -730,6 +890,12 @@ function NotificationTextLabel(props: { return ( <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> ) + } else if (sourceType === 'bonus' && sourceText) { + return ( + <span className="text-primary"> + {'+' + formatMoney(parseInt(sourceText))} + </span> + ) } // return default text return ( @@ -740,15 +906,13 @@ function NotificationTextLabel(props: { } function getReasonForShowingNotification( - source: notification_source_types, - reason: notification_reason_types, - sourceUpdateType: notification_source_update_types | undefined, - contract: Contract | undefined | null, - simple?: boolean, - sourceSlug?: string + notification: Notification, + simple?: boolean ) { + const { sourceType, sourceUpdateType, sourceText, reason, sourceSlug } = + notification let reasonText: string - switch (source) { + switch (sourceType) { case 'comment': if (reason === 'reply_to_users_answer') reasonText = !simple ? 'replied to your answer on' : 'replied' @@ -768,16 +932,9 @@ function getReasonForShowingNotification( break case 'contract': if (reason === 'you_follow_user') reasonText = 'created a new question' - else if ( - isNotificationAboutContractResolution( - source, - sourceUpdateType, - contract - ) - ) - reasonText = `resolved` + else if (sourceUpdateType === 'resolved') reasonText = `resolved` else if (sourceUpdateType === 'closed') - reasonText = `please resolve your question` + reasonText = `Please resolve your question` else reasonText = `updated` break case 'answer': @@ -805,6 +962,15 @@ function getReasonForShowingNotification( else if (sourceSlug) reasonText = 'joined because you shared' else reasonText = 'joined because of you' break + case 'bonus': + if (reason === 'unique_bettors_on_your_contract' && sourceText) + reasonText = !simple + ? `You had ${ + parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT + } unique bettors on` + : 'You earned Mana for unique bettors:' + else reasonText = 'You earned your daily manna' + break default: reasonText = '' } From 9bff858696a6a1502b8ff9bb3fec2868d3591524 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 5 Jul 2022 12:25:44 -0700 Subject: [PATCH 085/220] Fix up lint configuration, lint line endings (#615) * Make sure we ignore all built code in common and functions * Add lint for Unix line endings * Fix line endings in withdraw-liquidity.ts --- common/.eslintrc.js | 2 + functions/.eslintrc.js | 3 +- functions/src/withdraw-liquidity.ts | 276 ++++++++++++++-------------- web/.eslintrc.js | 1 + 4 files changed, 143 insertions(+), 139 deletions(-) diff --git a/common/.eslintrc.js b/common/.eslintrc.js index 3d6cfa82..c6f9703e 100644 --- a/common/.eslintrc.js +++ b/common/.eslintrc.js @@ -1,6 +1,7 @@ module.exports = { plugins: ['lodash'], extends: ['eslint:recommended'], + ignorePatterns: ['lib'], env: { browser: true, node: true, @@ -31,6 +32,7 @@ module.exports = { rules: { 'no-extra-semi': 'off', 'no-constant-condition': ['error', { checkLoops: false }], + 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], }, } diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index 7f571610..2c607231 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -1,7 +1,7 @@ module.exports = { plugins: ['lodash'], extends: ['eslint:recommended'], - ignorePatterns: ['lib'], + ignorePatterns: ['dist', 'lib'], env: { node: true, }, @@ -30,6 +30,7 @@ module.exports = { }, ], rules: { + 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], }, } diff --git a/functions/src/withdraw-liquidity.ts b/functions/src/withdraw-liquidity.ts index 4c48ce49..cc8c84cf 100644 --- a/functions/src/withdraw-liquidity.ts +++ b/functions/src/withdraw-liquidity.ts @@ -1,138 +1,138 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' - -import { CPMMContract } from '../../common/contract' -import { User } from '../../common/user' -import { subtractObjects } from '../../common/util/object' -import { LiquidityProvision } from '../../common/liquidity-provision' -import { getUserLiquidityShares } from '../../common/calculate-cpmm' -import { Bet } from '../../common/bet' -import { getProbability } from '../../common/calculate' -import { noFees } from '../../common/fees' - -import { APIError } from './api' -import { redeemShares } from './redeem-shares' - -export const withdrawLiquidity = functions - .runWith({ minInstances: 1 }) - .https.onCall( - async ( - data: { - contractId: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } - - const { contractId } = data - if (!contractId) - return { status: 'error', message: 'Missing contract id' } - - return await firestore - .runTransaction(async (trans) => { - const lpDoc = firestore.doc(`users/${userId}`) - const lpSnap = await trans.get(lpDoc) - if (!lpSnap.exists) throw new APIError(400, 'User not found.') - const lp = lpSnap.data() as User - - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await trans.get(contractDoc) - if (!contractSnap.exists) - throw new APIError(400, 'Contract not found.') - const contract = contractSnap.data() as CPMMContract - - const liquidityCollection = firestore.collection( - `contracts/${contractId}/liquidity` - ) - - const liquiditiesSnap = await trans.get(liquidityCollection) - - const liquidities = liquiditiesSnap.docs.map( - (doc) => doc.data() as LiquidityProvision - ) - - const userShares = getUserLiquidityShares( - userId, - contract, - liquidities - ) - - // zero all added amounts for now - // can add support for partial withdrawals in the future - liquiditiesSnap.docs - .filter( - (_, i) => - !liquidities[i].isAnte && liquidities[i].userId === userId - ) - .forEach((doc) => trans.update(doc.ref, { amount: 0 })) - - const payout = Math.min(...Object.values(userShares)) - if (payout <= 0) return {} - - const newBalance = lp.balance + payout - const newTotalDeposits = lp.totalDeposits + payout - trans.update(lpDoc, { - balance: newBalance, - totalDeposits: newTotalDeposits, - } as Partial<User>) - - const newPool = subtractObjects(contract.pool, userShares) - - const minPoolShares = Math.min(...Object.values(newPool)) - const adjustedTotal = contract.totalLiquidity - payout - - // total liquidity is a bogus number; use minPoolShares to prevent from going negative - const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) - - trans.update(contractDoc, { - pool: newPool, - totalLiquidity: newTotalLiquidity, - }) - - const prob = getProbability(contract) - - // surplus shares become user's bets - const bets = Object.entries(userShares) - .map(([outcome, shares]) => - shares - payout < 1 // don't create bet if less than 1 share - ? undefined - : ({ - userId: userId, - contractId: contract.id, - amount: - (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), - shares: shares - payout, - outcome, - probBefore: prob, - probAfter: prob, - createdTime: Date.now(), - isLiquidityProvision: true, - fees: noFees, - } as Omit<Bet, 'id'>) - ) - .filter((x) => x !== undefined) - - for (const bet of bets) { - const doc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() - trans.create(doc, { id: doc.id, ...bet }) - } - - return userShares - }) - .then(async (result) => { - // redeem surplus bet with pre-existing bets - await redeemShares(userId, contractId) - - console.log('userid', userId, 'withdraws', result) - return { status: 'success', userShares: result } - }) - .catch((e) => { - return { status: 'error', message: e.message } - }) - } - ) - -const firestore = admin.firestore() +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { CPMMContract } from '../../common/contract' +import { User } from '../../common/user' +import { subtractObjects } from '../../common/util/object' +import { LiquidityProvision } from '../../common/liquidity-provision' +import { getUserLiquidityShares } from '../../common/calculate-cpmm' +import { Bet } from '../../common/bet' +import { getProbability } from '../../common/calculate' +import { noFees } from '../../common/fees' + +import { APIError } from './api' +import { redeemShares } from './redeem-shares' + +export const withdrawLiquidity = functions + .runWith({ minInstances: 1 }) + .https.onCall( + async ( + data: { + contractId: string + }, + context + ) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const { contractId } = data + if (!contractId) + return { status: 'error', message: 'Missing contract id' } + + return await firestore + .runTransaction(async (trans) => { + const lpDoc = firestore.doc(`users/${userId}`) + const lpSnap = await trans.get(lpDoc) + if (!lpSnap.exists) throw new APIError(400, 'User not found.') + const lp = lpSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await trans.get(contractDoc) + if (!contractSnap.exists) + throw new APIError(400, 'Contract not found.') + const contract = contractSnap.data() as CPMMContract + + const liquidityCollection = firestore.collection( + `contracts/${contractId}/liquidity` + ) + + const liquiditiesSnap = await trans.get(liquidityCollection) + + const liquidities = liquiditiesSnap.docs.map( + (doc) => doc.data() as LiquidityProvision + ) + + const userShares = getUserLiquidityShares( + userId, + contract, + liquidities + ) + + // zero all added amounts for now + // can add support for partial withdrawals in the future + liquiditiesSnap.docs + .filter( + (_, i) => + !liquidities[i].isAnte && liquidities[i].userId === userId + ) + .forEach((doc) => trans.update(doc.ref, { amount: 0 })) + + const payout = Math.min(...Object.values(userShares)) + if (payout <= 0) return {} + + const newBalance = lp.balance + payout + const newTotalDeposits = lp.totalDeposits + payout + trans.update(lpDoc, { + balance: newBalance, + totalDeposits: newTotalDeposits, + } as Partial<User>) + + const newPool = subtractObjects(contract.pool, userShares) + + const minPoolShares = Math.min(...Object.values(newPool)) + const adjustedTotal = contract.totalLiquidity - payout + + // total liquidity is a bogus number; use minPoolShares to prevent from going negative + const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) + + trans.update(contractDoc, { + pool: newPool, + totalLiquidity: newTotalLiquidity, + }) + + const prob = getProbability(contract) + + // surplus shares become user's bets + const bets = Object.entries(userShares) + .map(([outcome, shares]) => + shares - payout < 1 // don't create bet if less than 1 share + ? undefined + : ({ + userId: userId, + contractId: contract.id, + amount: + (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), + shares: shares - payout, + outcome, + probBefore: prob, + probAfter: prob, + createdTime: Date.now(), + isLiquidityProvision: true, + fees: noFees, + } as Omit<Bet, 'id'>) + ) + .filter((x) => x !== undefined) + + for (const bet of bets) { + const doc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() + trans.create(doc, { id: doc.id, ...bet }) + } + + return userShares + }) + .then(async (result) => { + // redeem surplus bet with pre-existing bets + await redeemShares(userId, contractId) + + console.log('userid', userId, 'withdraws', result) + return { status: 'success', userShares: result } + }) + .catch((e) => { + return { status: 'error', message: e.message } + }) + } + ) + +const firestore = admin.firestore() diff --git a/web/.eslintrc.js b/web/.eslintrc.js index b55b3277..fec650f9 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -19,6 +19,7 @@ module.exports = { ], '@next/next/no-img-element': 'off', '@next/next/no-typos': 'off', + 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], }, env: { From a9e74e71119d65b4d7f819370d450d0c4e1e2da3 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 5 Jul 2022 12:25:58 -0700 Subject: [PATCH 086/220] Add functions framework as explicit dependency (#613) --- functions/package.json | 1 + yarn.lock | 208 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 194 insertions(+), 15 deletions(-) diff --git a/functions/package.json b/functions/package.json index 93bea621..4c9f4338 100644 --- a/functions/package.json +++ b/functions/package.json @@ -23,6 +23,7 @@ "main": "functions/src/index.js", "dependencies": { "@amplitude/node": "1.10.0", + "@google-cloud/functions-framework": "3.1.2", "firebase-admin": "10.0.0", "firebase-functions": "3.21.2", "lodash": "4.17.21", diff --git a/yarn.lock b/yarn.lock index c07d548f..0ee2aa0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2181,6 +2181,20 @@ google-gax "^2.24.1" protobufjs "^6.8.6" +"@google-cloud/functions-framework@3.1.2": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@google-cloud/functions-framework/-/functions-framework-3.1.2.tgz#2cd92ce4307bf7f32555d028dca22e398473b410" + integrity sha512-pYvEH65/Rqh1JNPdcBmorcV7Xoom2/iOSmbtYza8msro7Inl+qOYxbyMiQfySD2gwAyn38WyWPRqsDRcf/BFLg== + dependencies: + "@types/express" "4.17.13" + body-parser "^1.18.3" + cloudevents "^6.0.0" + express "^4.16.4" + minimist "^1.2.5" + on-finished "^2.3.0" + read-pkg-up "^7.0.1" + semver "^7.3.5" + "@google-cloud/paginator@^3.0.7": version "3.0.7" resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-3.0.7.tgz#fb6f8e24ec841f99defaebf62c75c2e744dd419b" @@ -2926,7 +2940,7 @@ "@types/qs" "*" "@types/range-parser" "*" -"@types/express@*", "@types/express@^4.17.13": +"@types/express@*", "@types/express@4.17.13", "@types/express@^4.17.13": version "4.17.13" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034" integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA== @@ -3049,6 +3063,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.38.tgz#f8bb07c371ccb1903f3752872c89f44006132947" integrity sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g== +"@types/normalize-package-data@^2.4.0": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" + integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -3498,7 +3517,7 @@ ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.0, ajv@^8.8.0: +ajv@^8.0.0, ajv@^8.11.0, ajv@^8.8.0: version "8.11.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f" integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg== @@ -3750,6 +3769,11 @@ autoprefixer@^10.3.7, autoprefixer@^10.4.2: picocolors "^1.0.0" postcss-value-parser "^4.2.0" +available-typed-arrays@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" + integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== + axe-core@^4.3.5: version "4.4.2" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.2.tgz#dcf7fb6dea866166c3eab33d68208afe4d5f670c" @@ -3880,7 +3904,7 @@ bluebird@^3.7.1: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== -body-parser@1.20.0: +body-parser@1.20.0, body-parser@^1.18.3: version "1.20.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5" integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg== @@ -4236,6 +4260,16 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +cloudevents@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/cloudevents/-/cloudevents-6.0.2.tgz#7b4990a92c6c30f6790eb4b59207b4d8949fca12" + integrity sha512-mn/4EZnAbhfb/TghubK2jPnxYM15JRjf8LnWJtXidiVKi5ZCkd+p9jyBZbL57w7nRm6oFAzJhjxRLsXd/DNaBQ== + dependencies: + ajv "^8.11.0" + ajv-formats "^2.1.1" + util "^0.12.4" + uuid "^8.3.2" + clsx@1.1.1, clsx@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" @@ -5277,7 +5311,7 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5: +es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5, es-abstract@^1.20.0: version "1.20.1" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.1.tgz#027292cd6ef44bd12b1913b828116f54787d1814" integrity sha512-WEm2oBhfoI2sImeM4OF2zE2V3BYdSF+KnSi9Sidz51fQHd7+JuF8Xgcj9/0o+OWeIeIS/MiuNnlruQrJf16GQA== @@ -5657,7 +5691,7 @@ execa@^5.0.0: signal-exit "^3.0.3" strip-final-newline "^2.0.0" -express@^4.17.1, express@^4.17.3: +express@^4.16.4, express@^4.17.1, express@^4.17.3: version "4.18.1" resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf" integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q== @@ -5871,7 +5905,7 @@ find-up@^3.0.0: dependencies: locate-path "^3.0.0" -find-up@^4.0.0: +find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== @@ -5981,6 +6015,13 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.7: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + fork-ts-checker-webpack-plugin@^6.5.0: version "6.5.2" resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz#4f67183f2f9eb8ba7df7177ce3cf3e75cdafb340" @@ -6585,6 +6626,11 @@ hoist-non-react-statics@^3.1.0: dependencies: react-is "^16.7.0" +hosted-git-info@^2.1.4: + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== + hpack.js@^2.1.6: version "2.1.6" resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" @@ -6945,6 +6991,14 @@ is-alphanumerical@^1.0.0: is-alphabetical "^1.0.0" is-decimal "^1.0.0" +is-arguments@^1.0.4: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" + integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -6977,7 +7031,7 @@ is-buffer@^2.0.0: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== -is-callable@^1.1.4, is-callable@^1.2.4: +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== @@ -6989,7 +7043,7 @@ is-ci@^2.0.0: dependencies: ci-info "^2.0.0" -is-core-module@^2.2.0, is-core-module@^2.8.1: +is-core-module@^2.2.0, is-core-module@^2.8.1, is-core-module@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== @@ -7028,6 +7082,13 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-generator-function@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" + integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A== + dependencies: + has-tostringtag "^1.0.0" + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -7161,6 +7222,17 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" +is-typed-array@^1.1.3, is-typed-array@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.9.tgz#246d77d2871e7d9f5aeb1d54b9f52c71329ece67" + integrity sha512-kfrlnTTn8pZkfpJMUgYD7YZ3qzeJgWUn8XfVYBARc4wnmNOmLbmuuaAs3q5fvB0UJOn6yHAKaGTPM7d6ezoD/A== + dependencies: + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + es-abstract "^1.20.0" + for-each "^0.3.3" + has-tostringtag "^1.0.0" + is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -8126,6 +8198,16 @@ nopt@1.0.10: dependencies: abbrev "1" +normalize-package-data@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" @@ -8252,7 +8334,7 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== -on-finished@2.4.1: +on-finished@2.4.1, on-finished@^2.3.0: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== @@ -9463,6 +9545,25 @@ react@17.0.2, react@^17.0.1: loose-envify "^1.1.0" object-assign "^4.1.1" +read-pkg-up@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" + integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== + dependencies: + find-up "^4.1.0" + read-pkg "^5.2.0" + type-fest "^0.8.1" + +read-pkg@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" + integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== + dependencies: + "@types/normalize-package-data" "^2.4.0" + normalize-package-data "^2.5.0" + parse-json "^5.0.0" + type-fest "^0.6.0" + readable-stream@1.1.x: version "1.1.14" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" @@ -9767,6 +9868,15 @@ resolve@^1.1.6, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.3. path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +resolve@^1.10.0: + version "1.22.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" + integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== + dependencies: + is-core-module "^2.9.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@^2.0.0-next.3: version "2.0.0-next.3" resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.3.tgz#d41016293d4a8586a39ca5d9b5f15cbea1f55e46" @@ -9848,7 +9958,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -9959,16 +10069,16 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" +"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.6.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + semver@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@^5.4.1, semver@^5.6.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== - semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" @@ -10223,6 +10333,32 @@ spawn-command@^0.0.2-1: resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0" integrity sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A= +spdx-correct@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" + integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" + integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== + +spdx-expression-parse@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" + integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.11" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz#50c0d8c40a14ec1bf449bae69a0ea4685a9d9f95" + integrity sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g== + spdy-transport@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" @@ -10706,6 +10842,16 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== +type-fest@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" + integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== + +type-fest@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + type-fest@^2.5.0: version "2.13.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.13.0.tgz#d1ecee38af29eb2e863b22299a3d68ef30d2abfb" @@ -10974,6 +11120,18 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= +util@^0.12.4: + version "0.12.4" + resolved "https://registry.yarnpkg.com/util/-/util-0.12.4.tgz#66121a31420df8f01ca0c464be15dfa1d1850253" + integrity sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw== + dependencies: + inherits "^2.0.3" + is-arguments "^1.0.4" + is-generator-function "^1.0.7" + is-typed-array "^1.1.3" + safe-buffer "^5.1.2" + which-typed-array "^1.1.2" + utila@~0.4: version "0.4.0" resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" @@ -10999,6 +11157,14 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + value-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" @@ -11232,6 +11398,18 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" +which-typed-array@^1.1.2: + version "1.1.8" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.8.tgz#0cfd53401a6f334d90ed1125754a42ed663eb01f" + integrity sha512-Jn4e5PItbcAHyLoRDwvPj1ypu27DJbtdYXUa5zsinrUx77Uvfb0cXwwnGMTn7cjUfhhqgVQnVJCwF+7cgU7tpw== + dependencies: + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + es-abstract "^1.20.0" + for-each "^0.3.3" + has-tostringtag "^1.0.0" + is-typed-array "^1.1.9" + which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" From f0fbdf1b42490a80e0899bcfcfded8d3c26aa61f Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 5 Jul 2022 12:26:13 -0700 Subject: [PATCH 087/220] Add a missing index (#606) --- firestore.indexes.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/firestore.indexes.json b/firestore.indexes.json index 064f6f2f..e0cee632 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -337,6 +337,20 @@ "order": "DESCENDING" } ] + }, + { + "collectionGroup": "portfolioHistory", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", + "order": "ASCENDING" + } + ] } ], "fieldOverrides": [ From 7f2bbdcb878477604d6c9c27b58d801054493e7a Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 5 Jul 2022 12:26:51 -0700 Subject: [PATCH 088/220] Allow people to sell all their shares (#599) --- functions/src/sell-shares.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index a0c19f2c..62e43105 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -46,7 +46,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const outcomeBets = userBets.filter((bet) => bet.outcome == outcome) const maxShares = sumBy(outcomeBets, (bet) => bet.shares) - if (shares > maxShares + 0.000000000001) + if (shares > maxShares) throw new APIError(400, `You can only sell up to ${maxShares} shares.`) const { newBet, newPool, newP, fees } = getCpmmSellBetInfo( From 4d1c50a6cca80ba20b674f0bd90dbda9e4a17aac Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 5 Jul 2022 12:35:39 -0700 Subject: [PATCH 089/220] Redemption refactoring (#614) * Refactor share redemption code into a few sensible functions * Put very general share redemption code into common --- common/new-bet.ts | 10 ++-- common/redeem.ts | 54 ++++++++++++++++++++++ functions/src/redeem-shares.ts | 83 ++++++---------------------------- 3 files changed, 74 insertions(+), 73 deletions(-) create mode 100644 common/redeem.ts diff --git a/common/new-bet.ts b/common/new-bet.ts index 236c0908..57739af3 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -20,9 +20,9 @@ import { noFees } from './fees' import { addObjects } from './util/object' import { NUMERIC_FIXED_VAR } from './numeric-constants' -export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'> +export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'> export type BetInfo = { - newBet: CandidateBet<Bet> + newBet: CandidateBet newPool?: { [outcome: string]: number } newTotalShares?: { [outcome: string]: number } newTotalBets?: { [outcome: string]: number } @@ -46,7 +46,7 @@ export const getNewBinaryCpmmBetInfo = ( const probBefore = getCpmmProbability(pool, p) const probAfter = getCpmmProbability(newPool, newP) - const newBet: CandidateBet<Bet> = { + const newBet: CandidateBet = { contractId: contract.id, amount, shares, @@ -96,7 +96,7 @@ export const getNewBinaryDpmBetInfo = ( const probBefore = getDpmProbability(contract.totalShares) const probAfter = getDpmProbability(newTotalShares) - const newBet: CandidateBet<Bet> = { + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount, @@ -133,7 +133,7 @@ export const getNewMultiBetInfo = ( const probBefore = getDpmOutcomeProbability(totalShares, outcome) const probAfter = getDpmOutcomeProbability(newTotalShares, outcome) - const newBet: CandidateBet<Bet> = { + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount, diff --git a/common/redeem.ts b/common/redeem.ts new file mode 100644 index 00000000..4a4080f6 --- /dev/null +++ b/common/redeem.ts @@ -0,0 +1,54 @@ +import { partition, sumBy } from 'lodash' + +import { Bet } from './bet' +import { getProbability } from './calculate' +import { CPMMContract } from './contract' +import { noFees } from './fees' +import { CandidateBet } from './new-bet' + +type RedeemableBet = Pick<Bet, 'outcome' | 'shares' | 'loanAmount'> + +export const getRedeemableAmount = (bets: RedeemableBet[]) => { + const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES') + const yesShares = sumBy(yesBets, (b) => b.shares) + const noShares = sumBy(noBets, (b) => b.shares) + const shares = Math.max(Math.min(yesShares, noShares), 0) + const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) + const loanPayment = Math.min(loanAmount, shares) + const netAmount = shares - loanPayment + return { shares, loanPayment, netAmount } +} + +export const getRedemptionBets = ( + shares: number, + loanPayment: number, + contract: CPMMContract +) => { + const p = getProbability(contract) + const createdTime = Date.now() + const yesBet: CandidateBet = { + contractId: contract.id, + amount: p * -shares, + shares: -shares, + loanAmount: loanPayment ? -loanPayment / 2 : 0, + outcome: 'YES', + probBefore: p, + probAfter: p, + createdTime, + isRedemption: true, + fees: noFees, + } + const noBet: CandidateBet = { + contractId: contract.id, + amount: (1 - p) * -shares, + shares: -shares, + loanAmount: loanPayment ? -loanPayment / 2 : 0, + outcome: 'NO', + probBefore: p, + probAfter: p, + createdTime, + isRedemption: true, + fees: noFees, + } + return [yesBet, noBet] +} diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index 67922a65..32b1d433 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -1,96 +1,43 @@ import * as admin from 'firebase-admin' -import { partition, sumBy } from 'lodash' import { Bet } from '../../common/bet' -import { getProbability } from '../../common/calculate' +import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem' import { Contract } from '../../common/contract' -import { noFees } from '../../common/fees' import { User } from '../../common/user' export const redeemShares = async (userId: string, contractId: string) => { - return await firestore.runTransaction(async (transaction) => { + return await firestore.runTransaction(async (trans) => { const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) + const contractSnap = await trans.get(contractDoc) if (!contractSnap.exists) return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - const { mechanism, outcomeType } = contract - if ( - !(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') || - mechanism !== 'cpmm-1' - ) - return { status: 'success' } + const { mechanism } = contract + if (mechanism !== 'cpmm-1') return { status: 'success' } - const betsSnap = await transaction.get( - firestore - .collection(`contracts/${contract.id}/bets`) - .where('userId', '==', userId) - ) + const betsColl = firestore.collection(`contracts/${contract.id}/bets`) + const betsSnap = await trans.get(betsColl.where('userId', '==', userId)) const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES') - const yesShares = sumBy(yesBets, (b) => b.shares) - const noShares = sumBy(noBets, (b) => b.shares) - - const amount = Math.min(yesShares, noShares) - if (amount <= 0) return - - const prevLoanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) - const loanPaid = Math.min(prevLoanAmount, amount) - const netAmount = amount - loanPaid - - const p = getProbability(contract) - const createdTime = Date.now() - - const yesDoc = firestore.collection(`contracts/${contract.id}/bets`).doc() - const yesBet: Bet = { - id: yesDoc.id, - userId: userId, - contractId: contract.id, - amount: p * -amount, - shares: -amount, - loanAmount: loanPaid ? -loanPaid / 2 : 0, - outcome: 'YES', - probBefore: p, - probAfter: p, - createdTime, - isRedemption: true, - fees: noFees, - } - - const noDoc = firestore.collection(`contracts/${contract.id}/bets`).doc() - const noBet: Bet = { - id: noDoc.id, - userId: userId, - contractId: contract.id, - amount: (1 - p) * -amount, - shares: -amount, - loanAmount: loanPaid ? -loanPaid / 2 : 0, - outcome: 'NO', - probBefore: p, - probAfter: p, - createdTime, - isRedemption: true, - fees: noFees, - } + const { shares, loanPayment, netAmount } = getRedeemableAmount(bets) + const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract) const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) + const userSnap = await trans.get(userDoc) if (!userSnap.exists) return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User - const newBalance = user.balance + netAmount if (!isFinite(newBalance)) { throw new Error('Invalid user balance for ' + user.username) } - transaction.update(userDoc, { balance: newBalance }) - - transaction.create(yesDoc, yesBet) - transaction.create(noDoc, noBet) + const yesDoc = betsColl.doc() + const noDoc = betsColl.doc() + trans.update(userDoc, { balance: newBalance }) + trans.create(yesDoc, { id: yesDoc.id, userId, ...yesBet }) + trans.create(noDoc, { id: noDoc.id, userId, ...noBet }) return { status: 'success' } }) From 5eca9def9d011c80e4ea0ea6452f10012b17054a Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 5 Jul 2022 14:01:57 -0700 Subject: [PATCH 090/220] Don't accidentally make meaningless zero bets (#619) --- functions/src/redeem-shares.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index 32b1d433..0a69521f 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -21,6 +21,9 @@ export const redeemShares = async (userId: string, contractId: string) => { const betsSnap = await trans.get(betsColl.where('userId', '==', userId)) const bets = betsSnap.docs.map((doc) => doc.data() as Bet) const { shares, loanPayment, netAmount } = getRedeemableAmount(bets) + if (netAmount === 0) { + return { status: 'success' } + } const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract) const userDoc = firestore.doc(`users/${userId}`) From 270a5fc13911e63f0d6a8615a56c478868bc9547 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Tue, 5 Jul 2022 14:34:16 -0700 Subject: [PATCH 091/220] also filter by username when adding people --- web/components/filter-select-users.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/components/filter-select-users.tsx b/web/components/filter-select-users.tsx index 8d2dbbae..7ce73cf8 100644 --- a/web/components/filter-select-users.tsx +++ b/web/components/filter-select-users.tsx @@ -35,7 +35,8 @@ export function FilterSelectUsers(props: { return ( !selectedUsers.map((user) => user.name).includes(user.name) && !ignoreUserIds.includes(user.id) && - user.name.toLowerCase().includes(query.toLowerCase()) + (user.name.toLowerCase().includes(query.toLowerCase()) || + user.username.toLowerCase().includes(query.toLowerCase())) ) }) ) From 3a6d28e2c2b94c26207c5abff126eae50410da4f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 5 Jul 2022 17:18:37 -0600 Subject: [PATCH 092/220] Bold groups with recent chat activity (#621) * Bold groups with recent chat activity * Cleanup * Cleanup --- common/notification.ts | 3 ++ functions/src/create-notification.ts | 16 +++++- functions/src/index.ts | 3 +- ...nt.ts => on-create-comment-on-contract.ts} | 2 +- functions/src/on-create-comment-on-group.ts | 52 +++++++++++++++++++ functions/src/on-update-group.ts | 1 + web/components/nav/sidebar.tsx | 48 ++++++++++++++--- web/hooks/use-notifications.ts | 10 ++-- web/pages/notifications.tsx | 4 +- 9 files changed, 123 insertions(+), 16 deletions(-) rename functions/src/{on-create-comment.ts => on-create-comment-on-contract.ts} (98%) create mode 100644 functions/src/on-create-comment-on-group.ts diff --git a/common/notification.ts b/common/notification.ts index e90624a4..16444c48 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -22,6 +22,8 @@ export type Notification = { sourceSlug?: string sourceTitle?: string + + isSeenOnHref?: string } export type notification_source_types = | 'contract' @@ -58,3 +60,4 @@ export type notification_reason_types = | 'you_referred_user' | 'user_joined_to_bet_on_your_market' | 'unique_bettors_on_your_contract' + | 'on_group_you_are_member_of' diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index b63958f0..45db1c4e 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -17,7 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object' const firestore = admin.firestore() type user_to_reason_texts = { - [userId: string]: { reason: notification_reason_types } + [userId: string]: { reason: notification_reason_types; isSeeOnHref?: string } } export const createNotification = async ( @@ -72,6 +72,7 @@ export const createNotification = async ( sourceContractSlug: sourceContract?.slug, sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, + isSeenOnHref: userToReasonTexts[userId].isSeeOnHref, } await notificationRef.set(removeUndefinedProps(notification)) }) @@ -276,6 +277,17 @@ export const createNotification = async ( } } + const notifyOtherGroupMembersOfComment = async ( + userToReasonTexts: user_to_reason_texts, + userId: string + ) => { + if (shouldGetNotification(userId, userToReasonTexts)) + userToReasonTexts[userId] = { + reason: 'on_group_you_are_member_of', + isSeeOnHref: sourceSlug, + } + } + const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -286,6 +298,8 @@ export const createNotification = async ( await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) } else if (sourceType === 'user' && relatedUserId) { await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId) + } else if (sourceType === 'comment' && !sourceContract && relatedUserId) { + await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId) } // The following functions need sourceContract to be defined. diff --git a/functions/src/index.ts b/functions/src/index.ts index e4a30761..d9b7a255 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -10,7 +10,7 @@ export * from './stripe' export * from './create-user' export * from './create-answer' export * from './on-create-bet' -export * from './on-create-comment' +export * from './on-create-comment-on-contract' export * from './on-view' export * from './unsubscribe' export * from './update-metrics' @@ -28,6 +28,7 @@ export * from './on-create-liquidity-provision' export * from './on-update-group' export * from './on-create-group' export * from './on-update-user' +export * from './on-create-comment-on-group' // v2 export * from './health' diff --git a/functions/src/on-create-comment.ts b/functions/src/on-create-comment-on-contract.ts similarity index 98% rename from functions/src/on-create-comment.ts rename to functions/src/on-create-comment-on-contract.ts index 8d52fd46..f7839b44 100644 --- a/functions/src/on-create-comment.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -11,7 +11,7 @@ import { createNotification } from './create-notification' const firestore = admin.firestore() -export const onCreateComment = functions +export const onCreateCommentOnContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) .firestore.document('contracts/{contractId}/comments/{commentId}') .onCreate(async (change, context) => { diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts new file mode 100644 index 00000000..7217e602 --- /dev/null +++ b/functions/src/on-create-comment-on-group.ts @@ -0,0 +1,52 @@ +import * as functions from 'firebase-functions' +import { Comment } from '../../common/comment' +import * as admin from 'firebase-admin' +import { Group } from '../../common/group' +import { User } from '../../common/user' +import { createNotification } from './create-notification' +const firestore = admin.firestore() + +export const onCreateCommentOnGroup = functions.firestore + .document('groups/{groupId}/comments/{commentId}') + .onCreate(async (change, context) => { + const { eventId } = context + const { groupId } = context.params as { + groupId: string + } + + const comment = change.data() as Comment + const creatorSnapshot = await firestore + .collection('users') + .doc(comment.userId) + .get() + if (!creatorSnapshot.exists) throw new Error('Could not find user') + + const groupSnapshot = await firestore + .collection('groups') + .doc(groupId) + .get() + if (!groupSnapshot.exists) throw new Error('Could not find group') + + const group = groupSnapshot.data() as Group + await firestore.collection('groups').doc(groupId).update({ + mostRecentActivityTime: comment.createdTime, + }) + + await Promise.all( + group.memberIds.map(async (memberId) => { + return await createNotification( + comment.id, + 'comment', + 'created', + creatorSnapshot.data() as User, + eventId, + comment.text, + undefined, + undefined, + memberId, + `/group/${group.slug}`, + `${group.name}` + ) + }) + ) + }) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index bc6f6ab4..feaa6443 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -12,6 +12,7 @@ export const onUpdateGroup = functions.firestore // ignore the update we just made if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return + // TODO: create notification with isSeeOnHref set to the group's /group/questions url await firestore .collection('groups') diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index ba46bd80..b9449ea0 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -18,7 +18,7 @@ import { ManifoldLogo } from './manifold-logo' import { MenuButton } from './menu' import { ProfileSummary } from './profile-menu' import NotificationsIcon from 'web/components/notifications-icon' -import React from 'react' +import React, { useEffect } from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { CreateQuestionButton } from 'web/components/create-question-button' import { useMemberGroups } from 'web/hooks/use-group' @@ -26,6 +26,8 @@ import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' import { Group } from 'common/group' import { Spacer } from '../layout/spacer' +import { usePreferredNotifications } from 'web/hooks/use-notifications' +import { setNotificationsAsSeen } from 'web/pages/notifications' function getNavigation() { return [ @@ -182,6 +184,7 @@ export default function Sidebar(props: { className?: string }) { const { className } = props const router = useRouter() const currentPage = router.pathname + const user = useUser() const navigationOptions = !user ? signedOutNavigation : getNavigation() const mobileNavigationOptions = !user @@ -217,7 +220,11 @@ export default function Sidebar(props: { className?: string }) { /> )} - <GroupsList currentPage={currentPage} memberItems={memberItems} /> + <GroupsList + currentPage={router.asPath} + memberItems={memberItems} + user={user} + /> </div> {/* Desktop navigation */} @@ -236,14 +243,36 @@ export default function Sidebar(props: { className?: string }) { <div className="h-[1px] bg-gray-300" /> </div> )} - <GroupsList currentPage={currentPage} memberItems={memberItems} /> + <GroupsList + currentPage={router.asPath} + memberItems={memberItems} + user={user} + /> </div> </nav> ) } -function GroupsList(props: { currentPage: string; memberItems: Item[] }) { - const { currentPage, memberItems } = props +function GroupsList(props: { + currentPage: string + memberItems: Item[] + user: User | null | undefined +}) { + const { currentPage, memberItems, user } = props + const preferredNotifications = usePreferredNotifications(user?.id, { + unseenOnly: true, + customHref: '/group/', + }) + + // Set notification as seen if our current page is equal to the isSeenOnHref property + useEffect(() => { + preferredNotifications.forEach((notification) => { + if (notification.isSeenOnHref === currentPage) { + setNotificationsAsSeen([notification]) + } + }) + }, [currentPage, preferredNotifications]) + return ( <> <SidebarItem @@ -256,9 +285,14 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) { <a key={item.href} href={item.href} - className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900" + className={clsx( + 'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900', + preferredNotifications.some( + (n) => !n.isSeen && n.isSeenOnHref === item.href + ) && 'font-bold' + )} > - <span className="truncate">  {item.name}</span> + <span className="truncate">{item.name}</span> </a> ))} </div> diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 0a15754d..539573dd 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -83,11 +83,11 @@ export function groupNotifications(notifications: Notification[]) { return notificationGroups } -function usePreferredNotifications( +export function usePreferredNotifications( userId: string | undefined, - options: { unseenOnly: boolean } + options: { unseenOnly: boolean; customHref?: string } ) { - const { unseenOnly } = options + const { unseenOnly, customHref } = options const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null) const [notifications, setNotifications] = useState<Notification[]>([]) const [userAppropriateNotifications, setUserAppropriateNotifications] = @@ -112,9 +112,11 @@ function usePreferredNotifications( const notificationsToShow = getAppropriateNotifications( notifications, privateUser.notificationPreferences + ).filter((n) => + customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref ) setUserAppropriateNotifications(notificationsToShow) - }, [privateUser, notifications]) + }, [privateUser, notifications, customHref]) return userAppropriateNotifications } diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 229e8c8d..569f8ef8 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -166,7 +166,7 @@ export default function Notifications() { ) } -const setNotificationsAsSeen = (notifications: Notification[]) => { +export const setNotificationsAsSeen = (notifications: Notification[]) => { notifications.forEach((notification) => { if (!notification.isSeen) updateDoc( @@ -758,7 +758,7 @@ function NotificationItem(props: { <div className={clsx( 'bg-white px-2 pt-6 text-sm sm:px-4', - highlighted && 'bg-indigo-200' + highlighted && 'bg-indigo-200 hover:bg-indigo-100' )} > <a href={getSourceUrl()}> From cb25a7752d8552f7dd6ae01483508d435067f0ff Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 5 Jul 2022 16:26:58 -0700 Subject: [PATCH 093/220] Duplicate a question from '...' screen (#622) * Duplicate a question from '...' screen * Remove unused code --- .../contract/contract-info-dialog.tsx | 2 + web/components/copy-contract-button.tsx | 54 +++++++++++++ web/pages/create.tsx | 78 +++++++++++-------- 3 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 web/components/copy-contract-button.tsx diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 12fd8dd9..3e51902b 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -21,6 +21,7 @@ import { Title } from '../title' import { TweetButton } from '../tweet-button' import { InfoTooltip } from '../info-tooltip' import { TagsInput } from 'web/components/tags-input' +import { DuplicateContractButton } from '../copy-contract-button' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -71,6 +72,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { tweetText={getTweetText(contract, false)} /> <ShareEmbedButton contract={contract} toastClassName={'-left-20'} /> + <DuplicateContractButton contract={contract} /> </Row> <div /> diff --git a/web/components/copy-contract-button.tsx b/web/components/copy-contract-button.tsx new file mode 100644 index 00000000..ad378878 --- /dev/null +++ b/web/components/copy-contract-button.tsx @@ -0,0 +1,54 @@ +import { DuplicateIcon } from '@heroicons/react/outline' +import clsx from 'clsx' +import { Contract } from 'common/contract' +import { getMappedValue } from 'common/pseudo-numeric' +import { trackCallback } from 'web/lib/service/analytics' + +export function DuplicateContractButton(props: { + contract: Contract + className?: string +}) { + const { contract, className } = props + + return ( + <a + className={clsx('btn btn-xs flex-nowrap normal-case', className)} + style={{ + backgroundColor: 'white', + border: '2px solid #a78bfa', + // violet-400 + color: '#a78bfa', + }} + href={duplicateContractHref(contract)} + onClick={trackCallback('duplicate market')} + target="_blank" + > + <DuplicateIcon className="mr-1.5 h-4 w-4" aria-hidden="true" /> + <div>Duplicate</div> + </a> + ) +} + +// Pass along the Uri to create a new contract +function duplicateContractHref(contract: Contract) { + const params = { + q: contract.question, + closeTime: contract.closeTime || 0, + description: contract.description, + outcomeType: contract.outcomeType, + } as Record<string, any> + + if (contract.outcomeType === 'PSEUDO_NUMERIC') { + params.min = contract.min + params.max = contract.max + params.isLogScale = contract.isLogScale + params.initValue = getMappedValue(contract)(contract.initialProbability) + } + + return ( + `/create?` + + Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&') + ) +} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 6a5f96ae..95b8e247 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -28,14 +28,32 @@ import { GroupSelector } from 'web/components/groups/group-selector' import { CATEGORIES } from 'common/categories' import { User } from 'common/user' -export default function Create() { - const [question, setQuestion] = useState('') - // get query params: - const router = useRouter() - const { groupId } = router.query as { groupId: string } - useTracking('view create page') - const creator = useUser() +type NewQuestionParams = { + groupId?: string + q: string + type: string + description: string + closeTime: string + outcomeType: string + // Params for PSEUDO_NUMERIC outcomeType + min?: string + max?: string + isLogScale?: string + initValue?: string +} +export default function Create() { + useTracking('view create page') + const router = useRouter() + const params = router.query as NewQuestionParams + // TODO: Not sure why Question is pulled out as its own component; + // Maybe merge into newContract and then we don't need useEffect here. + const [question, setQuestion] = useState('') + useEffect(() => { + setQuestion(params.q ?? '') + }, [params.q]) + + const creator = useUser() useEffect(() => { if (creator === null) router.push('/') }, [creator, router]) @@ -65,11 +83,7 @@ export default function Create() { </div> </form> <Spacer h={6} /> - <NewContract - question={question} - groupId={groupId} - creator={creator} - /> + <NewContract question={question} params={params} creator={creator} /> </div> </div> </Page> @@ -80,20 +94,21 @@ export default function Create() { export function NewContract(props: { creator: User question: string - groupId?: string + params?: NewQuestionParams }) { - const { creator, question, groupId } = props - const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY') + const { creator, question, params } = props + const { groupId, initValue } = params ?? {} + const [outcomeType, setOutcomeType] = useState<outcomeType>( + (params?.outcomeType as outcomeType) ?? 'BINARY' + ) const [initialProb] = useState(50) - const [minString, setMinString] = useState('') - const [maxString, setMaxString] = useState('') - const [isLogScale, setIsLogScale] = useState(false) - const [initialValueString, setInitialValueString] = useState('') + const [minString, setMinString] = useState(params?.min ?? '') + const [maxString, setMaxString] = useState(params?.max ?? '') + const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale) + const [initialValueString, setInitialValueString] = useState(initValue) - const [description, setDescription] = useState('') - // const [tagText, setTagText] = useState<string>(tag ?? '') - // const tags = parseWordsAsTags(tagText) + const [description, setDescription] = useState(params?.description ?? '') useEffect(() => { if (groupId && creator) getGroup(groupId).then((group) => { @@ -105,18 +120,17 @@ export function NewContract(props: { }, [creator, groupId]) const [ante, _setAnte] = useState(FIXED_ANTE) - // useEffect(() => { - // if (ante === null && creator) { - // const initialAnte = creator.balance < 100 ? MINIMUM_ANTE : 100 - // setAnte(initialAnte) - // } - // }, [ante, creator]) - - // const [anteError, setAnteError] = useState<string | undefined>() + // If params.closeTime is set, extract out the specified date and time // By default, close the market a week from today const weekFromToday = dayjs().add(7, 'day').format('YYYY-MM-DD') - const [closeDate, setCloseDate] = useState<undefined | string>(weekFromToday) - const [closeHoursMinutes, setCloseHoursMinutes] = useState<string>('23:59') + const timeInMs = Number(params?.closeTime ?? 0) + const initDate = timeInMs + ? dayjs(timeInMs).format('YYYY-MM-DD') + : weekFromToday + const initTime = timeInMs ? dayjs(timeInMs).format('HH:mm') : '23:59' + const [closeDate, setCloseDate] = useState<undefined | string>(initDate) + const [closeHoursMinutes, setCloseHoursMinutes] = useState<string>(initTime) + const [marketInfoText, setMarketInfoText] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [selectedGroup, setSelectedGroup] = useState<Group | undefined>( From b71944607b7cd4701ce1a901a08c93c2196dd6b8 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 5 Jul 2022 16:48:59 -0700 Subject: [PATCH 094/220] Simplify Tweet text --- .../contract/contract-info-dialog.tsx | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 3e51902b..b5ecea15 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -7,11 +7,7 @@ import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' -import { - contractPath, - contractPool, - getBinaryProbPercent, -} from 'web/lib/firebase/contracts' +import { contractPath, contractPool } from 'web/lib/firebase/contracts' import { LiquidityPanel } from '../liquidity-panel' import { Col } from '../layout/col' import { Modal } from '../layout/modal' @@ -69,7 +65,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { <Row className="justify-start gap-4"> <TweetButton className="self-start" - tweetText={getTweetText(contract, false)} + tweetText={getTweetText(contract)} /> <ShareEmbedButton contract={contract} toastClassName={'-left-20'} /> <DuplicateContractButton contract={contract} /> @@ -157,23 +153,13 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { ) } -const getTweetText = (contract: Contract, isCreator: boolean) => { - const { question, creatorName, resolution, outcomeType } = contract - const isBinary = outcomeType === 'BINARY' +const getTweetText = (contract: Contract) => { + const { question, resolution } = contract - const tweetQuestion = isCreator - ? question - : `${question}\nAsked by ${creatorName}.` - const tweetDescription = resolution - ? `Resolved ${resolution}!` - : isBinary - ? `Currently ${getBinaryProbPercent( - contract - )} chance, place your bets here:` - : `Submit your own answer:` + const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : '' const timeParam = `${Date.now()}`.substring(7) const url = `https://manifold.markets${contractPath(contract)}?t=${timeParam}` - return `${tweetQuestion}\n\n${tweetDescription}\n\n${url}` + return `${question}\n\n${url}${tweetDescription}` } From 6cd8b04bd01f1f9852eff8d056c7fa683a3e34e8 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 5 Jul 2022 16:53:00 -0700 Subject: [PATCH 095/220] Nit: Fix spacing --- common/pseudo-numeric.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts index 9a322e35..c99e670f 100644 --- a/common/pseudo-numeric.ts +++ b/common/pseudo-numeric.ts @@ -17,7 +17,7 @@ export const getMappedValue = if (isLogScale) { const logValue = p * Math.log10(max - min) - return 10 ** logValue + min + return 10 ** logValue + min } return p * (max - min) + min From 029021b35117a572e16ef8b30eb025cc41daa663 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 5 Jul 2022 17:20:37 -0700 Subject: [PATCH 096/220] Remove Categories from /create --- web/pages/create.tsx | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 95b8e247..df83fb9f 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -25,7 +25,6 @@ import { useTracking } from 'web/hooks/use-tracking' import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' import { track } from 'web/lib/service/analytics' import { GroupSelector } from 'web/components/groups/group-selector' -import { CATEGORIES } from 'common/categories' import { User } from 'common/user' type NewQuestionParams = { @@ -137,7 +136,6 @@ export function NewContract(props: { undefined ) const [showGroupSelector, setShowGroupSelector] = useState(true) - const [category, setCategory] = useState<string>('') const closeTime = closeDate ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf() @@ -210,7 +208,6 @@ export function NewContract(props: { initialValue, isLogScale: (min ?? 0) < 0 ? false : isLogScale, groupId: selectedGroup?.id, - tags: category ? [category] : undefined, }) ) track('create market', { @@ -352,28 +349,6 @@ export function NewContract(props: { </> )} - <div className="form-control max-w-[265px] items-start"> - <label className="label gap-2"> - <span className="mb-1">Category</span> - </label> - - <select - className={clsx( - 'select select-bordered w-full text-sm', - category === '' ? 'font-normal text-gray-500' : '' - )} - value={category} - onChange={(e) => setCategory(e.currentTarget.value ?? '')} - > - <option value={''}>None</option> - {Object.entries(CATEGORIES).map(([id, name]) => ( - <option key={id} value={id}> - {name} - </option> - ))} - </select> - </div> - <div className={'mt-2'}> <GroupSelector selectedGroup={selectedGroup} From a6143c1abb791507fee58d4bce7c5f6f5a966830 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 6 Jul 2022 07:27:21 -0600 Subject: [PATCH 097/220] Always group income --- web/pages/notifications.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 569f8ef8..e20b6028 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -83,16 +83,16 @@ export default function Notifications() { {paginatedNotificationGroups.length === 0 && "You don't have any notifications. Try changing your settings to see more."} {paginatedNotificationGroups.map((notification) => - notification.notifications.length === 1 ? ( - <NotificationItem - notification={notification.notifications[0]} - key={notification.notifications[0].id} - /> - ) : notification.type === 'income' ? ( + notification.type === 'income' ? ( <IncomeNotificationGroupItem notificationGroup={notification} key={notification.groupedById + notification.timePeriod} /> + ) : notification.notifications.length === 1 ? ( + <NotificationItem + notification={notification.notifications[0]} + key={notification.notifications[0].id} + /> ) : ( <NotificationGroupItem notificationGroup={notification} From 83a02c4b20035592067b2bee97de23b4b7838163 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 6 Jul 2022 07:45:47 -0600 Subject: [PATCH 098/220] Small notifications ux improvements --- web/pages/notifications.tsx | 98 +++++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 26 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index e20b6028..185225e9 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -275,7 +275,9 @@ function IncomeNotificationGroupItem(props: { > <span> {'Daily Income Summary: '} - <span className={'text-primary'}>{formatMoney(totalIncome)}</span> + <span className={'text-primary'}> + {'+' + formatMoney(totalIncome)} + </span> </span> </div> <RelativeTimestamp time={notifications[0].createdTime} /> @@ -291,11 +293,44 @@ function IncomeNotificationGroupItem(props: { .slice(0, numSummaryLines) .map((notification) => { return ( - <NotificationItem - notification={notification} - justSummary={true} + <Row + className={ + 'items-center text-sm text-gray-500 sm:justify-start' + } key={notification.id} - /> + > + <div + className={ + 'line-clamp-1 flex-1 overflow-hidden sm:flex' + } + > + <div className={'flex pl-1 sm:pl-0'}> + <div + className={ + 'inline-flex overflow-hidden text-ellipsis pl-1' + } + > + <div className={'mr-1 text-black'}> + <NotificationTextLabel + contract={null} + defaultText={notification.sourceText ?? ''} + className={'line-clamp-1'} + notification={notification} + justSummary={true} + /> + </div> + <span className={'flex-shrink-0'}> + {getReasonForShowingNotification( + notification, + true + )} + {` on`} + <NotificationLink notification={notification} /> + </span> + </div> + </div> + </div> + </Row> ) })} <div className={'text-sm text-gray-500 hover:underline '}> @@ -640,6 +675,34 @@ function NotificationSettings() { ) } +function NotificationLink(props: { notification: Notification }) { + const { notification } = props + const { + sourceType, + sourceContractTitle, + sourceContractCreatorUsername, + sourceContractSlug, + sourceSlug, + sourceTitle, + } = notification + return ( + <a + href={ + sourceContractCreatorUsername + ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` + : sourceType === 'group' && sourceSlug + ? `${groupPath(sourceSlug)}` + : '' + } + className={ + 'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' + } + > + {sourceContractTitle || sourceTitle} + </a> + ) +} + function NotificationItem(props: { notification: Notification justSummary?: boolean @@ -656,11 +719,9 @@ function NotificationItem(props: { sourceUserUsername, createdTime, sourceText, - sourceContractTitle, sourceContractCreatorUsername, sourceContractSlug, sourceSlug, - sourceTitle, } = notification const [defaultNotificationText, setDefaultNotificationText] = @@ -790,20 +851,7 @@ function NotificationItem(props: { {sourceType && reason && ( <div className={'inline truncate'}> {getReasonForShowingNotification(notification, false)} - <a - href={ - sourceContractCreatorUsername - ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` - : sourceType === 'group' && sourceSlug - ? `${groupPath(sourceSlug)}` - : '' - } - className={ - 'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' - } - > - {sourceContractTitle || sourceTitle} - </a> + <NotificationLink notification={notification} /> </div> )} </div> @@ -892,9 +940,7 @@ function NotificationTextLabel(props: { ) } else if (sourceType === 'bonus' && sourceText) { return ( - <span className="text-primary"> - {'+' + formatMoney(parseInt(sourceText))} - </span> + <span className="text-primary">{formatMoney(parseInt(sourceText))}</span> ) } // return default text @@ -931,7 +977,7 @@ function getReasonForShowingNotification( else reasonText = `commented on` break case 'contract': - if (reason === 'you_follow_user') reasonText = 'created a new question' + if (reason === 'you_follow_user') reasonText = 'asked' else if (sourceUpdateType === 'resolved') reasonText = `resolved` else if (sourceUpdateType === 'closed') reasonText = `Please resolve your question` @@ -968,7 +1014,7 @@ function getReasonForShowingNotification( ? `You had ${ parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT } unique bettors on` - : 'You earned Mana for unique bettors:' + : ' for unique bettors' else reasonText = 'You earned your daily manna' break default: From 434b8b9dbe2f29b4ecbdd61aab0e45d23fafa053 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 6 Jul 2022 07:51:32 -0600 Subject: [PATCH 099/220] Just show first names to save space --- web/components/notifications-icon.tsx | 2 +- web/components/user-page.tsx | 5 +++-- web/pages/notifications.tsx | 14 +++++++------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index ac4d772f..8f45a054 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -19,7 +19,7 @@ export default function NotificationsIcon(props: { className?: string }) { useEffect(() => { if (!privateUser) return - if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 60 * 1000) + if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 65 * 1000) requestBonuses({}).catch((error) => { console.log("couldn't get bonuses:", error.message) }) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 07f722d7..c33476aa 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -45,15 +45,16 @@ export function UserLink(props: { username: string showUsername?: boolean className?: string + justFirstName?: boolean }) { - const { name, username, showUsername, className } = props + const { name, username, showUsername, className, justFirstName } = props return ( <SiteLink href={`/${username}`} className={clsx('z-10 truncate', className)} > - {name} + {justFirstName ? name.split(' ')[0] : name} {showUsername && ` (@${username})`} </SiteLink> ) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 185225e9..2c6c2433 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -783,13 +783,12 @@ function NotificationItem(props: { <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> <div className={'flex pl-1 sm:pl-0'}> - {sourceType != 'bonus' && ( - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - /> - )} + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-0 flex-shrink-0'} + justFirstName={true} + /> <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> <span className={'flex-shrink-0'}> {sourceType && @@ -845,6 +844,7 @@ function NotificationItem(props: { name={sourceUserName || ''} username={sourceUserUsername || ''} className={'mr-0 flex-shrink-0'} + justFirstName={true} /> )} <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> From 2d1e76eae8cbe22cd2c9af14f6249df696a902f2 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 6 Jul 2022 10:39:19 -0700 Subject: [PATCH 100/220] When duplicating, add the original link in description --- web/components/copy-contract-button.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/components/copy-contract-button.tsx b/web/components/copy-contract-button.tsx index ad378878..fcb3a347 100644 --- a/web/components/copy-contract-button.tsx +++ b/web/components/copy-contract-button.tsx @@ -1,7 +1,9 @@ import { DuplicateIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { Contract } from 'common/contract' +import { ENV_CONFIG } from 'common/envs/constants' import { getMappedValue } from 'common/pseudo-numeric' +import { contractPath } from 'web/lib/firebase/contracts' import { trackCallback } from 'web/lib/service/analytics' export function DuplicateContractButton(props: { @@ -34,7 +36,9 @@ function duplicateContractHref(contract: Contract) { const params = { q: contract.question, closeTime: contract.closeTime || 0, - description: contract.description, + description: + (contract.description ? `${contract.description}\n\n` : '') + + `(Copied from https://${ENV_CONFIG.domain}${contractPath(contract)})`, outcomeType: contract.outcomeType, } as Record<string, any> From de20ee9fb9a4eddfd1049beebe5b2f881ee5322b Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 6 Jul 2022 13:30:51 -0600 Subject: [PATCH 101/220] Show tip notifications (#623) * Show tip notifications * Optimizing notifications for mobile * Unused vars * Move income reason logic to income notif * Remove unnecessary icons * Unused vars --- common/antes.ts | 1 + common/notification.ts | 1 + functions/src/create-notification.ts | 18 +- functions/src/get-daily-bonuses.ts | 13 +- functions/src/index.ts | 1 + functions/src/on-create-txn.ts | 68 ++++ web/components/avatar.tsx | 2 +- web/components/feed/feed-comments.tsx | 2 +- web/hooks/use-notifications.ts | 14 +- web/pages/notifications.tsx | 498 +++++++++++++++----------- 10 files changed, 391 insertions(+), 227 deletions(-) create mode 100644 functions/src/on-create-txn.ts diff --git a/common/antes.ts b/common/antes.ts index d4cb2ff9..b3dd990b 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -15,6 +15,7 @@ import { ENV_CONFIG } from './envs/constants' export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100 export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id +export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id export function getCpmmInitialLiquidity( providerId: string, diff --git a/common/notification.ts b/common/notification.ts index 16444c48..da8a045a 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -61,3 +61,4 @@ export type notification_reason_types = | 'user_joined_to_bet_on_your_market' | 'unique_bettors_on_your_contract' | 'on_group_you_are_member_of' + | 'tip_received' diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 45db1c4e..49bff5f7 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -66,9 +66,7 @@ export const createNotification = async ( sourceUserAvatarUrl: sourceUser.avatarUrl, sourceText, sourceContractCreatorUsername: sourceContract?.creatorUsername, - // TODO: move away from sourceContractTitle to sourceTitle sourceContractTitle: sourceContract?.question, - // TODO: move away from sourceContractSlug to sourceSlug sourceContractSlug: sourceContract?.slug, sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, @@ -278,13 +276,22 @@ export const createNotification = async ( } const notifyOtherGroupMembersOfComment = async ( + userToReasons: user_to_reason_texts, + userId: string + ) => { + if (shouldGetNotification(userId, userToReasons)) + userToReasons[userId] = { + reason: 'on_group_you_are_member_of', + isSeeOnHref: sourceSlug, + } + } + const notifyTippedUserOfNewTip = async ( userToReasonTexts: user_to_reason_texts, userId: string ) => { if (shouldGetNotification(userId, userToReasonTexts)) userToReasonTexts[userId] = { - reason: 'on_group_you_are_member_of', - isSeeOnHref: sourceSlug, + reason: 'tip_received', } } @@ -304,6 +311,7 @@ export const createNotification = async ( // The following functions need sourceContract to be defined. if (!sourceContract) return userToReasonTexts + if ( sourceType === 'comment' || sourceType === 'answer' || @@ -338,6 +346,8 @@ export const createNotification = async ( userToReasonTexts, sourceContract.creatorId ) + } else if (sourceType === 'tip' && relatedUserId) { + await notifyTippedUserOfNewTip(userToReasonTexts, relatedUserId) } return userToReasonTexts } diff --git a/functions/src/get-daily-bonuses.ts b/functions/src/get-daily-bonuses.ts index c5c1a1b3..017c32fc 100644 --- a/functions/src/get-daily-bonuses.ts +++ b/functions/src/get-daily-bonuses.ts @@ -1,11 +1,14 @@ import { APIError, newEndpoint } from './api' -import { log } from './utils' +import { isProd, log } from './utils' import * as admin from 'firebase-admin' import { PrivateUser } from '../../common/lib/user' import { uniq } from 'lodash' import { Bet } from '../../common/lib/bet' const firestore = admin.firestore() -import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' import { runTxn, TxnData } from './transact' import { createNotification } from './create-notification' import { User } from '../../common/lib/user' @@ -38,9 +41,9 @@ export const getdailybonuses = newEndpoint({}, async (req, auth) => { } } ) - // TODO: switch to prod id - // const fromUserId = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // dev manifold account - const fromUserId = HOUSE_LIQUIDITY_PROVIDER_ID // prod manifold account + const fromUserId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID const fromSnap = await firestore.doc(`users/${fromUserId}`).get() if (!fromSnap.exists) throw new APIError(400, 'From user not found.') const fromUser = fromSnap.data() as User diff --git a/functions/src/index.ts b/functions/src/index.ts index d9b7a255..8d1756f2 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -29,6 +29,7 @@ export * from './on-update-group' export * from './on-create-group' export * from './on-update-user' export * from './on-create-comment-on-group' +export * from './on-create-txn' // v2 export * from './health' diff --git a/functions/src/on-create-txn.ts b/functions/src/on-create-txn.ts new file mode 100644 index 00000000..d877ecac --- /dev/null +++ b/functions/src/on-create-txn.ts @@ -0,0 +1,68 @@ +import * as functions from 'firebase-functions' +import { Txn } from 'common/txn' +import { getContract, getUser, log } from './utils' +import { createNotification } from './create-notification' +import * as admin from 'firebase-admin' +import { Comment } from 'common/comment' + +const firestore = admin.firestore() + +export const onCreateTxn = functions.firestore + .document('txns/{txnId}') + .onCreate(async (change, context) => { + const txn = change.data() as Txn + const { eventId } = context + + if (txn.category === 'TIP') { + await handleTipTxn(txn, eventId) + } + }) + +async function handleTipTxn(txn: Txn, eventId: string) { + // get user sending and receiving tip + const [sender, receiver] = await Promise.all([ + getUser(txn.fromId), + getUser(txn.toId), + ]) + if (!sender || !receiver) { + log('Could not find corresponding users') + return + } + + if (!txn.data?.contractId || !txn.data?.commentId) { + log('No contractId or comment id in tip txn.data') + return + } + + const contract = await getContract(txn.data.contractId) + if (!contract) { + log('Could not find contract') + return + } + + const commentSnapshot = await firestore + .collection('contracts') + .doc(contract.id) + .collection('comments') + .doc(txn.data.commentId) + .get() + if (!commentSnapshot.exists) { + log('Could not find comment') + return + } + const comment = commentSnapshot.data() as Comment + + await createNotification( + txn.id, + 'tip', + 'created', + sender, + eventId, + txn.amount.toString(), + contract, + 'comment', + receiver.id, + txn.data?.commentId, + comment.text + ) +} diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index e6506c03..53257deb 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -53,7 +53,7 @@ export function EmptyAvatar(props: { size?: number; multi?: boolean }) { return ( <div - className={`flex h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`} + className={`flex flex-shrink-0 h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`} > <Icon className={`h-${insize} w-${insize} text-gray-500`} aria-hidden /> </div> diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index ed02128e..c327d8af 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -224,7 +224,7 @@ export function FeedComment(props: { return ( <Row className={clsx( - 'flex space-x-1.5 transition-all duration-1000 sm:space-x-3', + 'flex space-x-1.5 sm:space-x-3', highlighted ? `-m-1 rounded bg-indigo-500/[0.2] p-2` : '' )} > diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 539573dd..98b0f2fd 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -39,17 +39,19 @@ export function groupNotifications(notifications: Notification[]) { ) Object.keys(notificationGroupsByDay).forEach((day) => { const notificationsGroupedByDay = notificationGroupsByDay[day] - const bonusNotifications = notificationsGroupedByDay.filter( - (notification) => notification.sourceType === 'bonus' + const incomeNotifications = notificationsGroupedByDay.filter( + (notification) => + notification.sourceType === 'bonus' || notification.sourceType === 'tip' ) const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter( - (notification) => notification.sourceType !== 'bonus' + (notification) => + notification.sourceType !== 'bonus' && notification.sourceType !== 'tip' ) - if (bonusNotifications.length > 0) { + if (incomeNotifications.length > 0) { notificationGroups = notificationGroups.concat({ - notifications: bonusNotifications, + notifications: incomeNotifications, groupedById: 'income' + day, - isSeen: bonusNotifications[0].isSeen, + isSeen: incomeNotifications[0].isSeen, timePeriod: day, type: 'income', }) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 2c6c2433..45ca234a 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,7 +1,7 @@ import { Tabs } from 'web/components/layout/tabs' import { useUser } from 'web/hooks/use-user' import React, { useEffect, useState } from 'react' -import { Notification } from 'common/notification' +import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' @@ -34,10 +34,10 @@ import toast from 'react-hot-toast' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' -import { groupBy } from 'lodash' +import { groupBy, sum, uniq } from 'lodash' export const NOTIFICATIONS_PER_PAGE = 30 -export const HIGHLIGHT_DURATION = 30 * 1000 +const MULTIPLE_USERS_KEY = 'multipleUsers' export default function Notifications() { const user = useUser() @@ -187,16 +187,12 @@ function IncomeNotificationGroupItem(props: { const { notificationGroup, className } = props const { notifications } = notificationGroup const numSummaryLines = 3 - const [expanded, setExpanded] = useState(false) - const [highlighted, setHighlighted] = useState(false) + const [highlighted, setHighlighted] = useState( + notifications.some((n) => !n.isSeen) + ) + useEffect(() => { - if (notifications.some((n) => !n.isSeen)) { - setHighlighted(true) - setTimeout(() => { - setHighlighted(false) - }, HIGHLIGHT_DURATION) - } setNotificationsAsSeen(notifications) }, [notifications]) @@ -204,51 +200,62 @@ function IncomeNotificationGroupItem(props: { if (expanded) setHighlighted(false) }, [expanded]) - const totalIncome = notifications.reduce( - (acc, notification) => - acc + - (notification.sourceType && - notification.sourceText && - notification.sourceType === 'bonus' - ? parseInt(notification.sourceText) - : 0), - 0 + const totalIncome = sum( + notifications.map((notification) => + notification.sourceText ? parseInt(notification.sourceText) : 0 + ) ) - // loop through the contracts and combine the notification items into one - function combineNotificationsByAddingSourceTextsAndReturningTheRest( + // Loop through the contracts and combine the notification items into one + function combineNotificationsByAddingNumericSourceTexts( notifications: Notification[] ) { const newNotifications = [] - const groupedNotificationsByContractId = groupBy( + const groupedNotificationsBySourceType = groupBy( notifications, - (notification) => { - return notification.sourceContractId - } + (n) => n.sourceType ) - for (const contractId in groupedNotificationsByContractId) { - const notificationsForContractId = - groupedNotificationsByContractId[contractId] - let sum = 0 - notificationsForContractId.forEach( - (notification) => - notification.sourceText && - (sum = parseInt(notification.sourceText) + sum) + for (const sourceType in groupedNotificationsBySourceType) { + const groupedNotificationsByContractId = groupBy( + groupedNotificationsBySourceType[sourceType], + (notification) => { + return notification.sourceContractId + } ) + for (const contractId in groupedNotificationsByContractId) { + const notificationsForContractId = + groupedNotificationsByContractId[contractId] + if (notificationsForContractId.length === 1) { + newNotifications.push(notificationsForContractId[0]) + continue + } + let sum = 0 + notificationsForContractId.forEach( + (notification) => + notification.sourceText && + (sum = parseInt(notification.sourceText) + sum) + ) + const uniqueUsers = uniq( + notificationsForContractId.map((notification) => { + return notification.sourceUserUsername + }) + ) - const newNotification = - notificationsForContractId.length === 1 - ? notificationsForContractId[0] - : { - ...notificationsForContractId[0], - sourceText: sum.toString(), - } - newNotifications.push(newNotification) + const newNotification = { + ...notificationsForContractId[0], + sourceText: sum.toString(), + sourceUserUsername: + uniqueUsers.length > 1 + ? MULTIPLE_USERS_KEY + : notificationsForContractId[0].sourceType, + } + newNotifications.push(newNotification) + } } return newNotifications } const combinedNotifs = - combineNotificationsByAddingSourceTextsAndReturningTheRest(notifications) + combineNotificationsByAddingNumericSourceTexts(notifications) return ( <div @@ -286,53 +293,23 @@ function IncomeNotificationGroupItem(props: { <div> <div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}> {' '} - <div className={'line-clamp-4 mt-1 ml-1 gap-1 whitespace-pre-line'}> + <div + className={clsx( + 'mt-1 ml-1 gap-1 whitespace-pre-line', + !expanded ? 'line-clamp-4' : '' + )} + > {!expanded ? ( <> {combinedNotifs .slice(0, numSummaryLines) - .map((notification) => { - return ( - <Row - className={ - 'items-center text-sm text-gray-500 sm:justify-start' - } - key={notification.id} - > - <div - className={ - 'line-clamp-1 flex-1 overflow-hidden sm:flex' - } - > - <div className={'flex pl-1 sm:pl-0'}> - <div - className={ - 'inline-flex overflow-hidden text-ellipsis pl-1' - } - > - <div className={'mr-1 text-black'}> - <NotificationTextLabel - contract={null} - defaultText={notification.sourceText ?? ''} - className={'line-clamp-1'} - notification={notification} - justSummary={true} - /> - </div> - <span className={'flex-shrink-0'}> - {getReasonForShowingNotification( - notification, - true - )} - {` on`} - <NotificationLink notification={notification} /> - </span> - </div> - </div> - </div> - </Row> - ) - })} + .map((notification) => ( + <IncomeNotificationItem + notification={notification} + justSummary={true} + key={notification.id} + /> + ))} <div className={'text-sm text-gray-500 hover:underline '}> {combinedNotifs.length - numSummaryLines > 0 ? 'And ' + @@ -344,7 +321,7 @@ function IncomeNotificationGroupItem(props: { ) : ( <> {combinedNotifs.map((notification) => ( - <NotificationItem + <IncomeNotificationItem notification={notification} key={notification.id} justSummary={false} @@ -361,28 +338,130 @@ function IncomeNotificationGroupItem(props: { ) } +function IncomeNotificationItem(props: { + notification: Notification + justSummary?: boolean +}) { + const { notification, justSummary } = props + const { + sourceType, + sourceUserName, + reason, + sourceUserUsername, + createdTime, + } = notification + const [highlighted] = useState(!notification.isSeen) + + useEffect(() => { + setNotificationsAsSeen([notification]) + }, [notification]) + + function getReasonForShowingIncomeNotification(simple: boolean) { + const { sourceText } = notification + let reasonText = '' + if (sourceType === 'bonus' && sourceText) { + reasonText = !simple + ? `bonus for ${ + parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT + } unique bettors` + : ' bonus for unique bettors on' + } else if (sourceType === 'tip') { + reasonText = !simple ? `tipped you` : `in tips on` + } + return <span className={'flex-shrink-0'}>{reasonText}</span> + } + + if (justSummary) { + return ( + <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> + <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> + <div className={'flex pl-1 sm:pl-0'}> + <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> + <div className={'mr-1 text-black'}> + <NotificationTextLabel + contract={null} + defaultText={notification.sourceText ?? ''} + className={'line-clamp-1'} + notification={notification} + justSummary={true} + /> + </div> + <span className={'flex truncate'}> + {getReasonForShowingIncomeNotification(true)} + <NotificationLink notification={notification} /> + </span> + </div> + </div> + </div> + </Row> + ) + } + + return ( + <div + className={clsx( + 'bg-white px-2 pt-6 text-sm sm:px-4', + highlighted && 'bg-indigo-200 hover:bg-indigo-100' + )} + > + <a href={getSourceUrl(notification)}> + <Row className={'items-center text-gray-500 sm:justify-start'}> + <div className={'flex max-w-xl shrink '}> + {sourceType && reason && ( + <div className={'inline'}> + <span className={'mr-1'}> + <NotificationTextLabel + contract={null} + defaultText={notification.sourceText ?? ''} + notification={notification} + /> + </span> + + {sourceType != 'bonus' && + (sourceUserUsername === MULTIPLE_USERS_KEY ? ( + <span className={'mr-1 truncate'}>Multiple users</span> + ) : ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-1 flex-shrink-0'} + justFirstName={true} + /> + ))} + </div> + )} + {getReasonForShowingIncomeNotification(false)} + <span className={'ml-1 flex hidden sm:inline-block'}> + on + <NotificationLink notification={notification} /> + </span> + <RelativeTimestamp time={createdTime} /> + </div> + </Row> + <span className={'flex truncate text-gray-500 sm:hidden'}> + on + <NotificationLink notification={notification} /> + </span> + <div className={'mt-4 border-b border-gray-300'} /> + </a> + </div> + ) +} + function NotificationGroupItem(props: { notificationGroup: NotificationGroup className?: string }) { const { notificationGroup, className } = props const { notifications } = notificationGroup - const { - sourceContractTitle, - sourceContractSlug, - sourceContractCreatorUsername, - } = notifications[0] + const { sourceContractTitle } = notifications[0] const numSummaryLines = 3 const [expanded, setExpanded] = useState(false) - const [highlighted, setHighlighted] = useState(false) + const [highlighted, setHighlighted] = useState( + notifications.some((n) => !n.isSeen) + ) useEffect(() => { - if (notifications.some((n) => !n.isSeen)) { - setHighlighted(true) - setTimeout(() => { - setHighlighted(false) - }, HIGHLIGHT_DURATION) - } setNotificationsAsSeen(notifications) }, [notifications]) @@ -408,27 +487,18 @@ function NotificationGroupItem(props: { )} <Row className={'items-center text-gray-500 sm:justify-start'}> <EmptyAvatar multi /> - <div className={'flex-1 overflow-hidden pl-2 sm:flex'}> + <div className={'flex truncate pl-2'}> <div onClick={() => setExpanded(!expanded)} - className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'} + className={' flex cursor-pointer truncate pl-1 sm:pl-0'} > {sourceContractTitle ? ( - <span> - {'Activity on '} - <a - href={ - sourceContractCreatorUsername - ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` - : '' - } - className={ - 'font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' - } - > - {sourceContractTitle} - </a> - </span> + <> + <span className={'flex-shrink-0'}>{'Activity on '}</span> + <span className={'truncate'}> + <NotificationLink notification={notifications[0]} /> + </span> + </> ) : ( 'Other activity' )} @@ -439,7 +509,13 @@ function NotificationGroupItem(props: { <div> <div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}> {' '} - <div className={'line-clamp-4 mt-1 ml-1 gap-1 whitespace-pre-line'}> + <div + className={clsx( + 'mt-1 ml-1 gap-1 whitespace-pre-line', + !expanded ? 'line-clamp-4' : '' + )} + > + {' '} {!expanded ? ( <> {notifications.slice(0, numSummaryLines).map((notification) => { @@ -466,6 +542,7 @@ function NotificationGroupItem(props: { notification={notification} key={notification.id} justSummary={false} + hideTitle={true} /> ))} </> @@ -695,7 +772,7 @@ function NotificationLink(props: { notification: Notification }) { : '' } className={ - 'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' + 'ml-1 inline max-w-xs truncate font-bold text-gray-500 hover:underline hover:decoration-indigo-400 hover:decoration-2 sm:max-w-sm' } > {sourceContractTitle || sourceTitle} @@ -703,11 +780,54 @@ function NotificationLink(props: { notification: Notification }) { ) } +function getSourceUrl(notification: Notification) { + const { + sourceType, + sourceId, + sourceUserUsername, + sourceContractCreatorUsername, + sourceContractSlug, + sourceSlug, + } = notification + if (sourceType === 'follow') return `/${sourceUserUsername}` + if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}` + if ( + sourceContractCreatorUsername && + sourceContractSlug && + sourceType === 'user' + ) + return `/${sourceContractCreatorUsername}/${sourceContractSlug}` + if (sourceType === 'tip') + return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` + if (sourceContractCreatorUsername && sourceContractSlug) + return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( + sourceId ?? '', + sourceType + )}` +} + +function getSourceIdForLinkComponent( + sourceId: string, + sourceType?: notification_source_types +) { + switch (sourceType) { + case 'answer': + return `answer-${sourceId}` + case 'comment': + return sourceId + case 'contract': + return '' + default: + return sourceId + } +} + function NotificationItem(props: { notification: Notification justSummary?: boolean + hideTitle?: boolean }) { - const { notification, justSummary } = props + const { notification, justSummary, hideTitle } = props const { sourceType, sourceId, @@ -721,7 +841,6 @@ function NotificationItem(props: { sourceText, sourceContractCreatorUsername, sourceContractSlug, - sourceSlug, } = notification const [defaultNotificationText, setDefaultNotificationText] = @@ -736,48 +855,12 @@ function NotificationItem(props: { } }, [reasonText, sourceText]) - const [highlighted, setHighlighted] = useState(false) - useEffect(() => { - if (!notification.isSeen) { - setHighlighted(true) - setTimeout(() => { - setHighlighted(false) - }, HIGHLIGHT_DURATION) - } - }, [notification.isSeen]) + const [highlighted] = useState(!notification.isSeen) useEffect(() => { setNotificationsAsSeen([notification]) }, [notification]) - function getSourceUrl() { - if (sourceType === 'follow') return `/${sourceUserUsername}` - if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}` - if ( - sourceContractCreatorUsername && - sourceContractSlug && - sourceType === 'user' - ) - return `/${sourceContractCreatorUsername}/${sourceContractSlug}` - if (sourceContractCreatorUsername && sourceContractSlug) - return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( - sourceId ?? '' - )}` - } - - function getSourceIdForLinkComponent(sourceId: string) { - switch (sourceType) { - case 'answer': - return `answer-${sourceId}` - case 'comment': - return sourceId - case 'contract': - return '' - default: - return sourceId - } - } - if (justSummary) { return ( <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> @@ -793,10 +876,7 @@ function NotificationItem(props: { <span className={'flex-shrink-0'}> {sourceType && reason && - getReasonForShowingNotification(notification, true).replace( - ' on', - '' - )} + getReasonForShowingNotification(notification, true, true)} </span> <div className={'ml-1 text-black'}> <NotificationTextLabel @@ -821,25 +901,21 @@ function NotificationItem(props: { highlighted && 'bg-indigo-200 hover:bg-indigo-100' )} > - <a href={getSourceUrl()}> + <a href={getSourceUrl(notification)}> <Row className={'items-center text-gray-500 sm:justify-start'}> - {sourceType != 'bonus' ? ( - <Avatar - avatarUrl={sourceUserAvatarUrl} - size={'sm'} - className={'mr-2'} - username={sourceUserName} - /> - ) : ( - <TrendingUpIcon className={'text-primary h-7 w-7'} /> - )} + <Avatar + avatarUrl={sourceUserAvatarUrl} + size={'sm'} + className={'mr-2'} + username={sourceUserName} + /> <div className={'flex-1 overflow-hidden sm:flex'}> <div className={ 'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0' } > - {sourceType != 'bonus' && sourceUpdateType != 'closed' && ( + {sourceUpdateType != 'closed' && ( <UserLink name={sourceUserName || ''} username={sourceUserUsername || ''} @@ -847,26 +923,30 @@ function NotificationItem(props: { justFirstName={true} /> )} - <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> - {sourceType && reason && ( - <div className={'inline truncate'}> - {getReasonForShowingNotification(notification, false)} + {sourceType && reason && ( + <div className={'inline flex truncate'}> + <span className={'ml-1 flex-shrink-0'}> + {getReasonForShowingNotification(notification, false, true)} + </span> + {!hideTitle && ( <NotificationLink notification={notification} /> - </div> - )} - </div> + )} + </div> + )} + {sourceId && + sourceContractSlug && + sourceContractCreatorUsername ? ( + <CopyLinkDateTimeComponent + prefix={sourceContractCreatorUsername} + slug={sourceContractSlug} + createdTime={createdTime} + elementId={getSourceIdForLinkComponent(sourceId)} + className={'-mx-1 inline-flex sm:inline-block'} + /> + ) : ( + <RelativeTimestamp time={createdTime} /> + )} </div> - {sourceId && sourceContractSlug && sourceContractCreatorUsername ? ( - <CopyLinkDateTimeComponent - prefix={sourceContractCreatorUsername} - slug={sourceContractSlug} - createdTime={createdTime} - elementId={getSourceIdForLinkComponent(sourceId)} - className={'-mx-1 inline-flex sm:inline-block'} - /> - ) : ( - <RelativeTimestamp time={createdTime} /> - )} </div> </Row> <div className={'mt-1 ml-1 md:text-base'}> @@ -938,9 +1018,11 @@ function NotificationTextLabel(props: { return ( <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> ) - } else if (sourceType === 'bonus' && sourceText) { + } else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) { return ( - <span className="text-primary">{formatMoney(parseInt(sourceText))}</span> + <span className="text-primary"> + {'+' + formatMoney(parseInt(sourceText))} + </span> ) } // return default text @@ -953,19 +1035,19 @@ function NotificationTextLabel(props: { function getReasonForShowingNotification( notification: Notification, - simple?: boolean + simple?: boolean, + replaceOn?: boolean ) { - const { sourceType, sourceUpdateType, sourceText, reason, sourceSlug } = - notification + const { sourceType, sourceUpdateType, reason, sourceSlug } = notification let reasonText: string switch (sourceType) { case 'comment': if (reason === 'reply_to_users_answer') - reasonText = !simple ? 'replied to your answer on' : 'replied' + reasonText = !simple ? 'replied to you on' : 'replied' else if (reason === 'tagged_user') - reasonText = !simple ? 'tagged you in a comment on' : 'tagged you' + reasonText = !simple ? 'tagged you on' : 'tagged you' else if (reason === 'reply_to_users_comment') - reasonText = !simple ? 'replied to your comment on' : 'replied' + reasonText = !simple ? 'replied to you on' : 'replied' else if (reason === 'on_users_contract') reasonText = !simple ? `commented on your question` : 'commented' else if (reason === 'on_contract_with_users_comment') @@ -973,7 +1055,7 @@ function getReasonForShowingNotification( else if (reason === 'on_contract_with_users_answer') reasonText = `commented on` else if (reason === 'on_contract_with_users_shares_in') - reasonText = `commented` + reasonText = `commented on` else reasonText = `commented on` break case 'contract': @@ -1008,17 +1090,13 @@ function getReasonForShowingNotification( else if (sourceSlug) reasonText = 'joined because you shared' else reasonText = 'joined because of you' break - case 'bonus': - if (reason === 'unique_bettors_on_your_contract' && sourceText) - reasonText = !simple - ? `You had ${ - parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT - } unique bettors on` - : ' for unique bettors' - else reasonText = 'You earned your daily manna' - break default: reasonText = '' } - return reasonText + + return ( + <span className={'flex-shrink-0'}> + {replaceOn ? reasonText.replace(' on', '') : reasonText} + </span> + ) } From 54b4f97a84a61b9f43e8f658a69f3cbb97e422c9 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 6 Jul 2022 13:45:31 -0600 Subject: [PATCH 102/220] Move timestamp to same line --- web/pages/notifications.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 45ca234a..54dbdd09 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -275,7 +275,7 @@ function IncomeNotificationGroupItem(props: { )} <Row className={'items-center text-gray-500 sm:justify-start'}> <TrendingUpIcon className={'text-primary h-7 w-7'} /> - <div className={'flex-1 overflow-hidden pl-2 sm:flex'}> + <div className={'flex truncate'}> <div onClick={() => setExpanded(!expanded)} className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'} @@ -286,8 +286,8 @@ function IncomeNotificationGroupItem(props: { {'+' + formatMoney(totalIncome)} </span> </span> + <RelativeTimestamp time={notifications[0].createdTime} /> </div> - <RelativeTimestamp time={notifications[0].createdTime} /> </div> </Row> <div> From e969540c72dab78770df9941e3af12adc0941106 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 6 Jul 2022 15:06:41 -0600 Subject: [PATCH 103/220] Slight notifications refactor --- web/pages/notifications.tsx | 726 ++++++++++++++++++------------------ 1 file changed, 363 insertions(+), 363 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 54dbdd09..382505e2 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -166,20 +166,6 @@ export default function Notifications() { ) } -export const setNotificationsAsSeen = (notifications: Notification[]) => { - notifications.forEach((notification) => { - if (!notification.isSeen) - updateDoc( - doc(db, `users/${notification.userId}/notifications/`, notification.id), - { - isSeen: true, - viewTime: new Date(), - } - ) - }) - return notifications -} - function IncomeNotificationGroupItem(props: { notificationGroup: NotificationGroup className?: string @@ -556,6 +542,369 @@ function NotificationGroupItem(props: { ) } +function NotificationItem(props: { + notification: Notification + justSummary?: boolean + hideTitle?: boolean +}) { + const { notification, justSummary, hideTitle } = props + const { + sourceType, + sourceId, + sourceUserName, + sourceUserAvatarUrl, + sourceUpdateType, + reasonText, + reason, + sourceUserUsername, + createdTime, + sourceText, + sourceContractCreatorUsername, + sourceContractSlug, + } = notification + + const [defaultNotificationText, setDefaultNotificationText] = + useState<string>('') + + useEffect(() => { + if (sourceText) { + setDefaultNotificationText(sourceText) + } else if (reasonText) { + // Handle arbitrary notifications with reason text here. + setDefaultNotificationText(reasonText) + } + }, [reasonText, sourceText]) + + const [highlighted] = useState(!notification.isSeen) + + useEffect(() => { + setNotificationsAsSeen([notification]) + }, [notification]) + + if (justSummary) { + return ( + <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> + <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> + <div className={'flex pl-1 sm:pl-0'}> + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-0 flex-shrink-0'} + justFirstName={true} + /> + <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> + <span className={'flex-shrink-0'}> + {sourceType && + reason && + getReasonForShowingNotification(notification, true, true)} + </span> + <div className={'ml-1 text-black'}> + <NotificationTextLabel + contract={null} + defaultText={defaultNotificationText} + className={'line-clamp-1'} + notification={notification} + justSummary={true} + /> + </div> + </div> + </div> + </div> + </Row> + ) + } + + return ( + <div + className={clsx( + 'bg-white px-2 pt-6 text-sm sm:px-4', + highlighted && 'bg-indigo-200 hover:bg-indigo-100' + )} + > + <a href={getSourceUrl(notification)}> + <Row className={'items-center text-gray-500 sm:justify-start'}> + <Avatar + avatarUrl={sourceUserAvatarUrl} + size={'sm'} + className={'mr-2'} + username={sourceUserName} + /> + <div className={'flex-1 overflow-hidden sm:flex'}> + <div + className={ + 'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0' + } + > + {sourceUpdateType != 'closed' && ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-0 flex-shrink-0'} + justFirstName={true} + /> + )} + {sourceType && reason && ( + <div className={'inline flex truncate'}> + <span className={'ml-1 flex-shrink-0'}> + {getReasonForShowingNotification(notification, false, true)} + </span> + {!hideTitle && ( + <NotificationLink notification={notification} /> + )} + </div> + )} + {sourceId && + sourceContractSlug && + sourceContractCreatorUsername ? ( + <CopyLinkDateTimeComponent + prefix={sourceContractCreatorUsername} + slug={sourceContractSlug} + createdTime={createdTime} + elementId={getSourceIdForLinkComponent(sourceId)} + className={'-mx-1 inline-flex sm:inline-block'} + /> + ) : ( + <RelativeTimestamp time={createdTime} /> + )} + </div> + </div> + </Row> + <div className={'mt-1 ml-1 md:text-base'}> + <NotificationTextLabel + contract={null} + defaultText={defaultNotificationText} + notification={notification} + /> + </div> + + <div className={'mt-6 border-b border-gray-300'} /> + </a> + </div> + ) +} + +export const setNotificationsAsSeen = (notifications: Notification[]) => { + notifications.forEach((notification) => { + if (!notification.isSeen) + updateDoc( + doc(db, `users/${notification.userId}/notifications/`, notification.id), + { + isSeen: true, + viewTime: new Date(), + } + ) + }) + return notifications +} + +function NotificationLink(props: { notification: Notification }) { + const { notification } = props + const { + sourceType, + sourceContractTitle, + sourceContractCreatorUsername, + sourceContractSlug, + sourceSlug, + sourceTitle, + } = notification + return ( + <a + href={ + sourceContractCreatorUsername + ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` + : sourceType === 'group' && sourceSlug + ? `${groupPath(sourceSlug)}` + : '' + } + className={ + 'ml-1 inline max-w-xs truncate font-bold text-gray-500 hover:underline hover:decoration-indigo-400 hover:decoration-2 sm:max-w-sm' + } + > + {sourceContractTitle || sourceTitle} + </a> + ) +} + +function getSourceUrl(notification: Notification) { + const { + sourceType, + sourceId, + sourceUserUsername, + sourceContractCreatorUsername, + sourceContractSlug, + sourceSlug, + } = notification + if (sourceType === 'follow') return `/${sourceUserUsername}` + if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}` + if ( + sourceContractCreatorUsername && + sourceContractSlug && + sourceType === 'user' + ) + return `/${sourceContractCreatorUsername}/${sourceContractSlug}` + if (sourceType === 'tip') + return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` + if (sourceContractCreatorUsername && sourceContractSlug) + return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( + sourceId ?? '', + sourceType + )}` +} + +function getSourceIdForLinkComponent( + sourceId: string, + sourceType?: notification_source_types +) { + switch (sourceType) { + case 'answer': + return `answer-${sourceId}` + case 'comment': + return sourceId + case 'contract': + return '' + default: + return sourceId + } +} + +function NotificationTextLabel(props: { + defaultText: string + contract?: Contract | null + notification: Notification + className?: string + justSummary?: boolean +}) { + const { contract, className, defaultText, notification, justSummary } = props + const { sourceUpdateType, sourceType, sourceText, sourceContractTitle } = + notification + if (sourceType === 'contract') { + if (justSummary) + return <span>{contract?.question || sourceContractTitle}</span> + if (!sourceText) return <div /> + // Resolved contracts + if (sourceType === 'contract' && sourceUpdateType === 'resolved') { + { + if (sourceText === 'YES' || sourceText == 'NO') { + return <BinaryOutcomeLabel outcome={sourceText as any} /> + } + if (sourceText.includes('%')) + return ( + <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} /> + ) + if (sourceText === 'CANCEL') return <CancelLabel /> + if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel /> + } + } + // Close date will be a number - it looks better without it + if (sourceUpdateType === 'closed') { + return <div /> + } + // Updated contracts + // Description will be in default text + if (parseInt(sourceText) > 0) { + return ( + <span> + Updated close time: {new Date(parseInt(sourceText)).toLocaleString()} + </span> + ) + } + } else if (sourceType === 'user' && sourceText) { + return ( + <span> + As a thank you, we sent you{' '} + <span className="text-primary"> + {formatMoney(parseInt(sourceText))} + </span> + ! + </span> + ) + } else if (sourceType === 'liquidity' && sourceText) { + return ( + <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> + ) + } else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) { + return ( + <span className="text-primary"> + {'+' + formatMoney(parseInt(sourceText))} + </span> + ) + } + // return default text + return ( + <div className={className ? className : 'line-clamp-4 whitespace-pre-line'}> + <Linkify text={defaultText} /> + </div> + ) +} + +function getReasonForShowingNotification( + notification: Notification, + simple?: boolean, + replaceOn?: boolean +) { + const { sourceType, sourceUpdateType, reason, sourceSlug } = notification + let reasonText: string + switch (sourceType) { + case 'comment': + if (reason === 'reply_to_users_answer') + reasonText = !simple ? 'replied to you on' : 'replied' + else if (reason === 'tagged_user') + reasonText = !simple ? 'tagged you on' : 'tagged you' + else if (reason === 'reply_to_users_comment') + reasonText = !simple ? 'replied to you on' : 'replied' + else if (reason === 'on_users_contract') + reasonText = !simple ? `commented on your question` : 'commented' + else if (reason === 'on_contract_with_users_comment') + reasonText = `commented on` + else if (reason === 'on_contract_with_users_answer') + reasonText = `commented on` + else if (reason === 'on_contract_with_users_shares_in') + reasonText = `commented on` + else reasonText = `commented on` + break + case 'contract': + if (reason === 'you_follow_user') reasonText = 'asked' + else if (sourceUpdateType === 'resolved') reasonText = `resolved` + else if (sourceUpdateType === 'closed') + reasonText = `Please resolve your question` + else reasonText = `updated` + break + case 'answer': + if (reason === 'on_users_contract') reasonText = `answered your question ` + else if (reason === 'on_contract_with_users_comment') + reasonText = `answered` + else if (reason === 'on_contract_with_users_answer') + reasonText = `answered` + else if (reason === 'on_contract_with_users_shares_in') + reasonText = `answered` + else reasonText = `answered` + break + case 'follow': + reasonText = 'followed you' + break + case 'liquidity': + reasonText = 'added liquidity to your question' + break + case 'group': + reasonText = 'added you to the group' + break + case 'user': + if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') + reasonText = 'joined to bet on your market' + else if (sourceSlug) reasonText = 'joined because you shared' + else reasonText = 'joined because of you' + break + default: + reasonText = '' + } + + return ( + <span className={'flex-shrink-0'}> + {replaceOn ? reasonText.replace(' on', '') : reasonText} + </span> + ) +} + // TODO: where should we put referral bonus notifications? function NotificationSettings() { const user = useUser() @@ -751,352 +1100,3 @@ function NotificationSettings() { </div> ) } - -function NotificationLink(props: { notification: Notification }) { - const { notification } = props - const { - sourceType, - sourceContractTitle, - sourceContractCreatorUsername, - sourceContractSlug, - sourceSlug, - sourceTitle, - } = notification - return ( - <a - href={ - sourceContractCreatorUsername - ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` - : sourceType === 'group' && sourceSlug - ? `${groupPath(sourceSlug)}` - : '' - } - className={ - 'ml-1 inline max-w-xs truncate font-bold text-gray-500 hover:underline hover:decoration-indigo-400 hover:decoration-2 sm:max-w-sm' - } - > - {sourceContractTitle || sourceTitle} - </a> - ) -} - -function getSourceUrl(notification: Notification) { - const { - sourceType, - sourceId, - sourceUserUsername, - sourceContractCreatorUsername, - sourceContractSlug, - sourceSlug, - } = notification - if (sourceType === 'follow') return `/${sourceUserUsername}` - if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}` - if ( - sourceContractCreatorUsername && - sourceContractSlug && - sourceType === 'user' - ) - return `/${sourceContractCreatorUsername}/${sourceContractSlug}` - if (sourceType === 'tip') - return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` - if (sourceContractCreatorUsername && sourceContractSlug) - return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( - sourceId ?? '', - sourceType - )}` -} - -function getSourceIdForLinkComponent( - sourceId: string, - sourceType?: notification_source_types -) { - switch (sourceType) { - case 'answer': - return `answer-${sourceId}` - case 'comment': - return sourceId - case 'contract': - return '' - default: - return sourceId - } -} - -function NotificationItem(props: { - notification: Notification - justSummary?: boolean - hideTitle?: boolean -}) { - const { notification, justSummary, hideTitle } = props - const { - sourceType, - sourceId, - sourceUserName, - sourceUserAvatarUrl, - sourceUpdateType, - reasonText, - reason, - sourceUserUsername, - createdTime, - sourceText, - sourceContractCreatorUsername, - sourceContractSlug, - } = notification - - const [defaultNotificationText, setDefaultNotificationText] = - useState<string>('') - - useEffect(() => { - if (sourceText) { - setDefaultNotificationText(sourceText) - } else if (reasonText) { - // Handle arbitrary notifications with reason text here. - setDefaultNotificationText(reasonText) - } - }, [reasonText, sourceText]) - - const [highlighted] = useState(!notification.isSeen) - - useEffect(() => { - setNotificationsAsSeen([notification]) - }, [notification]) - - if (justSummary) { - return ( - <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> - <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> - <div className={'flex pl-1 sm:pl-0'}> - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - justFirstName={true} - /> - <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> - <span className={'flex-shrink-0'}> - {sourceType && - reason && - getReasonForShowingNotification(notification, true, true)} - </span> - <div className={'ml-1 text-black'}> - <NotificationTextLabel - contract={null} - defaultText={defaultNotificationText} - className={'line-clamp-1'} - notification={notification} - justSummary={true} - /> - </div> - </div> - </div> - </div> - </Row> - ) - } - - return ( - <div - className={clsx( - 'bg-white px-2 pt-6 text-sm sm:px-4', - highlighted && 'bg-indigo-200 hover:bg-indigo-100' - )} - > - <a href={getSourceUrl(notification)}> - <Row className={'items-center text-gray-500 sm:justify-start'}> - <Avatar - avatarUrl={sourceUserAvatarUrl} - size={'sm'} - className={'mr-2'} - username={sourceUserName} - /> - <div className={'flex-1 overflow-hidden sm:flex'}> - <div - className={ - 'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0' - } - > - {sourceUpdateType != 'closed' && ( - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - justFirstName={true} - /> - )} - {sourceType && reason && ( - <div className={'inline flex truncate'}> - <span className={'ml-1 flex-shrink-0'}> - {getReasonForShowingNotification(notification, false, true)} - </span> - {!hideTitle && ( - <NotificationLink notification={notification} /> - )} - </div> - )} - {sourceId && - sourceContractSlug && - sourceContractCreatorUsername ? ( - <CopyLinkDateTimeComponent - prefix={sourceContractCreatorUsername} - slug={sourceContractSlug} - createdTime={createdTime} - elementId={getSourceIdForLinkComponent(sourceId)} - className={'-mx-1 inline-flex sm:inline-block'} - /> - ) : ( - <RelativeTimestamp time={createdTime} /> - )} - </div> - </div> - </Row> - <div className={'mt-1 ml-1 md:text-base'}> - <NotificationTextLabel - contract={null} - defaultText={defaultNotificationText} - notification={notification} - /> - </div> - - <div className={'mt-6 border-b border-gray-300'} /> - </a> - </div> - ) -} - -function NotificationTextLabel(props: { - defaultText: string - contract?: Contract | null - notification: Notification - className?: string - justSummary?: boolean -}) { - const { contract, className, defaultText, notification, justSummary } = props - const { sourceUpdateType, sourceType, sourceText, sourceContractTitle } = - notification - if (sourceType === 'contract') { - if (justSummary) - return <span>{contract?.question || sourceContractTitle}</span> - if (!sourceText) return <div /> - // Resolved contracts - if (sourceType === 'contract' && sourceUpdateType === 'resolved') { - { - if (sourceText === 'YES' || sourceText == 'NO') { - return <BinaryOutcomeLabel outcome={sourceText as any} /> - } - if (sourceText.includes('%')) - return ( - <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} /> - ) - if (sourceText === 'CANCEL') return <CancelLabel /> - if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel /> - } - } - // Close date will be a number - it looks better without it - if (sourceUpdateType === 'closed') { - return <div /> - } - // Updated contracts - // Description will be in default text - if (parseInt(sourceText) > 0) { - return ( - <span> - Updated close time: {new Date(parseInt(sourceText)).toLocaleString()} - </span> - ) - } - } else if (sourceType === 'user' && sourceText) { - return ( - <span> - As a thank you, we sent you{' '} - <span className="text-primary"> - {formatMoney(parseInt(sourceText))} - </span> - ! - </span> - ) - } else if (sourceType === 'liquidity' && sourceText) { - return ( - <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> - ) - } else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) { - return ( - <span className="text-primary"> - {'+' + formatMoney(parseInt(sourceText))} - </span> - ) - } - // return default text - return ( - <div className={className ? className : 'line-clamp-4 whitespace-pre-line'}> - <Linkify text={defaultText} /> - </div> - ) -} - -function getReasonForShowingNotification( - notification: Notification, - simple?: boolean, - replaceOn?: boolean -) { - const { sourceType, sourceUpdateType, reason, sourceSlug } = notification - let reasonText: string - switch (sourceType) { - case 'comment': - if (reason === 'reply_to_users_answer') - reasonText = !simple ? 'replied to you on' : 'replied' - else if (reason === 'tagged_user') - reasonText = !simple ? 'tagged you on' : 'tagged you' - else if (reason === 'reply_to_users_comment') - reasonText = !simple ? 'replied to you on' : 'replied' - else if (reason === 'on_users_contract') - reasonText = !simple ? `commented on your question` : 'commented' - else if (reason === 'on_contract_with_users_comment') - reasonText = `commented on` - else if (reason === 'on_contract_with_users_answer') - reasonText = `commented on` - else if (reason === 'on_contract_with_users_shares_in') - reasonText = `commented on` - else reasonText = `commented on` - break - case 'contract': - if (reason === 'you_follow_user') reasonText = 'asked' - else if (sourceUpdateType === 'resolved') reasonText = `resolved` - else if (sourceUpdateType === 'closed') - reasonText = `Please resolve your question` - else reasonText = `updated` - break - case 'answer': - if (reason === 'on_users_contract') reasonText = `answered your question ` - else if (reason === 'on_contract_with_users_comment') - reasonText = `answered` - else if (reason === 'on_contract_with_users_answer') - reasonText = `answered` - else if (reason === 'on_contract_with_users_shares_in') - reasonText = `answered` - else reasonText = `answered` - break - case 'follow': - reasonText = 'followed you' - break - case 'liquidity': - reasonText = 'added liquidity to your question' - break - case 'group': - reasonText = 'added you to the group' - break - case 'user': - if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') - reasonText = 'joined to bet on your market' - else if (sourceSlug) reasonText = 'joined because you shared' - else reasonText = 'joined because of you' - break - default: - reasonText = '' - } - - return ( - <span className={'flex-shrink-0'}> - {replaceOn ? reasonText.replace(' on', '') : reasonText} - </span> - ) -} From 2591655269ecc300f1b77779a460b882d4f89d56 Mon Sep 17 00:00:00 2001 From: ahalekelly <ahalekelly@gmail.com> Date: Wed, 6 Jul 2022 14:41:13 -0700 Subject: [PATCH 104/220] Fix docs edit link (#624) * Fix docs edit link * Update github links --- docs/docusaurus.config.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 85129d87..0cf5a8f2 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -26,8 +26,7 @@ const config = { docs: { routeBasePath: '/', sidebarPath: require.resolve('./sidebars.js'), - // Please change this to your repo. - editUrl: 'https://github.com/manifoldmarkets/manifold/tree/main/docs/docs', + editUrl: 'https://github.com/manifoldmarkets/manifold/tree/main/docs', remarkPlugins: [math], rehypePlugins: [katex], }, @@ -72,7 +71,7 @@ const config = { label: 'Docs', }, { - href: 'https://github.com/manifoldmarkets/docs', + href: 'https://github.com/manifoldmarkets/manifold/tree/main/docs/docs', label: 'GitHub', position: 'right', }, @@ -116,7 +115,7 @@ const config = { }, { label: 'GitHub', - href: 'https://github.com/manifoldmarkets/docs', + href: 'https://github.com/manifoldmarkets/manifold/', }, ], }, From a23c744c3e1eeda0ada57b779c607555114161aa Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 6 Jul 2022 17:24:53 -0600 Subject: [PATCH 105/220] Small groups UX changes --- web/components/create-question-button.tsx | 14 +- web/components/groups/group-chat.tsx | 3 +- web/lib/firebase/groups.ts | 2 +- web/pages/group/[...slugs]/index.tsx | 154 ++++++++++++++-------- web/pages/notifications.tsx | 4 +- 5 files changed, 113 insertions(+), 64 deletions(-) diff --git a/web/components/create-question-button.tsx b/web/components/create-question-button.tsx index 564beb83..a9161ac6 100644 --- a/web/components/create-question-button.tsx +++ b/web/components/create-question-button.tsx @@ -3,6 +3,8 @@ import clsx from 'clsx' import { firebaseLogin, User } from 'web/lib/firebase/users' import React from 'react' +export const createButtonStyle = + 'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11' export const CreateQuestionButton = (props: { user: User | null | undefined overrideText?: string @@ -12,20 +14,20 @@ export const CreateQuestionButton = (props: { const gradient = 'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700' - const buttonStyle = - 'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0' - const { user, overrideText, className, query } = props return ( - <div className={clsx('aligncenter flex justify-center', className)}> + <div className={clsx('flex justify-center', className)}> {user ? ( <Link href={`/create${query ? query : ''}`} passHref> - <button className={clsx(gradient, buttonStyle)}> + <button className={clsx(gradient, createButtonStyle)}> {overrideText ? overrideText : 'Create a question'} </button> </Link> ) : ( - <button onClick={firebaseLogin} className={clsx(gradient, buttonStyle)}> + <button + onClick={firebaseLogin} + className={clsx(gradient, createButtonStyle)} + > Sign in </button> )} diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 114a9003..13028313 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -36,6 +36,7 @@ export function GroupChat(props: { const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) const [groupedMessages, setGroupedMessages] = useState<Comment[]>([]) const router = useRouter() + const isMember = user && group.memberIds.includes(user?.id) useMemo(() => { // Group messages with createdTime within 2 minutes of each other. @@ -120,7 +121,7 @@ export function GroupChat(props: { ))} {messages.length === 0 && ( <div className="p-2 text-gray-500"> - No messages yet. Why not{' '} + No messages yet. Why not{isMember ? ` ` : ' join and '} <button className={'cursor-pointer font-bold text-gray-700'} onClick={() => focusInput()} diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 04a5bd44..fbb11520 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -22,7 +22,7 @@ export const groups = coll<Group>('groups') export function groupPath( groupSlug: string, - subpath?: 'edit' | 'questions' | 'about' | 'chat' + subpath?: 'edit' | 'questions' | 'about' | 'chat' | 'rankings' ) { return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 8a8bc4c1..b6fcfe38 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -21,7 +21,6 @@ import { User, writeReferralInfo, } from 'web/lib/firebase/users' -import { Spacer } from 'web/components/layout/spacer' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' @@ -36,7 +35,10 @@ import { Linkify } from 'web/components/linkify' import { fromPropz, usePropz } from 'web/hooks/use-propz' import { Tabs } from 'web/components/layout/tabs' import { ContractsGrid } from 'web/components/contract/contracts-list' -import { CreateQuestionButton } from 'web/components/create-question-button' +import { + createButtonStyle, + CreateQuestionButton, +} from 'web/components/create-question-button' import React, { useEffect, useState } from 'react' import { GroupChat } from 'web/components/groups/group-chat' import { LoadingIndicator } from 'web/components/loading-indicator' @@ -45,11 +47,13 @@ import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' import { useCommentsOnGroup } from 'web/hooks/use-comments' -import ShortToggle from 'web/components/widgets/short-toggle' import { ShareIconButton } from 'web/components/share-icon-button' import { REFERRAL_AMOUNT } from 'common/user' import { SiteLink } from 'web/components/site-link' import { ContractSearch } from 'web/components/contract-search' +import clsx from 'clsx' +import { FollowList } from 'web/components/follow-list' +import { SearchIcon } from '@heroicons/react/outline' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -104,7 +108,13 @@ async function toTopUsers(userScores: { [userId: string]: number }) { export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } -const groupSubpages = [undefined, 'chat', 'questions', 'about'] as const +const groupSubpages = [ + undefined, + 'chat', + 'questions', + 'rankings', + 'about', +] as const export default function GroupPage(props: { group: Group | null @@ -178,20 +188,18 @@ export default function GroupPage(props: { const rightSidebar = ( <Col className="mt-6 hidden xl:block"> <JoinOrCreateButton group={group} user={user} isMember={!!isMember} /> - <Spacer h={6} /> - {contracts && ( - <div className={'mt-2'}> - <div className={'my-2 text-gray-500'}>Recent Questions</div> - <ContractsGrid - contracts={contracts - .sort((a, b) => b.createdTime - a.createdTime) - .slice(0, 3)} - hasMore={false} - loadMore={() => {}} - overrideGridClassName={'grid w-full grid-cols-1 gap-4'} - /> - </div> - )} + </Col> + ) + const leaderboard = ( + <Col> + <GroupLeaderboards + traderScores={traderScores} + creatorScores={creatorScores} + topTraders={topTraders} + topCreators={topCreators} + members={members} + user={user} + /> </Col> ) @@ -203,16 +211,6 @@ export default function GroupPage(props: { isCreator={!!isCreator} user={user} /> - <Spacer h={8} /> - - <GroupLeaderboards - traderScores={traderScores} - creatorScores={creatorScores} - topTraders={topTraders} - topCreators={topCreators} - members={members} - user={user} - /> </Col> ) return ( @@ -243,7 +241,15 @@ export default function GroupPage(props: { </Col> <Tabs - defaultIndex={page === 'about' ? 2 : page === 'questions' ? 1 : 0} + defaultIndex={ + page === 'rankings' + ? 2 + : page === 'about' + ? 3 + : page === 'questions' + ? 1 + : 0 + } tabs={[ { title: 'Chat', @@ -287,11 +293,15 @@ export default function GroupPage(props: { ) : ( <LoadingIndicator /> )} - {isMember && <AddContractButton group={group} user={user} />} </div> ), href: groupPath(group.slug, 'questions'), }, + { + title: 'Leaderboards', + content: leaderboard, + href: groupPath(group.slug, 'rankings'), + }, { title: 'About', content: aboutTab, @@ -309,13 +319,16 @@ function JoinOrCreateButton(props: { isMember: boolean }) { const { group, user, isMember } = props - return isMember ? ( - <CreateQuestionButton - user={user} - overrideText={'Add a new question'} - className={'w-48 flex-shrink-0'} - query={`?groupId=${group.id}`} - /> + return user && isMember ? ( + <Row className={'justify-between sm:flex-col sm:justify-center'}> + <CreateQuestionButton + user={user} + overrideText={'Add a new question'} + className={'w-48 flex-shrink-0'} + query={`?groupId=${group.id}`} + /> + <AddContractButton group={group} user={user} /> + </Row> ) : group.anyoneCanJoin ? ( <JoinGroupButton group={group} user={user} /> ) : null @@ -389,11 +402,51 @@ function GroupOverview(props: { </ShareIconButton> </Row> )} + <Col className={'mt-2'}> + <GroupMemberSearch group={group} /> + </Col> </Col> </Col> ) } +function SearchBar(props: { setQuery: (query: string) => void }) { + const { setQuery } = props + const debouncedQuery = debounce(setQuery, 50) + return ( + <div className={'relative'}> + <SearchIcon className={'absolute left-5 top-3.5 h-5 w-5 text-gray-500'} /> + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Find a member" + className="input input-bordered mb-4 w-full pl-12" + /> + </div> + ) +} + +function GroupMemberSearch(props: { group: Group }) { + const [query, setQuery] = useState('') + const members = useMembers(props.group) + + // TODO use find-active-contracts to sort by? + const matches = sortBy(members, [(member) => member.name]).filter( + (m) => + checkAgainstQuery(query, m.name) || checkAgainstQuery(query, m.username) + ) + return ( + <div> + <SearchBar setQuery={setQuery} /> + <Col className={'gap-2'}> + {matches.length > 0 && ( + <FollowList userIds={matches.map((m) => m.id)} /> + )} + </Col> + </div> + ) +} + export function GroupMembersList(props: { group: Group }) { const { group } = props const members = useMembers(group) @@ -449,32 +502,24 @@ function GroupLeaderboards(props: { }) { const { traderScores, creatorScores, members, topTraders, topCreators } = props - const [includeOutsiders, setIncludeOutsiders] = useState(false) // Consider hiding M$0 + // If it's just one member (curator), show all bettors, otherwise just show members return ( <Col> - <Row className="items-center justify-end gap-4 text-gray-500"> - Include all users - <ShortToggle - enabled={includeOutsiders} - setEnabled={setIncludeOutsiders} - /> - </Row> - <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> - {!includeOutsiders ? ( + {members.length > 1 ? ( <> <SortedLeaderboard users={members} scoreFunction={(user) => traderScores[user.id] ?? 0} - title="🏅 Top bettors" + title="🏅 Bettor rankings" header="Profit" /> <SortedLeaderboard users={members} scoreFunction={(user) => creatorScores[user.id] ?? 0} - title="🏅 Top creators" + title="🏅 Creator rankings" header="Market volume" /> </> @@ -543,16 +588,17 @@ function AddContractButton(props: { group: Group; user: User }) { </div> </Col> </Modal> - <Row className={'items-center justify-center'}> + <div className={'flex w-48 justify-center'}> <button - className={ - 'btn btn-md btn-outline cursor-pointer gap-2 whitespace-nowrap text-sm normal-case' - } + className={clsx( + createButtonStyle, + 'w-48 whitespace-nowrap border border-black text-black hover:bg-black hover:text-white' + )} onClick={() => setOpen(true)} > Add an old question </button> - </Row> + </div> </> ) } diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 382505e2..08ef9bb8 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -347,10 +347,10 @@ function IncomeNotificationItem(props: { let reasonText = '' if (sourceType === 'bonus' && sourceText) { reasonText = !simple - ? `bonus for ${ + ? `Bonus for ${ parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT } unique bettors` - : ' bonus for unique bettors on' + : 'bonus on' } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you` : `in tips on` } From 93b29000152ed4434853252673f4459e36f69ad8 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 7 Jul 2022 06:53:14 -0600 Subject: [PATCH 106/220] Groups UX on mobile --- web/components/create-question-button.tsx | 2 + web/components/groups/group-chat.tsx | 2 +- web/components/layout/tabs.tsx | 5 ++- web/pages/group/[...slugs]/index.tsx | 51 ++++++++++++++++++----- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/web/components/create-question-button.tsx b/web/components/create-question-button.tsx index a9161ac6..b8b5dcf3 100644 --- a/web/components/create-question-button.tsx +++ b/web/components/create-question-button.tsx @@ -2,6 +2,8 @@ import Link from 'next/link' import clsx from 'clsx' import { firebaseLogin, User } from 'web/lib/firebase/users' import React from 'react' +import { PlusIcon } from '@heroicons/react/outline' +import { Row } from 'web/components/layout/row' export const createButtonStyle = 'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11' diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 13028313..1298065d 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -97,7 +97,7 @@ export function GroupChat(props: { } return ( - <Col className={'flex-1'}> + <Col className={'mt-2 flex-1'}> <Col className={ 'max-h-[65vh] w-full space-y-2 overflow-x-hidden overflow-y-scroll' diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index ac1c0fe3..f025951c 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -16,14 +16,15 @@ export function Tabs(props: { defaultIndex?: number labelClassName?: string onClick?: (tabTitle: string, index: number) => void + className?: string }) { - const { tabs, defaultIndex, labelClassName, onClick } = props + const { tabs, defaultIndex, labelClassName, onClick, className } = props const [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0) const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case return ( <> - <div className="mb-4 border-b border-gray-200"> + <div className={clsx('mb-4 border-b border-gray-200', className)}> <nav className="-mb-px flex space-x-8" aria-label="Tabs"> {tabs.map((tab, i) => ( <Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}> diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index b6fcfe38..2fbd1c5e 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -53,7 +53,7 @@ import { SiteLink } from 'web/components/site-link' import { ContractSearch } from 'web/components/contract-search' import clsx from 'clsx' import { FollowList } from 'web/components/follow-list' -import { SearchIcon } from '@heroicons/react/outline' +import { PlusIcon, SearchIcon } from '@heroicons/react/outline' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -223,9 +223,17 @@ export default function GroupPage(props: { <Col className="px-3 lg:px-1"> <Row className={'items-center justify-between gap-4'}> - <div className={'mb-1'}> - <Title className={'line-clamp-2'} text={group.name} /> - <Linkify text={group.about} /> + <div className={'sm:mb-1'}> + <div + className={ + 'line-clamp-1 my-1 text-lg text-indigo-700 sm:my-3 sm:text-2xl' + } + > + {group.name} + </div> + <div className={'hidden sm:block'}> + <Linkify text={group.about} /> + </div> </div> <div className="hidden sm:block xl:hidden"> <JoinOrCreateButton @@ -241,6 +249,7 @@ export default function GroupPage(props: { </Col> <Tabs + className={'mb-0 sm:mb-2'} defaultIndex={ page === 'rankings' ? 2 @@ -320,11 +329,19 @@ function JoinOrCreateButton(props: { }) { const { group, user, isMember } = props return user && isMember ? ( - <Row className={'justify-between sm:flex-col sm:justify-center'}> + <Row + className={'-mt-2 justify-between sm:mt-0 sm:flex-col sm:justify-center'} + > <CreateQuestionButton user={user} overrideText={'Add a new question'} - className={'w-48 flex-shrink-0'} + className={'hidden w-48 flex-shrink-0 sm:block'} + query={`?groupId=${group.id}`} + /> + <CreateQuestionButton + user={user} + overrideText={'New question'} + className={'block w-40 flex-shrink-0 sm:hidden'} query={`?groupId=${group.id}`} /> <AddContractButton group={group} user={user} /> @@ -357,8 +374,8 @@ function GroupOverview(props: { } return ( - <Col> - <Col className="gap-2 rounded-b bg-white p-4"> + <> + <Col className="gap-2 rounded-b bg-white p-2"> <Row className={'flex-wrap justify-between'}> <div className={'inline-flex items-center'}> <div className="mr-1 text-gray-500">Created by</div> @@ -370,6 +387,9 @@ function GroupOverview(props: { </div> {isCreator && <EditGroupButton className={'ml-1'} group={group} />} </Row> + <div className={'block sm:hidden'}> + <Linkify text={group.about} /> + </div> <Row className={'items-center gap-1'}> <span className={'text-gray-500'}>Membership</span> {user && user.id === creator.id ? ( @@ -406,7 +426,7 @@ function GroupOverview(props: { <GroupMemberSearch group={group} /> </Col> </Col> - </Col> + </> ) } @@ -588,16 +608,25 @@ function AddContractButton(props: { group: Group; user: User }) { </div> </Col> </Modal> - <div className={'flex w-48 justify-center'}> + <div className={'flex justify-center'}> <button className={clsx( createButtonStyle, - 'w-48 whitespace-nowrap border border-black text-black hover:bg-black hover:text-white' + 'hidden w-48 whitespace-nowrap border border-black text-black hover:bg-black hover:text-white sm:block' )} onClick={() => setOpen(true)} > Add an old question </button> + <button + className={clsx( + createButtonStyle, + 'block w-40 whitespace-nowrap border border-black text-black hover:bg-black hover:text-white sm:hidden' + )} + onClick={() => setOpen(true)} + > + Old question + </button> </div> </> ) From b8748fd49a0b4f2a7c69848fd76dc0576eb7c33e Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 7 Jul 2022 06:54:00 -0600 Subject: [PATCH 107/220] Leaderboards => Rankings on groups --- web/pages/group/[...slugs]/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 2fbd1c5e..9b155083 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -307,7 +307,7 @@ export default function GroupPage(props: { href: groupPath(group.slug, 'questions'), }, { - title: 'Leaderboards', + title: 'Rankings', content: leaderboard, href: groupPath(group.slug, 'rankings'), }, From 7f8617832f6eaf7be1c345a178c8ed74d8fb671b Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 7 Jul 2022 07:05:12 -0600 Subject: [PATCH 108/220] Unused vars --- web/components/create-question-button.tsx | 2 -- web/pages/group/[...slugs]/index.tsx | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/web/components/create-question-button.tsx b/web/components/create-question-button.tsx index b8b5dcf3..a9161ac6 100644 --- a/web/components/create-question-button.tsx +++ b/web/components/create-question-button.tsx @@ -2,8 +2,6 @@ import Link from 'next/link' import clsx from 'clsx' import { firebaseLogin, User } from 'web/lib/firebase/users' import React from 'react' -import { PlusIcon } from '@heroicons/react/outline' -import { Row } from 'web/components/layout/row' export const createButtonStyle = 'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11' diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 9b155083..b38750fc 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -2,7 +2,6 @@ import { take, sortBy, debounce } from 'lodash' import { Group } from 'common/group' import { Page } from 'web/components/page' -import { Title } from 'web/components/title' import { listAllBets } from 'web/lib/firebase/bets' import { Contract } from 'web/lib/firebase/contracts' import { @@ -53,7 +52,7 @@ import { SiteLink } from 'web/components/site-link' import { ContractSearch } from 'web/components/contract-search' import clsx from 'clsx' import { FollowList } from 'web/components/follow-list' -import { PlusIcon, SearchIcon } from '@heroicons/react/outline' +import { SearchIcon } from '@heroicons/react/outline' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -410,7 +409,7 @@ function GroupOverview(props: { </Row> {anyoneCanJoin && user && ( <Row className={'flex-wrap items-center gap-1'}> - <span className={'text-gray-500'}>Sharing</span> + <span className={'text-gray-500'}>Share</span> <ShareIconButton group={group} username={user.username} From a22b29ad6d495ed965b69a71c6709de6a905c0db Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 7 Jul 2022 12:36:34 -0400 Subject: [PATCH 109/220] create: remove automatic setting of log scale --- web/pages/create.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index df83fb9f..f26d5687 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -149,14 +149,6 @@ export function NewContract(props: { ? parseFloat(initialValueString) : undefined - const adjustIsLog = () => { - if (min === undefined || max === undefined) return - const lengthDiff = Math.log10(max - min) - if (lengthDiff > 2) { - setIsLogScale(true) - } - } - // get days from today until the end of this year: const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') @@ -279,7 +271,6 @@ export function NewContract(props: { placeholder="MIN" onClick={(e) => e.stopPropagation()} onChange={(e) => setMinString(e.target.value)} - onBlur={adjustIsLog} min={Number.MIN_SAFE_INTEGER} max={Number.MAX_SAFE_INTEGER} disabled={isSubmitting} @@ -291,7 +282,6 @@ export function NewContract(props: { placeholder="MAX" onClick={(e) => e.stopPropagation()} onChange={(e) => setMaxString(e.target.value)} - onBlur={adjustIsLog} min={Number.MIN_SAFE_INTEGER} max={Number.MAX_SAFE_INTEGER} disabled={isSubmitting} From cfbb78af48a26fa070e47aba121d46ddb03e4744 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 7 Jul 2022 14:41:50 -0600 Subject: [PATCH 110/220] Use react-query to cache notifications (#625) * Use react-query to cache notifications * Fix imports * Cleanup * Limit unseen notifs query * Catch the bounced query * Don't use interval * Unused var * Avoid flash of page nav * Give notification question priority & 2 lines * Right justify timestamps * Rewording * Margin * Simplify error msg * Be explicit about limit for unseen notifs * Pass limit > 0 --- web/components/nav/sidebar.tsx | 45 ++- web/components/notifications-icon.tsx | 56 +-- web/hooks/use-notifications.ts | 77 +++-- web/lib/firebase/notifications.ts | 27 +- web/pages/notifications.tsx | 475 ++++++++++++-------------- 5 files changed, 358 insertions(+), 322 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index b9449ea0..6ab095ef 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -12,7 +12,7 @@ import { import clsx from 'clsx' import Link from 'next/link' import { useRouter } from 'next/router' -import { useUser } from 'web/hooks/use-user' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import { firebaseLogout, User } from 'web/lib/firebase/users' import { ManifoldLogo } from './manifold-logo' import { MenuButton } from './menu' @@ -26,8 +26,9 @@ import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' import { Group } from 'common/group' import { Spacer } from '../layout/spacer' -import { usePreferredNotifications } from 'web/hooks/use-notifications' +import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { setNotificationsAsSeen } from 'web/pages/notifications' +import { PrivateUser } from 'common/user' function getNavigation() { return [ @@ -186,6 +187,7 @@ export default function Sidebar(props: { className?: string }) { const currentPage = router.pathname const user = useUser() + const privateUser = usePrivateUser(user?.id) const navigationOptions = !user ? signedOutNavigation : getNavigation() const mobileNavigationOptions = !user ? signedOutMobileNavigation @@ -220,11 +222,13 @@ export default function Sidebar(props: { className?: string }) { /> )} - <GroupsList - currentPage={router.asPath} - memberItems={memberItems} - user={user} - /> + {privateUser && ( + <GroupsList + currentPage={router.asPath} + memberItems={memberItems} + privateUser={privateUser} + /> + )} </div> {/* Desktop navigation */} @@ -243,11 +247,13 @@ export default function Sidebar(props: { className?: string }) { <div className="h-[1px] bg-gray-300" /> </div> )} - <GroupsList - currentPage={router.asPath} - memberItems={memberItems} - user={user} - /> + {privateUser && ( + <GroupsList + currentPage={router.asPath} + memberItems={memberItems} + privateUser={privateUser} + /> + )} </div> </nav> ) @@ -256,13 +262,16 @@ export default function Sidebar(props: { className?: string }) { function GroupsList(props: { currentPage: string memberItems: Item[] - user: User | null | undefined + privateUser: PrivateUser }) { - const { currentPage, memberItems, user } = props - const preferredNotifications = usePreferredNotifications(user?.id, { - unseenOnly: true, - customHref: '/group/', - }) + const { currentPage, memberItems, privateUser } = props + const preferredNotifications = useUnseenPreferredNotifications( + privateUser, + { + customHref: '/group/', + }, + memberItems.length > 0 ? memberItems.length : undefined + ) // Set notification as seen if our current page is equal to the isSeenOnHref property useEffect(() => { diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index 8f45a054..2938fd17 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -4,45 +4,53 @@ import { Row } from 'web/components/layout/row' import { useEffect, useState } from 'react' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { useRouter } from 'next/router' -import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications' +import { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' import { requestBonuses } from 'web/lib/firebase/api-call' +import { PrivateUser } from 'common/user' export default function NotificationsIcon(props: { className?: string }) { const user = useUser() const privateUser = usePrivateUser(user?.id) - const notifications = usePreferredGroupedNotifications(privateUser?.id, { - unseenOnly: true, - }) - const [seen, setSeen] = useState(false) useEffect(() => { - if (!privateUser) return - - if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 65 * 1000) - requestBonuses({}).catch((error) => { - console.log("couldn't get bonuses:", error.message) - }) + if ( + privateUser && + privateUser.lastTimeCheckedBonuses && + Date.now() - privateUser.lastTimeCheckedBonuses > 1000 * 70 + ) + requestBonuses({}).catch(() => console.log('no bonuses for you (yet)')) }, [privateUser]) - const router = useRouter() - useEffect(() => { - if (router.pathname.endsWith('notifications')) return setSeen(true) - else setSeen(false) - }, [router.pathname]) - return ( <Row className={clsx('justify-center')}> <div className={'relative'}> - {!seen && notifications && notifications.length > 0 && ( - <div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2"> - {notifications.length > NOTIFICATIONS_PER_PAGE - ? `${NOTIFICATIONS_PER_PAGE}+` - : notifications.length} - </div> - )} + {privateUser && <UnseenNotificationsBubble privateUser={privateUser} />} <BellIcon className={clsx(props.className)} /> </div> </Row> ) } +function UnseenNotificationsBubble(props: { privateUser: PrivateUser }) { + const router = useRouter() + const { privateUser } = props + const [seen, setSeen] = useState(false) + + useEffect(() => { + if (router.pathname.endsWith('notifications')) return setSeen(true) + else setSeen(false) + }, [router.pathname]) + + const notifications = useUnseenPreferredNotificationGroups(privateUser) + if (!notifications || notifications.length === 0 || seen) { + return <div /> + } + + return ( + <div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2"> + {notifications.length > NOTIFICATIONS_PER_PAGE + ? `${NOTIFICATIONS_PER_PAGE}+` + : notifications.length} + </div> + ) +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 98b0f2fd..f5502b85 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -1,9 +1,13 @@ import { useEffect, useState } from 'react' -import { listenForPrivateUser } from 'web/lib/firebase/users' import { notification_subscribe_types, PrivateUser } from 'common/user' import { Notification } from 'common/notification' -import { listenForNotifications } from 'web/lib/firebase/notifications' +import { + getNotificationsQuery, + listenForNotifications, +} from 'web/lib/firebase/notifications' import { groupBy, map } from 'lodash' +import { useFirestoreQuery } from '@react-query-firebase/firestore' +import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' export type NotificationGroup = { notifications: Notification[] @@ -13,15 +17,30 @@ export type NotificationGroup = { type: 'income' | 'normal' } -export function usePreferredGroupedNotifications( - userId: string | undefined, - options: { unseenOnly: boolean } -) { +// For some reason react-query subscriptions don't actually listen for notifications +// Use useUnseenPreferredNotificationGroups to listen for new notifications +export function usePreferredGroupedNotifications(privateUser: PrivateUser) { const [notificationGroups, setNotificationGroups] = useState< NotificationGroup[] | undefined >(undefined) + const [notifications, setNotifications] = useState<Notification[]>([]) + const key = `notifications-${privateUser.id}-all` + + const result = useFirestoreQuery([key], getNotificationsQuery(privateUser.id)) + useEffect(() => { + if (result.isLoading) return + if (!result.data) return setNotifications([]) + const notifications = result.data.docs.map( + (doc) => doc.data() as Notification + ) + + const notificationsToShow = getAppropriateNotifications( + notifications, + privateUser.notificationPreferences + ).filter((n) => !n.isSeenOnHref) + setNotifications(notificationsToShow) + }, [privateUser.notificationPreferences, result.data, result.isLoading]) - const notifications = usePreferredNotifications(userId, options) useEffect(() => { if (!notifications) return @@ -32,6 +51,20 @@ export function usePreferredGroupedNotifications( return notificationGroups } +export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) { + const notifications = useUnseenPreferredNotifications(privateUser, {}) + const [notificationGroups, setNotificationGroups] = useState< + NotificationGroup[] | undefined + >(undefined) + useEffect(() => { + if (!notifications) return + + const groupedNotifications = groupNotifications(notifications) + setNotificationGroups(groupedNotifications) + }, [notifications]) + return notificationGroups +} + export function groupNotifications(notifications: Notification[]) { let notificationGroups: NotificationGroup[] = [] const notificationGroupsByDay = groupBy(notifications, (notification) => @@ -85,32 +118,24 @@ export function groupNotifications(notifications: Notification[]) { return notificationGroups } -export function usePreferredNotifications( - userId: string | undefined, - options: { unseenOnly: boolean; customHref?: string } +export function useUnseenPreferredNotifications( + privateUser: PrivateUser, + options: { customHref?: string }, + limit: number = NOTIFICATIONS_PER_PAGE ) { - const { unseenOnly, customHref } = options - const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null) + const { customHref } = options const [notifications, setNotifications] = useState<Notification[]>([]) const [userAppropriateNotifications, setUserAppropriateNotifications] = useState<Notification[]>([]) useEffect(() => { - if (userId) listenForPrivateUser(userId, setPrivateUser) - }, [userId]) + return listenForNotifications(privateUser.id, setNotifications, { + unseenOnly: true, + limit, + }) + }, [limit, privateUser.id]) useEffect(() => { - if (privateUser) - return listenForNotifications( - privateUser.id, - setNotifications, - unseenOnly - ) - }, [privateUser, unseenOnly]) - - useEffect(() => { - if (!privateUser) return - const notificationsToShow = getAppropriateNotifications( notifications, privateUser.notificationPreferences @@ -118,7 +143,7 @@ export function usePreferredNotifications( customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref ) setUserAppropriateNotifications(notificationsToShow) - }, [privateUser, notifications, customHref]) + }, [notifications, customHref, privateUser.notificationPreferences]) return userAppropriateNotifications } diff --git a/web/lib/firebase/notifications.ts b/web/lib/firebase/notifications.ts index c0dca8be..d2db3665 100644 --- a/web/lib/firebase/notifications.ts +++ b/web/lib/firebase/notifications.ts @@ -1,21 +1,36 @@ -import { collection, query, where } from 'firebase/firestore' +import { collection, limit, orderBy, query, where } from 'firebase/firestore' import { Notification } from 'common/notification' import { db } from 'web/lib/firebase/init' import { listenForValues } from 'web/lib/firebase/utils' +import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' -function getNotificationsQuery(userId: string, unseenOnly?: boolean) { +export function getNotificationsQuery( + userId: string, + unseenOnlyOptions?: { unseenOnly: boolean; limit: number } +) { const notifsCollection = collection(db, `/users/${userId}/notifications`) - if (unseenOnly) return query(notifsCollection, where('isSeen', '==', false)) - return query(notifsCollection) + if (unseenOnlyOptions?.unseenOnly) + return query( + notifsCollection, + where('isSeen', '==', false), + orderBy('createdTime', 'desc'), + limit(unseenOnlyOptions.limit) + ) + return query( + notifsCollection, + orderBy('createdTime', 'desc'), + // Nobody's going through 10 pages of notifications, right? + limit(NOTIFICATIONS_PER_PAGE * 10) + ) } export function listenForNotifications( userId: string, setNotifications: (notifs: Notification[]) => void, - unseenOnly?: boolean + unseenOnlyOptions?: { unseenOnly: boolean; limit: number } ) { return listenForValues<Notification>( - getNotificationsQuery(userId, unseenOnly), + getNotificationsQuery(userId, unseenOnlyOptions), (notifs) => { notifs.sort((n1, n2) => n2.createdTime - n1.createdTime) setNotifications(notifs) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 08ef9bb8..3f9b4eed 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,5 +1,5 @@ import { Tabs } from 'web/components/layout/tabs' -import { useUser } from 'web/hooks/use-user' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import React, { useEffect, useState } from 'react' import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' @@ -8,8 +8,6 @@ import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { doc, updateDoc } from 'firebase/firestore' import { db } from 'web/lib/firebase/init' -import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' -import Custom404 from 'web/pages/404' import { UserLink } from 'web/components/user-page' import { notification_subscribe_types, PrivateUser } from 'common/user' import { Contract } from 'common/contract' @@ -35,137 +33,149 @@ import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' import { groupBy, sum, uniq } from 'lodash' +import Custom404 from 'web/pages/404' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' export default function Notifications() { const user = useUser() - const [page, setPage] = useState(1) - - const groupedNotifications = usePreferredGroupedNotifications(user?.id, { - unseenOnly: false, - }) - const [paginatedNotificationGroups, setPaginatedNotificationGroups] = - useState<NotificationGroup[]>([]) - useEffect(() => { - if (!groupedNotifications) return - const start = (page - 1) * NOTIFICATIONS_PER_PAGE - const end = start + NOTIFICATIONS_PER_PAGE - const maxNotificationsToShow = groupedNotifications.slice(start, end) - const remainingNotification = groupedNotifications.slice(end) - for (const notification of remainingNotification) { - if (notification.isSeen) break - else setNotificationsAsSeen(notification.notifications) - } - setPaginatedNotificationGroups(maxNotificationsToShow) - }, [groupedNotifications, page]) - - if (user === undefined) { - return <LoadingIndicator /> - } - if (user === null) { - return <Custom404 /> - } + const privateUser = usePrivateUser(user?.id) + if (!user) return <Custom404 /> return ( <Page> <div className={'p-2 sm:p-4'}> <Title text={'Notifications'} className={'hidden md:block'} /> - <Tabs - labelClassName={'pb-2 pt-1 '} - defaultIndex={0} - tabs={[ - { - title: 'Notifications', - content: groupedNotifications ? ( - <div className={''}> - {paginatedNotificationGroups.length === 0 && - "You don't have any notifications. Try changing your settings to see more."} - {paginatedNotificationGroups.map((notification) => - notification.type === 'income' ? ( - <IncomeNotificationGroupItem - notificationGroup={notification} - key={notification.groupedById + notification.timePeriod} - /> - ) : notification.notifications.length === 1 ? ( - <NotificationItem - notification={notification.notifications[0]} - key={notification.notifications[0].id} - /> - ) : ( - <NotificationGroupItem - notificationGroup={notification} - key={notification.groupedById + notification.timePeriod} - /> - ) - )} - {groupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( - <nav - className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" - aria-label="Pagination" - > - <div className="hidden sm:block"> - <p className="text-sm text-gray-700"> - Showing{' '} - <span className="font-medium"> - {page === 1 - ? page - : (page - 1) * NOTIFICATIONS_PER_PAGE} - </span>{' '} - to{' '} - <span className="font-medium"> - {page * NOTIFICATIONS_PER_PAGE} - </span>{' '} - of{' '} - <span className="font-medium"> - {groupedNotifications.length} - </span>{' '} - results - </p> - </div> - <div className="flex flex-1 justify-between sm:justify-end"> - <a - href="#" - className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" - onClick={() => page > 1 && setPage(page - 1)} - > - Previous - </a> - <a - href="#" - className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" - onClick={() => - page < - groupedNotifications?.length / - NOTIFICATIONS_PER_PAGE && setPage(page + 1) - } - > - Next - </a> - </div> - </nav> - )} - </div> - ) : ( - <LoadingIndicator /> - ), - }, - { - title: 'Settings', - content: ( - <div className={''}> - <NotificationSettings /> - </div> - ), - }, - ]} - /> + <div> + <Tabs + labelClassName={'pb-2 pt-1 '} + className={'mb-0 sm:mb-2'} + defaultIndex={0} + tabs={[ + { + title: 'Notifications', + content: privateUser ? ( + <NotificationsList privateUser={privateUser} /> + ) : ( + <LoadingIndicator /> + ), + }, + { + title: 'Settings', + content: ( + <div className={''}> + <NotificationSettings /> + </div> + ), + }, + ]} + /> + </div> </div> </Page> ) } +function NotificationsList(props: { privateUser: PrivateUser }) { + const { privateUser } = props + const [page, setPage] = useState(1) + const allGroupedNotifications = usePreferredGroupedNotifications(privateUser) + const [paginatedGroupedNotifications, setPaginatedGroupedNotifications] = + useState<NotificationGroup[] | undefined>(undefined) + + useEffect(() => { + if (!allGroupedNotifications) return + const start = (page - 1) * NOTIFICATIONS_PER_PAGE + const end = start + NOTIFICATIONS_PER_PAGE + const maxNotificationsToShow = allGroupedNotifications.slice(start, end) + const remainingNotification = allGroupedNotifications.slice(end) + for (const notification of remainingNotification) { + if (notification.isSeen) break + else setNotificationsAsSeen(notification.notifications) + } + setPaginatedGroupedNotifications(maxNotificationsToShow) + }, [allGroupedNotifications, page]) + + if (!paginatedGroupedNotifications || !allGroupedNotifications) + return <LoadingIndicator /> + + return ( + <div className={'min-h-[100vh]'}> + {paginatedGroupedNotifications.length === 0 && ( + <div className={'mt-2'}> + You don't have any notifications. Try changing your settings to see + more. + </div> + )} + + {paginatedGroupedNotifications.map((notification) => + notification.type === 'income' ? ( + <IncomeNotificationGroupItem + notificationGroup={notification} + key={notification.groupedById + notification.timePeriod} + /> + ) : notification.notifications.length === 1 ? ( + <NotificationItem + notification={notification.notifications[0]} + key={notification.notifications[0].id} + /> + ) : ( + <NotificationGroupItem + notificationGroup={notification} + key={notification.groupedById + notification.timePeriod} + /> + ) + )} + {paginatedGroupedNotifications.length > 0 && + allGroupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( + <nav + className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" + aria-label="Pagination" + > + <div className="hidden sm:block"> + <p className="text-sm text-gray-700"> + Showing{' '} + <span className="font-medium"> + {page === 1 ? page : (page - 1) * NOTIFICATIONS_PER_PAGE} + </span>{' '} + to{' '} + <span className="font-medium"> + {page * NOTIFICATIONS_PER_PAGE} + </span>{' '} + of{' '} + <span className="font-medium"> + {allGroupedNotifications.length} + </span>{' '} + results + </p> + </div> + <div className="flex flex-1 justify-between sm:justify-end"> + <a + href="#" + className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => page > 1 && setPage(page - 1)} + > + Previous + </a> + <a + href="#" + className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => + page < + allGroupedNotifications?.length / NOTIFICATIONS_PER_PAGE && + setPage(page + 1) + } + > + Next + </a> + </div> + </nav> + )} + </div> + ) +} + function IncomeNotificationGroupItem(props: { notificationGroup: NotificationGroup className?: string @@ -261,18 +271,20 @@ function IncomeNotificationGroupItem(props: { )} <Row className={'items-center text-gray-500 sm:justify-start'}> <TrendingUpIcon className={'text-primary h-7 w-7'} /> - <div className={'flex truncate'}> - <div - onClick={() => setExpanded(!expanded)} - className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'} - > - <span> + <div + className={'flex w-full flex-row flex-wrap pl-1 sm:pl-0'} + onClick={() => setExpanded(!expanded)} + > + <div className={'flex w-full flex-row justify-between'}> + <div> {'Daily Income Summary: '} <span className={'text-primary'}> {'+' + formatMoney(totalIncome)} </span> - </span> - <RelativeTimestamp time={notifications[0].createdTime} /> + </div> + <div className={'inline-block'}> + <RelativeTimestamp time={notifications[0].createdTime} /> + </div> </div> </div> </Row> @@ -329,13 +341,7 @@ function IncomeNotificationItem(props: { justSummary?: boolean }) { const { notification, justSummary } = props - const { - sourceType, - sourceUserName, - reason, - sourceUserUsername, - createdTime, - } = notification + const { sourceType, sourceUserName, sourceUserUsername } = notification const [highlighted] = useState(!notification.isSeen) useEffect(() => { @@ -354,7 +360,7 @@ function IncomeNotificationItem(props: { } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you` : `in tips on` } - return <span className={'flex-shrink-0'}>{reasonText}</span> + return reasonText } if (justSummary) { @@ -374,7 +380,7 @@ function IncomeNotificationItem(props: { </div> <span className={'flex truncate'}> {getReasonForShowingIncomeNotification(true)} - <NotificationLink notification={notification} /> + <NotificationLink notification={notification} noClick={true} /> </span> </div> </div> @@ -392,42 +398,33 @@ function IncomeNotificationItem(props: { > <a href={getSourceUrl(notification)}> <Row className={'items-center text-gray-500 sm:justify-start'}> - <div className={'flex max-w-xl shrink '}> - {sourceType && reason && ( - <div className={'inline'}> - <span className={'mr-1'}> - <NotificationTextLabel - contract={null} - defaultText={notification.sourceText ?? ''} - notification={notification} + <div className={'line-clamp-2 flex max-w-xl shrink '}> + <div className={'inline'}> + <span className={'mr-1'}> + <NotificationTextLabel + contract={null} + defaultText={notification.sourceText ?? ''} + notification={notification} + /> + </span> + </div> + <span> + {sourceType != 'bonus' && + (sourceUserUsername === MULTIPLE_USERS_KEY ? ( + <span className={'mr-1 truncate'}>Multiple users</span> + ) : ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-1 flex-shrink-0'} + justFirstName={true} /> - </span> - - {sourceType != 'bonus' && - (sourceUserUsername === MULTIPLE_USERS_KEY ? ( - <span className={'mr-1 truncate'}>Multiple users</span> - ) : ( - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-1 flex-shrink-0'} - justFirstName={true} - /> - ))} - </div> - )} - {getReasonForShowingIncomeNotification(false)} - <span className={'ml-1 flex hidden sm:inline-block'}> - on + ))} + {getReasonForShowingIncomeNotification(false)} {' on'} <NotificationLink notification={notification} /> </span> - <RelativeTimestamp time={createdTime} /> </div> </Row> - <span className={'flex truncate text-gray-500 sm:hidden'}> - on - <NotificationLink notification={notification} /> - </span> <div className={'mt-4 border-b border-gray-300'} /> </a> </div> @@ -473,23 +470,25 @@ function NotificationGroupItem(props: { )} <Row className={'items-center text-gray-500 sm:justify-start'}> <EmptyAvatar multi /> - <div className={'flex truncate pl-2'}> - <div - onClick={() => setExpanded(!expanded)} - className={' flex cursor-pointer truncate pl-1 sm:pl-0'} - > - {sourceContractTitle ? ( - <> - <span className={'flex-shrink-0'}>{'Activity on '}</span> - <span className={'truncate'}> - <NotificationLink notification={notifications[0]} /> - </span> - </> - ) : ( - 'Other activity' - )} - </div> - <RelativeTimestamp time={notifications[0].createdTime} /> + <div + className={'line-clamp-2 flex w-full flex-row flex-wrap pl-1 sm:pl-0'} + > + {sourceContractTitle ? ( + <div className={'flex w-full flex-row justify-between'}> + <div className={'ml-2'}> + Activity on + <NotificationLink notification={notifications[0]} /> + </div> + <div className={'hidden sm:inline-block'}> + <RelativeTimestamp time={notifications[0].createdTime} /> + </div> + </div> + ) : ( + <span> + Other activity + <RelativeTimestamp time={notifications[0].createdTime} /> + </span> + )} </div> </Row> <div> @@ -528,7 +527,7 @@ function NotificationGroupItem(props: { notification={notification} key={notification.id} justSummary={false} - hideTitle={true} + isChildOfGroup={true} /> ))} </> @@ -545,22 +544,18 @@ function NotificationGroupItem(props: { function NotificationItem(props: { notification: Notification justSummary?: boolean - hideTitle?: boolean + isChildOfGroup?: boolean }) { - const { notification, justSummary, hideTitle } = props + const { notification, justSummary, isChildOfGroup } = props const { sourceType, - sourceId, sourceUserName, sourceUserAvatarUrl, sourceUpdateType, reasonText, reason, sourceUserUsername, - createdTime, sourceText, - sourceContractCreatorUsername, - sourceContractSlug, } = notification const [defaultNotificationText, setDefaultNotificationText] = @@ -629,44 +624,38 @@ function NotificationItem(props: { className={'mr-2'} username={sourceUserName} /> - <div className={'flex-1 overflow-hidden sm:flex'}> + <div className={'flex w-full flex-row pl-1 sm:pl-0'}> <div className={ - 'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0' + 'line-clamp-2 sm:line-clamp-none flex w-full flex-row justify-between' } > - {sourceUpdateType != 'closed' && ( - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - justFirstName={true} - /> - )} - {sourceType && reason && ( - <div className={'inline flex truncate'}> - <span className={'ml-1 flex-shrink-0'}> - {getReasonForShowingNotification(notification, false, true)} - </span> - {!hideTitle && ( - <NotificationLink notification={notification} /> - )} - </div> - )} - {sourceId && - sourceContractSlug && - sourceContractCreatorUsername ? ( - <CopyLinkDateTimeComponent - prefix={sourceContractCreatorUsername} - slug={sourceContractSlug} - createdTime={createdTime} - elementId={getSourceIdForLinkComponent(sourceId)} - className={'-mx-1 inline-flex sm:inline-block'} - /> - ) : ( - <RelativeTimestamp time={createdTime} /> - )} + <div> + {sourceUpdateType != 'closed' && ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-1 flex-shrink-0'} + justFirstName={true} + /> + )} + {getReasonForShowingNotification( + notification, + false, + isChildOfGroup + )} + {isChildOfGroup ? ( + <RelativeTimestamp time={notification.createdTime} /> + ) : ( + <NotificationLink notification={notification} /> + )} + </div> </div> + {!isChildOfGroup && ( + <div className={'hidden sm:inline-block'}> + <RelativeTimestamp time={notification.createdTime} /> + </div> + )} </div> </Row> <div className={'mt-1 ml-1 md:text-base'}> @@ -697,8 +686,11 @@ export const setNotificationsAsSeen = (notifications: Notification[]) => { return notifications } -function NotificationLink(props: { notification: Notification }) { - const { notification } = props +function NotificationLink(props: { + notification: Notification + noClick?: boolean +}) { + const { notification, noClick } = props const { sourceType, sourceContractTitle, @@ -707,8 +699,17 @@ function NotificationLink(props: { notification: Notification }) { sourceSlug, sourceTitle, } = notification + if (noClick) + return ( + <span className={'ml-1 font-bold '}> + {sourceContractTitle || sourceTitle} + </span> + ) return ( <a + className={ + 'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2 ' + } href={ sourceContractCreatorUsername ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` @@ -716,9 +717,6 @@ function NotificationLink(props: { notification: Notification }) { ? `${groupPath(sourceSlug)}` : '' } - className={ - 'ml-1 inline max-w-xs truncate font-bold text-gray-500 hover:underline hover:decoration-indigo-400 hover:decoration-2 sm:max-w-sm' - } > {sourceContractTitle || sourceTitle} </a> @@ -852,14 +850,6 @@ function getReasonForShowingNotification( reasonText = !simple ? 'tagged you on' : 'tagged you' else if (reason === 'reply_to_users_comment') reasonText = !simple ? 'replied to you on' : 'replied' - else if (reason === 'on_users_contract') - reasonText = !simple ? `commented on your question` : 'commented' - else if (reason === 'on_contract_with_users_comment') - reasonText = `commented on` - else if (reason === 'on_contract_with_users_answer') - reasonText = `commented on` - else if (reason === 'on_contract_with_users_shares_in') - reasonText = `commented on` else reasonText = `commented on` break case 'contract': @@ -871,12 +861,6 @@ function getReasonForShowingNotification( break case 'answer': if (reason === 'on_users_contract') reasonText = `answered your question ` - else if (reason === 'on_contract_with_users_comment') - reasonText = `answered` - else if (reason === 'on_contract_with_users_answer') - reasonText = `answered` - else if (reason === 'on_contract_with_users_shares_in') - reasonText = `answered` else reasonText = `answered` break case 'follow': @@ -897,12 +881,7 @@ function getReasonForShowingNotification( default: reasonText = '' } - - return ( - <span className={'flex-shrink-0'}> - {replaceOn ? reasonText.replace(' on', '') : reasonText} - </span> - ) + return replaceOn ? reasonText.replace(' on', '') : reasonText } // TODO: where should we put referral bonus notifications? From d6e808e1a39e20193c97f713b1710ed687f7a5a4 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 7 Jul 2022 14:45:26 -0600 Subject: [PATCH 111/220] Remove category filters --- web/components/contract-search.tsx | 123 ++--------------------------- 1 file changed, 8 insertions(+), 115 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 2c7f5b62..220a95ab 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -20,13 +20,6 @@ import { Row } from './layout/row' import { useEffect, useMemo, useRef, useState } from 'react' import { Spacer } from './layout/spacer' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' -import { useUser } from 'web/hooks/use-user' -import { useFollows } from 'web/hooks/use-follows' -import { EditCategoriesButton } from './feed/category-selector' -import { CATEGORIES, category } from 'common/categories' -import { Tabs } from './layout/tabs' -import { EditFollowingButton } from './following-button' -import { track } from '@amplitude/analytics-browser' import { trackCallback } from 'web/lib/service/analytics' import ContractSearchFirestore from 'web/pages/contract-search-firestore' @@ -60,7 +53,6 @@ export function ContractSearch(props: { tag?: string excludeContractIds?: string[] } - showCategorySelector: boolean onContractClick?: (contract: Contract) => void showPlaceHolder?: boolean hideOrderSelector?: boolean @@ -70,7 +62,6 @@ export function ContractSearch(props: { const { querySortOptions, additionalFilter, - showCategorySelector, onContractClick, overrideGridClassName, hideOrderSelector, @@ -78,10 +69,6 @@ export function ContractSearch(props: { hideQuickBet, } = props - const user = useUser() - const followedCategories = user?.followedCategories - const follows = useFollows(user?.id) - const { initialSort } = useInitialQueryAndSort(querySortOptions) const sort = sortIndexes @@ -94,18 +81,11 @@ export function ContractSearch(props: { querySortOptions?.defaultFilter ?? 'open' ) - const [mode, setMode] = useState<'categories' | 'following'>('categories') - const { filters, numericFilters } = useMemo(() => { let filters = [ filter === 'open' ? 'isResolved:false' : '', filter === 'closed' ? 'isResolved:false' : '', filter === 'resolved' ? 'isResolved:true' : '', - showCategorySelector - ? mode === 'categories' - ? followedCategories?.map((cat) => `lowercaseTags:${cat}`) ?? '' - : follows?.map((creatorId) => `creatorId:${creatorId}`) ?? '' - : '', additionalFilter?.creatorId ? `creatorId:${additionalFilter.creatorId}` : '', @@ -120,14 +100,7 @@ export function ContractSearch(props: { ].filter((f) => f) return { filters, numericFilters } - }, [ - filter, - showCategorySelector, - mode, - Object.values(additionalFilter ?? {}).join(','), - (followedCategories ?? []).join(','), - (follows ?? []).join(','), - ]) + }, [filter, Object.values(additionalFilter ?? {}).join(',')]) const indexName = `${indexPrefix}contracts-${sort}` @@ -182,28 +155,13 @@ export function ContractSearch(props: { <Spacer h={3} /> - {showCategorySelector && ( - <CategoryFollowSelector - mode={mode} - setMode={setMode} - followedCategories={followedCategories ?? []} - follows={follows ?? []} - /> - )} - - <Spacer h={4} /> - - {mode === 'following' && (follows ?? []).length === 0 ? ( - <>You're not following anyone yet.</> - ) : ( - <ContractSearchInner - querySortOptions={querySortOptions} - onContractClick={onContractClick} - overrideGridClassName={overrideGridClassName} - hideQuickBet={hideQuickBet} - excludeContractIds={additionalFilter?.excludeContractIds} - /> - )} + <ContractSearchInner + querySortOptions={querySortOptions} + onContractClick={onContractClick} + overrideGridClassName={overrideGridClassName} + hideQuickBet={hideQuickBet} + excludeContractIds={additionalFilter?.excludeContractIds} + /> </InstantSearch> ) } @@ -288,68 +246,3 @@ export function ContractSearchInner(props: { /> ) } - -function CategoryFollowSelector(props: { - mode: 'categories' | 'following' - setMode: (mode: 'categories' | 'following') => void - followedCategories: string[] - follows: string[] -}) { - const { mode, setMode, followedCategories, follows } = props - - const user = useUser() - - const categoriesTitle = `${ - followedCategories?.length ? followedCategories.length : 'All' - } Categories` - let categoriesDescription = `Showing all categories` - - const followingTitle = `${follows?.length ? follows.length : 'All'} Following` - - if (followedCategories.length) { - const categoriesLabel = followedCategories - .slice(0, 3) - .map((cat) => CATEGORIES[cat as category]) - .join(', ') - const andMoreLabel = - followedCategories.length > 3 - ? `, and ${followedCategories.length - 3} more` - : '' - categoriesDescription = `Showing ${categoriesLabel}${andMoreLabel}` - } - - return ( - <Tabs - defaultIndex={mode === 'categories' ? 0 : 1} - tabs={[ - { - title: categoriesTitle, - content: user && ( - <Row className="items-center gap-1 text-gray-500"> - <div>{categoriesDescription}</div> - <EditCategoriesButton className="self-start" user={user} /> - </Row> - ), - }, - ...(user - ? [ - { - title: followingTitle, - content: ( - <Row className="items-center gap-2 text-gray-500"> - <div>Showing markets by users you are following.</div> - <EditFollowingButton className="self-start" user={user} /> - </Row> - ), - }, - ] - : []), - ]} - onClick={(_, index) => { - const mode = index === 0 ? 'categories' : 'following' - setMode(mode) - track(`click ${mode} tab`) - }} - /> - ) -} From 3ff8b263122f0d8e030d1e8cfac10b28b217caae Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 7 Jul 2022 14:55:28 -0600 Subject: [PATCH 112/220] Remove category selector references --- web/components/contract/contracts-list.tsx | 1 - web/pages/group/[...slugs]/index.tsx | 1 - web/pages/home.tsx | 1 - web/pages/markets.tsx | 2 +- web/pages/tag/[tag].tsx | 1 - 5 files changed, 1 insertion(+), 5 deletions(-) diff --git a/web/components/contract/contracts-list.tsx b/web/components/contract/contracts-list.tsx index e24090d9..20a85ef4 100644 --- a/web/components/contract/contracts-list.tsx +++ b/web/components/contract/contracts-list.tsx @@ -87,7 +87,6 @@ export function CreatorContractsList(props: { creator: User }) { additionalFilter={{ creatorId: creator.id, }} - showCategorySelector={false} /> ) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index b38750fc..db8d38be 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -598,7 +598,6 @@ function AddContractButton(props: { group: Group; user: User }) { <ContractSearch hideOrderSelector={true} onContractClick={addContractToCurrentGroup} - showCategorySelector={false} overrideGridClassName={'flex grid-cols-1 flex-col gap-3 p-1'} showPlaceHolder={true} hideQuickBet={true} diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 75eae351..12bd46a2 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -34,7 +34,6 @@ const Home = () => { shouldLoadFromStorage: true, defaultSort: getSavedSort() ?? '24-hour-vol', }} - showCategorySelector onContractClick={(c) => { // Show contract without navigating to contract page. setContract(c) diff --git a/web/pages/markets.tsx b/web/pages/markets.tsx index ab8e064d..a3e851fc 100644 --- a/web/pages/markets.tsx +++ b/web/pages/markets.tsx @@ -11,7 +11,7 @@ export default function Markets() { description="Discover what's new, trending, or soon-to-close. Or search among our hundreds of markets." url="/markets" /> - <ContractSearch showCategorySelector /> + <ContractSearch /> </Page> ) } diff --git a/web/pages/tag/[tag].tsx b/web/pages/tag/[tag].tsx index c6b7d31d..476afecf 100644 --- a/web/pages/tag/[tag].tsx +++ b/web/pages/tag/[tag].tsx @@ -18,7 +18,6 @@ export default function TagPage() { shouldLoadFromStorage: true, }} additionalFilter={{ tag }} - showCategorySelector={false} /> </Page> ) From 3eee4a41030008f701e120792b22fc44fbd62a6a Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 7 Jul 2022 15:06:29 -0600 Subject: [PATCH 113/220] Track notification clicks --- web/pages/notifications.tsx | 50 ++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 3f9b4eed..aa2cdc51 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -34,6 +34,7 @@ import { groupPath } from 'web/lib/firebase/groups' import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' import { groupBy, sum, uniq } from 'lodash' import Custom404 from 'web/pages/404' +import { track } from '@amplitude/analytics-browser' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' @@ -380,7 +381,7 @@ function IncomeNotificationItem(props: { </div> <span className={'flex truncate'}> {getReasonForShowingIncomeNotification(true)} - <NotificationLink notification={notification} noClick={true} /> + <QuestionLink notification={notification} ignoreClick={true} /> </span> </div> </div> @@ -421,7 +422,7 @@ function IncomeNotificationItem(props: { /> ))} {getReasonForShowingIncomeNotification(false)} {' on'} - <NotificationLink notification={notification} /> + <QuestionLink notification={notification} /> </span> </div> </Row> @@ -477,7 +478,7 @@ function NotificationGroupItem(props: { <div className={'flex w-full flex-row justify-between'}> <div className={'ml-2'}> Activity on - <NotificationLink notification={notifications[0]} /> + <QuestionLink notification={notifications[0]} /> </div> <div className={'hidden sm:inline-block'}> <RelativeTimestamp time={notifications[0].createdTime} /> @@ -616,7 +617,22 @@ function NotificationItem(props: { highlighted && 'bg-indigo-200 hover:bg-indigo-100' )} > - <a href={getSourceUrl(notification)}> + <a + href={getSourceUrl(notification)} + onClick={() => + track('Notification Clicked', { + type: 'notification item', + sourceType, + sourceUserName, + sourceUserAvatarUrl, + sourceUpdateType, + reasonText, + reason, + sourceUserUsername, + sourceText, + }) + } + > <Row className={'items-center text-gray-500 sm:justify-start'}> <Avatar avatarUrl={sourceUserAvatarUrl} @@ -647,7 +663,7 @@ function NotificationItem(props: { {isChildOfGroup ? ( <RelativeTimestamp time={notification.createdTime} /> ) : ( - <NotificationLink notification={notification} /> + <QuestionLink notification={notification} /> )} </div> </div> @@ -686,11 +702,11 @@ export const setNotificationsAsSeen = (notifications: Notification[]) => { return notifications } -function NotificationLink(props: { +function QuestionLink(props: { notification: Notification - noClick?: boolean + ignoreClick?: boolean }) { - const { notification, noClick } = props + const { notification, ignoreClick } = props const { sourceType, sourceContractTitle, @@ -699,7 +715,8 @@ function NotificationLink(props: { sourceSlug, sourceTitle, } = notification - if (noClick) + + if (ignoreClick) return ( <span className={'ml-1 font-bold '}> {sourceContractTitle || sourceTitle} @@ -717,6 +734,17 @@ function NotificationLink(props: { ? `${groupPath(sourceSlug)}` : '' } + onClick={() => + track('Notification Clicked', { + type: 'question title', + sourceType, + sourceContractTitle, + sourceContractCreatorUsername, + sourceContractSlug, + sourceSlug, + sourceTitle, + }) + } > {sourceContractTitle || sourceTitle} </a> @@ -969,6 +997,10 @@ function NotificationSettings() { newValue: notification_subscribe_types ) { if (!privateUser) return + track('In-App Notification Preferences Changed', { + newPreference: newValue, + oldPreference: privateUser.notificationPreferences, + }) toast.promise( updatePrivateUser(privateUser.id, { notificationPreferences: newValue, From e456b9a85562c8eabad9767213537bf5ec266504 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 7 Jul 2022 15:24:13 -0600 Subject: [PATCH 114/220] Analyze tab usage --- web/components/contract/contract-tabs.tsx | 1 + web/components/layout/tabs.tsx | 16 +++++++++++++++- web/components/user-page.tsx | 1 + web/pages/group/[...slugs]/index.tsx | 1 + web/pages/groups.tsx | 1 + web/pages/leaderboards.tsx | 1 + web/pages/notifications.tsx | 1 + web/pages/stats.tsx | 1 + 8 files changed, 22 insertions(+), 1 deletion(-) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index e68e59b9..c7759fb8 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -87,6 +87,7 @@ export function ContractTabs(props: { return ( <Tabs + currentPageForAnalytics={'contract'} tabs={[ { title: 'Comments', content: commentActivity }, { title: 'Bets', content: betActivity }, diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index f025951c..8aec39b1 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx' import Link from 'next/link' import { ReactNode, useState } from 'react' import { Row } from './row' +import { track } from '@amplitude/analytics-browser' type Tab = { title: string @@ -17,8 +18,16 @@ export function Tabs(props: { labelClassName?: string onClick?: (tabTitle: string, index: number) => void className?: string + currentPageForAnalytics?: string }) { - const { tabs, defaultIndex, labelClassName, onClick, className } = props + const { + tabs, + defaultIndex, + labelClassName, + onClick, + className, + currentPageForAnalytics, + } = props const [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0) const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case @@ -32,6 +41,11 @@ export function Tabs(props: { id={`tab-${i}`} key={tab.title} onClick={(e) => { + track('Clicked Tab', { + title: tab.title, + href: tab.href, + currentPage: currentPageForAnalytics, + }) if (!tab.href) { e.preventDefault() } diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index c33476aa..af03eb46 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -262,6 +262,7 @@ export function UserPage(props: { {usersContracts !== 'loading' && commentsByContract != 'loading' ? ( <Tabs + currentPageForAnalytics={'profile'} labelClassName={'pb-2 pt-1 '} defaultIndex={ defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0 diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index db8d38be..73d7819a 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -248,6 +248,7 @@ export default function GroupPage(props: { </Col> <Tabs + currentPageForAnalytics={groupPath(group.slug)} className={'mb-0 sm:mb-2'} defaultIndex={ page === 'rankings' diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 22fe7661..ae64cc76 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -106,6 +106,7 @@ export default function Groups(props: { </div> <Tabs + currentPageForAnalytics={'groups'} tabs={[ ...(user && memberGroupIds.length > 0 ? [ diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index f306493b..061f3a19 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -126,6 +126,7 @@ export default function Leaderboards(props: { <Page> <Title text={'Leaderboards'} className={'hidden md:block'} /> <Tabs + currentPageForAnalytics={'leaderboards'} defaultIndex={0} onClick={(title, index) => { const period = ['allTime', 'monthly', 'weekly', 'daily'][index] diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index aa2cdc51..bb495ecd 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -50,6 +50,7 @@ export default function Notifications() { <Title text={'Notifications'} className={'hidden md:block'} /> <div> <Tabs + currentPageForAnalytics={'notifications'} labelClassName={'pb-2 pt-1 '} className={'mb-0 sm:mb-2'} defaultIndex={0} diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index c81bc3ff..57c47843 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -25,6 +25,7 @@ export default function Analytics() { return ( <Page> <Tabs + currentPageForAnalytics={'stats'} tabs={[ { title: 'Activity', From 999c1cd8e3d929ff3c445e4dde3a0d32769f4809 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 7 Jul 2022 15:52:28 -0600 Subject: [PATCH 115/220] Bold more on new group chats --- web/components/nav/nav-bar.tsx | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 9f0f8ddd..2b065f1c 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -9,7 +9,7 @@ import { import { Transition, Dialog } from '@headlessui/react' import { useState, Fragment } from 'react' import Sidebar, { Item } from './sidebar' -import { useUser } from 'web/hooks/use-user' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { Avatar } from '../avatar' import clsx from 'clsx' @@ -17,6 +17,8 @@ import { useRouter } from 'next/router' import NotificationsIcon from 'web/components/notifications-icon' import { useIsIframe } from 'web/hooks/use-is-iframe' import { trackCallback } from 'web/lib/service/analytics' +import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' +import { PrivateUser } from 'common/user' function getNavigation() { return [ @@ -42,6 +44,7 @@ export function BottomNavBar() { const currentPage = router.pathname const user = useUser() + const privateUser = usePrivateUser(user?.id) const isIframe = useIsIframe() if (isIframe) { @@ -81,8 +84,12 @@ export function BottomNavBar() { className="w-full select-none py-1 px-3 text-center hover:cursor-pointer hover:bg-indigo-200 hover:text-indigo-700" onClick={() => setSidebarOpen(true)} > - <MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" /> - More + <MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" /> + {privateUser ? ( + <MoreMenuWithGroupNotifications privateUser={privateUser} /> + ) : ( + 'More' + )} </div> <MobileSidebar @@ -93,6 +100,22 @@ export function BottomNavBar() { ) } +function MoreMenuWithGroupNotifications(props: { privateUser: PrivateUser }) { + const { privateUser } = props + const preferredNotifications = useUnseenPreferredNotifications(privateUser, { + customHref: '/group/', + }) + return ( + <span + className={ + preferredNotifications.length > 0 ? 'font-bold' : 'font-normal' + } + > + More + </span> + ) +} + function NavBarItem(props: { item: Item; currentPage: string }) { const { item, currentPage } = props const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`) From c3bc25a4b92c7ff6b285d937eca678dc0ecb6e4f Mon Sep 17 00:00:00 2001 From: Ben Congdon <ben@congdon.dev> Date: Thu, 7 Jul 2022 15:36:02 -0700 Subject: [PATCH 116/220] Add API route for listing a bets by user (#567) * Add API route for getting a user's bets * Refactor bets API to use /bets * Update /markets to use zod validation * Update docs --- docs/docs/api.md | 58 ++++++++++++++++ firestore.indexes.json | 22 +++++++ web/lib/firebase/bets.ts | 44 +++++++++++++ web/pages/api/v0/_types.ts | 12 ++++ web/pages/api/v0/_validate.ts | 17 +++++ web/pages/api/v0/bets.ts | 66 +++++++++++++++++++ web/pages/api/v0/markets.ts | 48 +++++++------- .../api/v0/user/[username]/bets/index.ts | 25 +++++++ 8 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 web/pages/api/v0/_validate.ts create mode 100644 web/pages/api/v0/bets.ts create mode 100644 web/pages/api/v0/user/[username]/bets/index.ts diff --git a/docs/docs/api.md b/docs/docs/api.md index a8ac18fe..1cea6027 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -567,6 +567,64 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ ]}' ``` +### `GET /v0/bets` + +Gets a list of bets, ordered by creation date descending. + +Parameters: + +- `username`: Optional. If set, the response will include only bets created by this user. +- `market`: Optional. The slug of a market. If set, the response will only include bets on this market. +- `limit`: Optional. How many bets to return. The maximum and the default is 1000. +- `before`: Optional. The ID of the bet before which the list will start. For + example, if you ask for the most recent 10 bets, and then perform a second + query for 10 more bets with `before=[the id of the 10th bet]`, you will + get bets 11 through 20. + +Requires no authorization. + +- Example request + ``` + https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-california-abolish-daylight-sa + ``` +- Response type: A `Bet[]`. + +- <details><summary>Example response</summary><p> + + ```json + [ + { + "probAfter": 0.44418877319153904, + "shares": -645.8346334931828, + "outcome": "YES", + "contractId": "tgB1XmvFXZNhjr3xMNLp", + "sale": { + "betId": "RcOtarI3d1DUUTjiE0rx", + "amount": 474.9999999999998 + }, + "createdTime": 1644602886293, + "userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", + "probBefore": 0.7229189477449224, + "id": "x9eNmCaqQeXW8AgJ8Zmp", + "amount": -499.9999999999998 + }, + { + "probAfter": 0.9901970375647697, + "contractId": "zdeaYVAfHlo9jKzWh57J", + "outcome": "YES", + "amount": 1, + "id": "8PqxKYwXCcLYoXy2m2Nm", + "shares": 1.0049875638533763, + "userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", + "probBefore": 0.9900000000000001, + "createdTime": 1644705818872 + } + ] + ``` + + </p> + </details> + ## Changelog - 2022-06-08: Add paging to markets endpoint diff --git a/firestore.indexes.json b/firestore.indexes.json index e0cee632..0a8b14bd 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -559,6 +559,28 @@ "queryScope": "COLLECTION_GROUP" } ] + }, + { + "collectionGroup": "bets", + "fieldPath": "id", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] } ] } diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index c442ff73..6fc29d24 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -4,6 +4,13 @@ import { query, where, orderBy, + QueryConstraint, + limit, + startAfter, + doc, + getDocs, + getDoc, + DocumentSnapshot, } from 'firebase/firestore' import { uniq } from 'lodash' @@ -78,6 +85,43 @@ export async function getUserBets( .catch((reason) => reason) } +export async function getBets(options: { + userId?: string + contractId?: string + before?: string + limit: number +}) { + const { userId, contractId, before } = options + + const queryParts: QueryConstraint[] = [ + orderBy('createdTime', 'desc'), + limit(options.limit), + ] + if (userId) { + queryParts.push(where('userId', '==', userId)) + } + if (before) { + let beforeSnap: DocumentSnapshot + if (contractId) { + beforeSnap = await getDoc( + doc(db, 'contracts', contractId, 'bets', before) + ) + } else { + beforeSnap = ( + await getDocs( + query(collectionGroup(db, 'bets'), where('id', '==', before)) + ) + ).docs[0] + } + queryParts.push(startAfter(beforeSnap)) + } + + const querySource = contractId + ? collection(db, 'contracts', contractId, 'bets') + : collectionGroup(db, 'bets') + return await getValues<Bet>(query(querySource, ...queryParts)) +} + export async function getContractsOfUserBets(userId: string) { const bets: Bet[] = await getUserBets(userId, { includeRedemptions: false }) const contractIds = uniq(bets.map((bet) => bet.contractId)) diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index e0012c2b..7f52077d 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -55,6 +55,18 @@ export type ApiError = { error: string } +type ValidationErrorDetail = { + field: string | null + error: string +} +export class ValidationError { + details: ValidationErrorDetail[] + + constructor(details: ValidationErrorDetail[]) { + this.details = details + } +} + export function toLiteMarket(contract: Contract): LiteMarket { const { id, diff --git a/web/pages/api/v0/_validate.ts b/web/pages/api/v0/_validate.ts new file mode 100644 index 00000000..25f5af4e --- /dev/null +++ b/web/pages/api/v0/_validate.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' +import { ValidationError } from './_types' + +export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => { + const result = schema.safeParse(val) + if (!result.success) { + const issues = result.error.issues.map((i) => { + return { + field: i.path.join('.') || null, + error: i.message, + } + }) + throw new ValidationError(issues) + } else { + return result.data as z.infer<T> + } +} diff --git a/web/pages/api/v0/bets.ts b/web/pages/api/v0/bets.ts new file mode 100644 index 00000000..c3de3e97 --- /dev/null +++ b/web/pages/api/v0/bets.ts @@ -0,0 +1,66 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { Bet, getBets } from 'web/lib/firebase/bets' +import { getContractFromSlug } from 'web/lib/firebase/contracts' +import { getUserByUsername } from 'web/lib/firebase/users' +import { ApiError, ValidationError } from './_types' +import { z } from 'zod' +import { validate } from './_validate' + +const queryParams = z + .object({ + username: z.string().optional(), + market: z.string().optional(), + limit: z + .number() + .default(1000) + .or(z.string().regex(/\d+/).transform(Number)) + .refine((n) => n >= 0 && n <= 1000, 'Limit must be between 0 and 1000'), + before: z.string().optional(), + }) + .strict() + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<Bet[] | ValidationError | ApiError> +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + + let params: z.infer<typeof queryParams> + try { + params = validate(queryParams, req.query) + } catch (e) { + if (e instanceof ValidationError) { + return res.status(400).json(e) + } + console.error(`Unknown error during validation: ${e}`) + return res.status(500).json({ error: 'Unknown error during validation' }) + } + + const { username, market, limit, before } = params + + let userId: string | undefined + if (username) { + const user = await getUserByUsername(username) + if (!user) { + res.status(404).json({ error: 'User not found' }) + return + } + userId = user.id + } + + let contractId: string | undefined + if (market) { + const contract = await getContractFromSlug(market) + if (!contract) { + res.status(404).json({ error: 'Contract not found' }) + return + } + contractId = contract.id + } + + const bets = await getBets({ userId, contractId, limit, before }) + + res.setHeader('Cache-Control', 'max-age=0') + return res.status(200).json(bets) +} diff --git a/web/pages/api/v0/markets.ts b/web/pages/api/v0/markets.ts index fec3cc30..56ecc594 100644 --- a/web/pages/api/v0/markets.ts +++ b/web/pages/api/v0/markets.ts @@ -2,38 +2,40 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { listAllContracts } from 'web/lib/firebase/contracts' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' -import { toLiteMarket } from './_types' +import { toLiteMarket, ValidationError } from './_types' +import { z } from 'zod' +import { validate } from './_validate' + +const queryParams = z + .object({ + limit: z + .number() + .default(1000) + .or(z.string().regex(/\d+/).transform(Number)) + .refine((n) => n >= 0 && n <= 1000, 'Limit must be between 0 and 1000'), + before: z.string().optional(), + }) + .strict() export default async function handler( req: NextApiRequest, res: NextApiResponse ) { await applyCorsHeaders(req, res, CORS_UNRESTRICTED) - let before: string | undefined - let limit: number | undefined - if (req.query.before != null) { - if (typeof req.query.before !== 'string') { - res.status(400).json({ error: 'before must be null or a market ID.' }) - return + + let params: z.infer<typeof queryParams> + try { + params = validate(queryParams, req.query) + } catch (e) { + if (e instanceof ValidationError) { + return res.status(400).json(e) } - before = req.query.before - } - if (req.query.limit != null) { - if (typeof req.query.limit !== 'string') { - res - .status(400) - .json({ error: 'limit must be null or a number of markets to return.' }) - return - } - limit = parseInt(req.query.limit) - } else { - limit = 1000 - } - if (limit < 1 || limit > 1000) { - res.status(400).json({ error: 'limit must be between 1 and 1000.' }) - return + console.error(`Unknown error during validation: ${e}`) + return res.status(500).json({ error: 'Unknown error during validation' }) } + const { limit, before } = params + try { const contracts = await listAllContracts(limit, before) // Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching diff --git a/web/pages/api/v0/user/[username]/bets/index.ts b/web/pages/api/v0/user/[username]/bets/index.ts new file mode 100644 index 00000000..464af52c --- /dev/null +++ b/web/pages/api/v0/user/[username]/bets/index.ts @@ -0,0 +1,25 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { Bet, getUserBets } from 'web/lib/firebase/bets' +import { getUserByUsername } from 'web/lib/firebase/users' +import { ApiError } from '../../../_types' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<Bet[] | ApiError> +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + const { username } = req.query + + const user = await getUserByUsername(username as string) + + if (!user) { + res.status(404).json({ error: 'User not found' }) + return + } + + const bets = await getUserBets(user.id, { includeRedemptions: false }) + + res.setHeader('Cache-Control', 'max-age=0') + return res.status(200).json(bets) +} From 53ddb1243be61121485dac57cefde4fa13a334b2 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 7 Jul 2022 15:41:44 -0700 Subject: [PATCH 117/220] Clone missing indexes from firestore --- firestore.indexes.json | 108 ++++++++++++++++++++++++++--------------- 1 file changed, 68 insertions(+), 40 deletions(-) diff --git a/firestore.indexes.json b/firestore.indexes.json index 0a8b14bd..12e88033 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -306,24 +306,6 @@ } ] }, - { - "collectionGroup": "txns", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "toId", - "order": "ASCENDING" - }, - { - "fieldPath": "toType", - "order": "ASCENDING" - }, - { - "fieldPath": "createdTime", - "order": "DESCENDING" - } - ] - }, { "collectionGroup": "manalinks", "queryScope": "COLLECTION", @@ -338,6 +320,34 @@ } ] }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isSeen", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "portfolioHistory", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", + "order": "ASCENDING" + } + ] + }, { "collectionGroup": "portfolioHistory", "queryScope": "COLLECTION", @@ -351,6 +361,24 @@ "order": "ASCENDING" } ] + }, + { + "collectionGroup": "txns", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "toId", + "order": "ASCENDING" + }, + { + "fieldPath": "toType", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] } ], "fieldOverrides": [ @@ -424,6 +452,28 @@ } ] }, + { + "collectionGroup": "bets", + "fieldPath": "id", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, { "collectionGroup": "bets", "fieldPath": "userId", @@ -559,28 +609,6 @@ "queryScope": "COLLECTION_GROUP" } ] - }, - { - "collectionGroup": "bets", - "fieldPath": "id", - "indexes": [ - { - "order": "ASCENDING", - "queryScope": "COLLECTION" - }, - { - "order": "DESCENDING", - "queryScope": "COLLECTION" - }, - { - "arrayConfig": "CONTAINS", - "queryScope": "COLLECTION" - }, - { - "order": "ASCENDING", - "queryScope": "COLLECTION_GROUP" - } - ] } ] } From d6136a993763714d3f990543a964834ee6527173 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 7 Jul 2022 17:17:10 -0600 Subject: [PATCH 118/220] Minor notif spacing adjustments --- web/pages/notifications.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index bb495ecd..0924fbdd 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -272,9 +272,11 @@ function IncomeNotificationGroupItem(props: { /> )} <Row className={'items-center text-gray-500 sm:justify-start'}> - <TrendingUpIcon className={'text-primary h-7 w-7'} /> + <TrendingUpIcon + className={'text-primary ml-1 h-7 w-7 flex-shrink-0 sm:ml-2'} + /> <div - className={'flex w-full flex-row flex-wrap pl-1 sm:pl-0'} + className={'ml-2 flex w-full flex-row flex-wrap truncate'} onClick={() => setExpanded(!expanded)} > <div className={'flex w-full flex-row justify-between'}> From b1b016f9e0732f87c68b084e8b0763f1c0e35c0f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 7 Jul 2022 17:23:13 -0600 Subject: [PATCH 119/220] Enable tipping on group chats w/ notif (#629) --- common/txn.ts | 3 +- functions/src/create-notification.ts | 49 ++++++++++++++----- functions/src/on-create-txn.ts | 65 +++++++++++++++---------- functions/src/utils.ts | 5 ++ web/components/groups/group-chat.tsx | 39 ++++++++++----- web/components/tipper.tsx | 2 + web/hooks/use-tip-txns.ts | 16 ++++-- web/lib/firebase/txns.ts | 17 ++++++- web/pages/[username]/[contractSlug].tsx | 2 +- web/pages/group/[...slugs]/index.tsx | 9 +++- web/pages/notifications.tsx | 18 ++++--- 11 files changed, 160 insertions(+), 65 deletions(-) diff --git a/common/txn.ts b/common/txn.ts index 53b08501..701b67fe 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -36,8 +36,9 @@ type Tip = { toType: 'USER' category: 'TIP' data: { - contractId: string commentId: string + contractId?: string + groupId?: string } } diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 49bff5f7..519720fd 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -14,6 +14,8 @@ import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' +import { TipTxn } from '../../common/txn' +import { Group } from '../../common/group' const firestore = admin.firestore() type user_to_reason_texts = { @@ -285,15 +287,6 @@ export const createNotification = async ( isSeeOnHref: sourceSlug, } } - const notifyTippedUserOfNewTip = async ( - userToReasonTexts: user_to_reason_texts, - userId: string - ) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'tip_received', - } - } const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} @@ -346,8 +339,6 @@ export const createNotification = async ( userToReasonTexts, sourceContract.creatorId ) - } else if (sourceType === 'tip' && relatedUserId) { - await notifyTippedUserOfNewTip(userToReasonTexts, relatedUserId) } return userToReasonTexts } @@ -355,3 +346,39 @@ export const createNotification = async ( const userToReasonTexts = await getUsersToNotify() await createUsersNotifications(userToReasonTexts) } + +export const createTipNotification = async ( + fromUser: User, + toUser: User, + tip: TipTxn, + idempotencyKey: string, + commentId: string, + contract?: Contract, + group?: Group +) => { + const slug = group ? group.slug + `#${commentId}` : commentId + + const notificationRef = firestore + .collection(`/users/${toUser.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUser.id, + reason: 'tip_received', + createdTime: Date.now(), + isSeen: false, + sourceId: tip.id, + sourceType: 'tip', + sourceUpdateType: 'created', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: tip.amount.toString(), + sourceContractCreatorUsername: contract?.creatorUsername, + sourceContractTitle: contract?.question, + sourceContractSlug: contract?.slug, + sourceSlug: slug, + sourceTitle: group?.name, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/on-create-txn.ts b/functions/src/on-create-txn.ts index d877ecac..b915cfa1 100644 --- a/functions/src/on-create-txn.ts +++ b/functions/src/on-create-txn.ts @@ -1,7 +1,7 @@ import * as functions from 'firebase-functions' -import { Txn } from 'common/txn' -import { getContract, getUser, log } from './utils' -import { createNotification } from './create-notification' +import { TipTxn, Txn } from 'common/txn' +import { getContract, getGroup, getUser, log } from './utils' +import { createTipNotification } from './create-notification' import * as admin from 'firebase-admin' import { Comment } from 'common/comment' @@ -18,7 +18,7 @@ export const onCreateTxn = functions.firestore } }) -async function handleTipTxn(txn: Txn, eventId: string) { +async function handleTipTxn(txn: TipTxn, eventId: string) { // get user sending and receiving tip const [sender, receiver] = await Promise.all([ getUser(txn.fromId), @@ -29,40 +29,53 @@ async function handleTipTxn(txn: Txn, eventId: string) { return } - if (!txn.data?.contractId || !txn.data?.commentId) { - log('No contractId or comment id in tip txn.data') + if (!txn.data?.commentId) { + log('No comment id in tip txn.data') return } + let contract = undefined + let group = undefined + let commentSnapshot = undefined - const contract = await getContract(txn.data.contractId) - if (!contract) { - log('Could not find contract') - return + if (txn.data.contractId) { + contract = await getContract(txn.data.contractId) + if (!contract) { + log('Could not find contract') + return + } + commentSnapshot = await firestore + .collection('contracts') + .doc(contract.id) + .collection('comments') + .doc(txn.data.commentId) + .get() + } else if (txn.data.groupId) { + group = await getGroup(txn.data.groupId) + if (!group) { + log('Could not find group') + return + } + commentSnapshot = await firestore + .collection('groups') + .doc(group.id) + .collection('comments') + .doc(txn.data.commentId) + .get() } - const commentSnapshot = await firestore - .collection('contracts') - .doc(contract.id) - .collection('comments') - .doc(txn.data.commentId) - .get() - if (!commentSnapshot.exists) { + if (!commentSnapshot || !commentSnapshot.exists) { log('Could not find comment') return } const comment = commentSnapshot.data() as Comment - await createNotification( - txn.id, - 'tip', - 'created', + await createTipNotification( sender, + receiver, + txn, eventId, - txn.amount.toString(), + comment.id, contract, - 'comment', - receiver.id, - txn.data?.commentId, - comment.text + group ) } diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 29f0db00..0414b01e 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -3,6 +3,7 @@ import * as admin from 'firebase-admin' import { chunk } from 'lodash' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' +import { Group } from '../../common/group' export const log = (...args: unknown[]) => { console.log(`[${new Date().toISOString()}]`, ...args) @@ -66,6 +67,10 @@ export const getContract = (contractId: string) => { return getDoc<Contract>('contracts', contractId) } +export const getGroup = (groupId: string) => { + return getDoc<Group>('groups', groupId) +} + export const getUser = (userId: string) => { return getDoc<User>('users', userId) } diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 1298065d..6e82b05c 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -18,13 +18,18 @@ import { UserLink } from 'web/components/user-page' import { groupPath } from 'web/lib/firebase/groups' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' +import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' +import { Tipper } from 'web/components/tipper' +import { sum } from 'lodash' +import { formatMoney } from 'common/util/format' export function GroupChat(props: { messages: Comment[] user: User | null | undefined group: Group + tips: CommentTipMap }) { - const { messages, user, group } = props + const { messages, user, group, tips } = props const [messageText, setMessageText] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [scrollToBottomRef, setScrollToBottomRef] = @@ -117,6 +122,7 @@ export function GroupChat(props: { ? setScrollToMessageRef : undefined } + tips={tips[message.id] ?? {}} /> ))} {messages.length === 0 && ( @@ -166,8 +172,9 @@ const GroupMessage = memo(function GroupMessage_(props: { onReplyClick?: (comment: Comment) => void setRef?: (ref: HTMLDivElement) => void highlight?: boolean + tips: CommentTips }) { - const { comment, onReplyClick, group, setRef, highlight, user } = props + const { comment, onReplyClick, group, setRef, highlight, user, tips } = props const { text, userUsername, userName, userAvatarUrl, createdTime } = comment const isCreatorsComment = user && comment.userId === user.id return ( @@ -209,16 +216,24 @@ const GroupMessage = memo(function GroupMessage_(props: { shouldTruncate={false} /> </Row> - {!isCreatorsComment && onReplyClick && ( - <button - className={ - 'self-start py-1 text-xs font-bold text-gray-500 hover:underline' - } - onClick={() => onReplyClick(comment)} - > - Reply - </button> - )} + <Row> + {!isCreatorsComment && onReplyClick && ( + <button + className={ + 'self-start py-1 text-xs font-bold text-gray-500 hover:underline' + } + onClick={() => onReplyClick(comment)} + > + Reply + </button> + )} + {isCreatorsComment && sum(Object.values(tips)) > 0 && ( + <span className={'text-primary'}> + {formatMoney(sum(Object.values(tips)))} + </span> + )} + {!isCreatorsComment && <Tipper comment={comment} tips={tips} />} + </Row> </Col> ) }) diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index 64bad4eb..6f7dfbcb 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -53,6 +53,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { data: { contractId: comment.contractId, commentId: comment.id, + groupId: comment.groupId, }, description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`, }) @@ -60,6 +61,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { track('send comment tip', { contractId: comment.contractId, commentId: comment.id, + groupId: comment.groupId, amount: change, fromId: user.id, toId: comment.userId, diff --git a/web/hooks/use-tip-txns.ts b/web/hooks/use-tip-txns.ts index 13ef3d34..50542402 100644 --- a/web/hooks/use-tip-txns.ts +++ b/web/hooks/use-tip-txns.ts @@ -1,17 +1,25 @@ import { TipTxn } from 'common/txn' import { groupBy, mapValues, sumBy } from 'lodash' import { useEffect, useMemo, useState } from 'react' -import { listenForTipTxns } from 'web/lib/firebase/txns' +import { + listenForTipTxns, + listenForTipTxnsOnGroup, +} from 'web/lib/firebase/txns' export type CommentTips = { [userId: string]: number } export type CommentTipMap = { [commentId: string]: CommentTips } -export function useTipTxns(contractId: string): CommentTipMap { +export function useTipTxns(on: { + contractId?: string + groupId?: string +}): CommentTipMap { const [txns, setTxns] = useState<TipTxn[]>([]) + const { contractId, groupId } = on useEffect(() => { - return listenForTipTxns(contractId, setTxns) - }, [contractId, setTxns]) + if (contractId) return listenForTipTxns(contractId, setTxns) + if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns) + }, [contractId, groupId, setTxns]) return useMemo(() => { const byComment = groupBy(txns, 'data.commentId') diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts index 17e9a09b..88ab1352 100644 --- a/web/lib/firebase/txns.ts +++ b/web/lib/firebase/txns.ts @@ -27,18 +27,31 @@ export function getAllCharityTxns() { return getValues<DonationTxn>(charitiesQuery) } -const getTipsQuery = (contractId: string) => +const getTipsOnContractQuery = (contractId: string) => query( txns, where('category', '==', 'TIP'), where('data.contractId', '==', contractId) ) +const getTipsOnGroupQuery = (groupId: string) => + query( + txns, + where('category', '==', 'TIP'), + where('data.groupId', '==', groupId) + ) + export function listenForTipTxns( contractId: string, setTxns: (txns: TipTxn[]) => void ) { - return listenForValues<TipTxn>(getTipsQuery(contractId), setTxns) + return listenForValues<TipTxn>(getTipsOnContractQuery(contractId), setTxns) +} +export function listenForTipTxnsOnGroup( + groupId: string, + setTxns: (txns: TipTxn[]) => void +) { + return listenForValues<TipTxn>(getTipsOnGroupQuery(groupId), setTxns) } // Find all manalink Txns that are from or to this user diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 2576c2e3..e33c116e 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -124,7 +124,7 @@ export function ContractPageContent( // Sort for now to see if bug is fixed. comments.sort((c1, c2) => c1.createdTime - c2.createdTime) - const tips = useTipTxns(contract.id) + const tips = useTipTxns({ contractId: contract.id }) const user = useUser() const { width, height } = useWindowSize() diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 73d7819a..dec25ab1 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -53,6 +53,7 @@ import { ContractSearch } from 'web/components/contract-search' import clsx from 'clsx' import { FollowList } from 'web/components/follow-list' import { SearchIcon } from '@heroicons/react/outline' +import { useTipTxns } from 'web/hooks/use-tip-txns' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -149,6 +150,7 @@ export default function GroupPage(props: { const group = useGroup(props.group?.id) ?? props.group const [contracts, setContracts] = useState<Contract[] | undefined>(undefined) const [query, setQuery] = useState('') + const tips = useTipTxns({ groupId: group?.id }) const messages = useCommentsOnGroup(group?.id) const debouncedQuery = debounce(setQuery, 50) @@ -263,7 +265,12 @@ export default function GroupPage(props: { { title: 'Chat', content: messages ? ( - <GroupChat messages={messages} user={user} group={group} /> + <GroupChat + messages={messages} + user={user} + group={group} + tips={tips} + /> ) : ( <LoadingIndicator /> ), diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 0924fbdd..3a8e4bc0 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -384,7 +384,10 @@ function IncomeNotificationItem(props: { </div> <span className={'flex truncate'}> {getReasonForShowingIncomeNotification(true)} - <QuestionLink notification={notification} ignoreClick={true} /> + <QuestionOrGroupLink + notification={notification} + ignoreClick={true} + /> </span> </div> </div> @@ -425,7 +428,7 @@ function IncomeNotificationItem(props: { /> ))} {getReasonForShowingIncomeNotification(false)} {' on'} - <QuestionLink notification={notification} /> + <QuestionOrGroupLink notification={notification} /> </span> </div> </Row> @@ -481,7 +484,7 @@ function NotificationGroupItem(props: { <div className={'flex w-full flex-row justify-between'}> <div className={'ml-2'}> Activity on - <QuestionLink notification={notifications[0]} /> + <QuestionOrGroupLink notification={notifications[0]} /> </div> <div className={'hidden sm:inline-block'}> <RelativeTimestamp time={notifications[0].createdTime} /> @@ -666,7 +669,7 @@ function NotificationItem(props: { {isChildOfGroup ? ( <RelativeTimestamp time={notification.createdTime} /> ) : ( - <QuestionLink notification={notification} /> + <QuestionOrGroupLink notification={notification} /> )} </div> </div> @@ -705,7 +708,7 @@ export const setNotificationsAsSeen = (notifications: Notification[]) => { return notifications } -function QuestionLink(props: { +function QuestionOrGroupLink(props: { notification: Notification ignoreClick?: boolean }) { @@ -733,7 +736,7 @@ function QuestionLink(props: { href={ sourceContractCreatorUsername ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` - : sourceType === 'group' && sourceSlug + : (sourceType === 'group' || sourceType === 'tip') && sourceSlug ? `${groupPath(sourceSlug)}` : '' } @@ -771,8 +774,9 @@ function getSourceUrl(notification: Notification) { sourceType === 'user' ) return `/${sourceContractCreatorUsername}/${sourceContractSlug}` - if (sourceType === 'tip') + if (sourceType === 'tip' && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` + if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}` if (sourceContractCreatorUsername && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( sourceId ?? '', From 50c5f8b6ebed75ddb20895a66a5434ef4cc9945a Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 8 Jul 2022 12:34:16 -0400 Subject: [PATCH 120/220] reenable fees on share sales; rename getCpmmFees() --- common/calculate-cpmm.ts | 24 ++++++++++-------------- web/components/bet-panel.tsx | 4 ++-- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index e7d56ba3..92d95251 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -1,7 +1,7 @@ import { sum, groupBy, mapValues, sumBy, partition } from 'lodash' import { CPMMContract } from './contract' -import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees' +import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees' import { LiquidityProvision } from './liquidity-provision' import { addObjects } from './util/object' @@ -58,7 +58,7 @@ function calculateCpmmShares( : n + bet - (k * (bet + y) ** -p) ** (1 / (1 - p)) } -export function getCpmmLiquidityFee( +export function getCpmmFees( contract: CPMMContract, bet: number, outcome: string @@ -83,7 +83,7 @@ export function calculateCpmmSharesAfterFee( outcome: string ) { const { pool, p } = contract - const { remainingBet } = getCpmmLiquidityFee(contract, bet, outcome) + const { remainingBet } = getCpmmFees(contract, bet, outcome) return calculateCpmmShares(pool, p, remainingBet, outcome) } @@ -94,9 +94,7 @@ export function calculateCpmmPurchase( outcome: string ) { const { pool, p } = contract - const { remainingBet, fees } = getCpmmLiquidityFee(contract, bet, outcome) - // const remainingBet = bet - // const fees = noFees + const { remainingBet, fees } = getCpmmFees(contract, bet, outcome) const shares = calculateCpmmShares(pool, p, remainingBet, outcome) const { YES: y, NO: n } = pool @@ -176,19 +174,17 @@ export function calculateCpmmSale( throw new Error('Cannot sell non-positive shares') } - const saleValue = calculateCpmmShareValue( + const rawSaleValue = calculateCpmmShareValue( contract, shares, outcome as 'YES' | 'NO' ) - const fees = noFees - - // const { fees, remainingBet: saleValue } = getCpmmLiquidityFee( - // contract, - // rawSaleValue, - // outcome === 'YES' ? 'NO' : 'YES' - // ) + const { fees, remainingBet: saleValue } = getCpmmFees( + contract, + rawSaleValue, + outcome === 'YES' ? 'NO' : 'YES' + ) const { pool } = contract const { YES: y, NO: n } = pool diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index f76117b9..a43f6f12 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -37,7 +37,7 @@ import { useUserContractBets } from 'web/hooks/use-user-bets' import { calculateCpmmSale, getCpmmProbability, - getCpmmLiquidityFee, + getCpmmFees, } from 'common/calculate-cpmm' import { getFormattedMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' @@ -302,7 +302,7 @@ function BuyPanel(props: { const cpmmFees = contract.mechanism === 'cpmm-1' && - getCpmmLiquidityFee(contract, betAmount ?? 0, betChoice ?? 'YES').totalFees + getCpmmFees(contract, betAmount ?? 0, betChoice ?? 'YES').totalFees const dpmTooltip = contract.mechanism === 'dpm-2' From 93b293ca0e2f984aa1dec93ec1dd6566c0113d5f Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 8 Jul 2022 12:50:46 -0400 Subject: [PATCH 121/220] remove quick betting for FR markets --- web/components/contract/quick-bet.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index adbcc456..76ee7536 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -131,6 +131,13 @@ export function QuickBet(props: { contract: Contract; user: User }) { }) } + if (outcomeType === 'FREE_RESPONSE') + return ( + <Col className="relative -my-4 -mr-5 min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle"> + <QuickOutcomeView contract={contract} previewProb={previewProb} /> + </Col> + ) + return ( <Col className={clsx( From ed0544212d133bb6af7447f9739beac6a2a72899 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 8 Jul 2022 15:00:03 -0700 Subject: [PATCH 122/220] Migrate changeUserInfo function to v2 (#626) --- functions/src/change-user-info.ts | 61 ++++++++++--------------------- functions/src/index.ts | 3 +- web/lib/firebase/api-call.ts | 4 ++ web/lib/firebase/fn-call.ts | 10 ----- web/pages/profile.tsx | 19 ++++------ 5 files changed, 32 insertions(+), 65 deletions(-) diff --git a/functions/src/change-user-info.ts b/functions/src/change-user-info.ts index 118d5c67..aa041856 100644 --- a/functions/src/change-user-info.ts +++ b/functions/src/change-user-info.ts @@ -1,5 +1,5 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { getUser } from './utils' import { Contract } from '../../common/contract' @@ -11,37 +11,23 @@ import { } from '../../common/util/clean-username' import { removeUndefinedProps } from '../../common/util/object' import { Answer } from '../../common/answer' +import { APIError, newEndpoint, validate } from './api' -export const changeUserInfo = functions - .runWith({ minInstances: 1 }) - .https.onCall( - async ( - data: { - username?: string - name?: string - avatarUrl?: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + username: z.string().optional(), + name: z.string().optional(), + avatarUrl: z.string().optional(), +}) - const user = await getUser(userId) - if (!user) return { status: 'error', message: 'User not found' } +export const changeuserinfo = newEndpoint({}, async (req, auth) => { + const { username, name, avatarUrl } = validate(bodySchema, req.body) - const { username, name, avatarUrl } = data + const user = await getUser(auth.uid) + if (!user) throw new APIError(400, 'User not found') - return await changeUser(user, { username, name, avatarUrl }) - .then(() => { - console.log('succesfully changed', user.username, 'to', data) - return { status: 'success' } - }) - .catch((e) => { - console.log('Error', e.message) - return { status: 'error', message: e.message } - }) - } - ) + await changeUser(user, { username, name, avatarUrl }) + return { message: 'Successfully changed user info.' } +}) export const changeUser = async ( user: User, @@ -55,14 +41,14 @@ export const changeUser = async ( if (update.username) { update.username = cleanUsername(update.username) if (!update.username) { - throw new Error('Invalid username') + throw new APIError(400, 'Invalid username') } const sameNameUser = await transaction.get( firestore.collection('users').where('username', '==', update.username) ) if (!sameNameUser.empty) { - throw new Error('Username already exists') + throw new APIError(400, 'Username already exists') } } @@ -104,17 +90,10 @@ export const changeUser = async ( ) const answerUpdate: Partial<Answer> = removeUndefinedProps(update) - await transaction.update(userRef, userUpdate) - - await Promise.all( - commentSnap.docs.map((d) => transaction.update(d.ref, commentUpdate)) - ) - - await Promise.all( - answerSnap.docs.map((d) => transaction.update(d.ref, answerUpdate)) - ) - - await contracts.docs.map((d) => transaction.update(d.ref, contractUpdate)) + transaction.update(userRef, userUpdate) + commentSnap.docs.forEach((d) => transaction.update(d.ref, commentUpdate)) + answerSnap.docs.forEach((d) => transaction.update(d.ref, answerUpdate)) + contracts.docs.forEach((d) => transaction.update(d.ref, contractUpdate)) }) } diff --git a/functions/src/index.ts b/functions/src/index.ts index 8d1756f2..08639c7c 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,7 +3,6 @@ import * as admin from 'firebase-admin' admin.initializeApp() // v1 -// export * from './keep-awake' export * from './claim-manalink' export * from './transact' export * from './stripe' @@ -16,7 +15,6 @@ export * from './unsubscribe' export * from './update-metrics' export * from './update-stats' export * from './backup-db' -export * from './change-user-info' export * from './market-close-notifications' export * from './add-liquidity' export * from './on-create-answer' @@ -33,6 +31,7 @@ export * from './on-create-txn' // v2 export * from './health' +export * from './change-user-info' export * from './place-bet' export * from './sell-bet' export * from './sell-shares' diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index db41e592..341e92b0 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -50,6 +50,10 @@ export function getFunctionUrl(name: string) { } } +export function changeUserInfo(params: any) { + return call(getFunctionUrl('changeuserinfo'), 'POST', params) +} + export function createMarket(params: any) { return call(getFunctionUrl('createmarket'), 'POST', params) } diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index ce78ac3a..b9b771b5 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -42,16 +42,6 @@ export const createUser: () => Promise<User | null> = () => { .catch(() => null) } -export const changeUserInfo = (data: { - username?: string - name?: string - avatarUrl?: string -}) => { - return cloudFunction('changeUserInfo')(data) - .then((r) => r.data as { status: string; message?: string }) - .catch((e) => ({ status: 'error', message: e.message })) -} - export const addLiquidity = (data: { amount: number; contractId: string }) => { return cloudFunction('addLiquidity')(data) .then((r) => r.data as { status: string }) diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index ac06eaf2..62177825 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -9,7 +9,7 @@ import { Title } from 'web/components/title' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { cleanDisplayName, cleanUsername } from 'common/util/clean-username' -import { changeUserInfo } from 'web/lib/firebase/fn-call' +import { changeUserInfo } from 'web/lib/firebase/api-call' import { uploadImage } from 'web/lib/firebase/storage' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' @@ -85,12 +85,9 @@ export default function ProfilePage() { if (newName) { setName(newName) - - await changeUserInfo({ name: newName }) - .catch(() => ({ status: 'error' })) - .then((r) => { - if (r.status === 'error') setName(user?.name || '') - }) + await changeUserInfo({ name: newName }).catch((_) => + setName(user?.name || '') + ) } else { setName(user?.name || '') } @@ -101,11 +98,9 @@ export default function ProfilePage() { if (newUsername) { setUsername(newUsername) - await changeUserInfo({ username: newUsername }) - .catch(() => ({ status: 'error' })) - .then((r) => { - if (r.status === 'error') setUsername(user?.username || '') - }) + await changeUserInfo({ username: newUsername }).catch((_) => + setUsername(user?.username || '') + ) } else { setUsername(user?.username || '') } From d9f42caa6a87acc643769475ff1e5a21b0cddc60 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 8 Jul 2022 15:08:17 -0700 Subject: [PATCH 123/220] Migrate addLiquidity and withdrawLiquidity functions to v2 (#627) --- functions/src/add-liquidity.ts | 149 +++++++++++----------- functions/src/index.ts | 2 +- functions/src/withdraw-liquidity.ts | 188 ++++++++++++---------------- web/components/liquidity-panel.tsx | 14 +-- web/lib/firebase/api-call.ts | 8 ++ web/lib/firebase/fn-call.ts | 11 -- 6 files changed, 167 insertions(+), 205 deletions(-) diff --git a/functions/src/add-liquidity.ts b/functions/src/add-liquidity.ts index eca0a056..3ef453c2 100644 --- a/functions/src/add-liquidity.ts +++ b/functions/src/add-liquidity.ts @@ -1,105 +1,96 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { Contract } from '../../common/contract' import { User } from '../../common/user' import { removeUndefinedProps } from '../../common/util/object' import { redeemShares } from './redeem-shares' import { getNewLiquidityProvision } from '../../common/add-liquidity' +import { APIError, newEndpoint, validate } from './api' -export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall( - async ( - data: { - amount: number - contractId: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string(), + amount: z.number().gt(0), +}) - const { amount, contractId } = data +export const addliquidity = newEndpoint({}, async (req, auth) => { + const { amount, contractId } = validate(bodySchema, req.body) - if (amount <= 0 || isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') - // run as transaction to prevent race conditions - return await firestore - .runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) - return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User + // run as transaction to prevent race conditions + return await firestore + .runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract - if ( - contract.mechanism !== 'cpmm-1' || - (contract.outcomeType !== 'BINARY' && - contract.outcomeType !== 'PSEUDO_NUMERIC') - ) - return { status: 'error', message: 'Invalid contract' } + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract + if ( + contract.mechanism !== 'cpmm-1' || + (contract.outcomeType !== 'BINARY' && + contract.outcomeType !== 'PSEUDO_NUMERIC') + ) + throw new APIError(400, 'Invalid contract') - const { closeTime } = contract - if (closeTime && Date.now() > closeTime) - return { status: 'error', message: 'Trading is closed' } + const { closeTime } = contract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed') - if (user.balance < amount) - return { status: 'error', message: 'Insufficient balance' } + if (user.balance < amount) throw new APIError(400, 'Insufficient balance') - const newLiquidityProvisionDoc = firestore - .collection(`contracts/${contractId}/liquidity`) - .doc() + const newLiquidityProvisionDoc = firestore + .collection(`contracts/${contractId}/liquidity`) + .doc() - const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = - getNewLiquidityProvision( - user, - amount, - contract, - newLiquidityProvisionDoc.id - ) - - if (newP !== undefined && !isFinite(newP)) { - return { - status: 'error', - message: 'Liquidity injection rejected due to overflow error.', - } - } - - transaction.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - p: newP, - totalLiquidity: newTotalLiquidity, - }) + const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = + getNewLiquidityProvision( + user, + amount, + contract, + newLiquidityProvisionDoc.id ) - const newBalance = user.balance - amount - const newTotalDeposits = user.totalDeposits - amount - - if (!isFinite(newBalance)) { - throw new Error('Invalid user balance for ' + user.username) + if (newP !== undefined && !isFinite(newP)) { + return { + status: 'error', + message: 'Liquidity injection rejected due to overflow error.', } + } - transaction.update(userDoc, { - balance: newBalance, - totalDeposits: newTotalDeposits, + transaction.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + p: newP, + totalLiquidity: newTotalLiquidity, }) + ) - transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) + const newBalance = user.balance - amount + const newTotalDeposits = user.totalDeposits - amount - return { status: 'success', newLiquidityProvision } + if (!isFinite(newBalance)) { + throw new APIError(500, 'Invalid user balance for ' + user.username) + } + + transaction.update(userDoc, { + balance: newBalance, + totalDeposits: newTotalDeposits, }) - .then(async (result) => { - await redeemShares(userId, contractId) - return result - }) - } -) + + transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) + + return newLiquidityProvision + }) + .then(async (result) => { + await redeemShares(auth.uid, contractId) + return result + }) +}) const firestore = admin.firestore() diff --git a/functions/src/index.ts b/functions/src/index.ts index 08639c7c..2800bb7d 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -16,7 +16,6 @@ export * from './update-metrics' export * from './update-stats' export * from './backup-db' export * from './market-close-notifications' -export * from './add-liquidity' export * from './on-create-answer' export * from './on-update-contract' export * from './on-create-contract' @@ -36,6 +35,7 @@ export * from './place-bet' export * from './sell-bet' export * from './sell-shares' export * from './create-contract' +export * from './add-liquidity' export * from './withdraw-liquidity' export * from './create-group' export * from './resolve-market' diff --git a/functions/src/withdraw-liquidity.ts b/functions/src/withdraw-liquidity.ts index cc8c84cf..1bdb19de 100644 --- a/functions/src/withdraw-liquidity.ts +++ b/functions/src/withdraw-liquidity.ts @@ -1,5 +1,5 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { CPMMContract } from '../../common/contract' import { User } from '../../common/user' @@ -10,129 +10,107 @@ import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' import { noFees } from '../../common/fees' -import { APIError } from './api' +import { APIError, newEndpoint, validate } from './api' import { redeemShares } from './redeem-shares' -export const withdrawLiquidity = functions - .runWith({ minInstances: 1 }) - .https.onCall( - async ( - data: { - contractId: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string(), +}) - const { contractId } = data - if (!contractId) - return { status: 'error', message: 'Missing contract id' } +export const withdrawliquidity = newEndpoint({}, async (req, auth) => { + const { contractId } = validate(bodySchema, req.body) - return await firestore - .runTransaction(async (trans) => { - const lpDoc = firestore.doc(`users/${userId}`) - const lpSnap = await trans.get(lpDoc) - if (!lpSnap.exists) throw new APIError(400, 'User not found.') - const lp = lpSnap.data() as User + return await firestore + .runTransaction(async (trans) => { + const lpDoc = firestore.doc(`users/${auth.uid}`) + const lpSnap = await trans.get(lpDoc) + if (!lpSnap.exists) throw new APIError(400, 'User not found.') + const lp = lpSnap.data() as User - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await trans.get(contractDoc) - if (!contractSnap.exists) - throw new APIError(400, 'Contract not found.') - const contract = contractSnap.data() as CPMMContract + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await trans.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') + const contract = contractSnap.data() as CPMMContract - const liquidityCollection = firestore.collection( - `contracts/${contractId}/liquidity` - ) + const liquidityCollection = firestore.collection( + `contracts/${contractId}/liquidity` + ) - const liquiditiesSnap = await trans.get(liquidityCollection) + const liquiditiesSnap = await trans.get(liquidityCollection) - const liquidities = liquiditiesSnap.docs.map( - (doc) => doc.data() as LiquidityProvision - ) + const liquidities = liquiditiesSnap.docs.map( + (doc) => doc.data() as LiquidityProvision + ) - const userShares = getUserLiquidityShares( - userId, - contract, - liquidities - ) + const userShares = getUserLiquidityShares(auth.uid, contract, liquidities) - // zero all added amounts for now - // can add support for partial withdrawals in the future - liquiditiesSnap.docs - .filter( - (_, i) => - !liquidities[i].isAnte && liquidities[i].userId === userId - ) - .forEach((doc) => trans.update(doc.ref, { amount: 0 })) + // zero all added amounts for now + // can add support for partial withdrawals in the future + liquiditiesSnap.docs + .filter( + (_, i) => !liquidities[i].isAnte && liquidities[i].userId === auth.uid + ) + .forEach((doc) => trans.update(doc.ref, { amount: 0 })) - const payout = Math.min(...Object.values(userShares)) - if (payout <= 0) return {} + const payout = Math.min(...Object.values(userShares)) + if (payout <= 0) return {} - const newBalance = lp.balance + payout - const newTotalDeposits = lp.totalDeposits + payout - trans.update(lpDoc, { - balance: newBalance, - totalDeposits: newTotalDeposits, - } as Partial<User>) + const newBalance = lp.balance + payout + const newTotalDeposits = lp.totalDeposits + payout + trans.update(lpDoc, { + balance: newBalance, + totalDeposits: newTotalDeposits, + } as Partial<User>) - const newPool = subtractObjects(contract.pool, userShares) + const newPool = subtractObjects(contract.pool, userShares) - const minPoolShares = Math.min(...Object.values(newPool)) - const adjustedTotal = contract.totalLiquidity - payout + const minPoolShares = Math.min(...Object.values(newPool)) + const adjustedTotal = contract.totalLiquidity - payout - // total liquidity is a bogus number; use minPoolShares to prevent from going negative - const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) + // total liquidity is a bogus number; use minPoolShares to prevent from going negative + const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) - trans.update(contractDoc, { - pool: newPool, - totalLiquidity: newTotalLiquidity, - }) + trans.update(contractDoc, { + pool: newPool, + totalLiquidity: newTotalLiquidity, + }) - const prob = getProbability(contract) + const prob = getProbability(contract) - // surplus shares become user's bets - const bets = Object.entries(userShares) - .map(([outcome, shares]) => - shares - payout < 1 // don't create bet if less than 1 share - ? undefined - : ({ - userId: userId, - contractId: contract.id, - amount: - (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), - shares: shares - payout, - outcome, - probBefore: prob, - probAfter: prob, - createdTime: Date.now(), - isLiquidityProvision: true, - fees: noFees, - } as Omit<Bet, 'id'>) - ) - .filter((x) => x !== undefined) + // surplus shares become user's bets + const bets = Object.entries(userShares) + .map(([outcome, shares]) => + shares - payout < 1 // don't create bet if less than 1 share + ? undefined + : ({ + userId: auth.uid, + contractId: contract.id, + amount: + (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), + shares: shares - payout, + outcome, + probBefore: prob, + probAfter: prob, + createdTime: Date.now(), + isLiquidityProvision: true, + fees: noFees, + } as Omit<Bet, 'id'>) + ) + .filter((x) => x !== undefined) - for (const bet of bets) { - const doc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() - trans.create(doc, { id: doc.id, ...bet }) - } + for (const bet of bets) { + const doc = firestore.collection(`contracts/${contract.id}/bets`).doc() + trans.create(doc, { id: doc.id, ...bet }) + } - return userShares - }) - .then(async (result) => { - // redeem surplus bet with pre-existing bets - await redeemShares(userId, contractId) - - console.log('userid', userId, 'withdraws', result) - return { status: 'success', userShares: result } - }) - .catch((e) => { - return { status: 'error', message: e.message } - }) - } - ) + return userShares + }) + .then(async (result) => { + // redeem surplus bet with pre-existing bets + await redeemShares(auth.uid, contractId) + console.log('userid', auth.uid, 'withdraws', result) + return result + }) +}) const firestore = admin.firestore() diff --git a/web/components/liquidity-panel.tsx b/web/components/liquidity-panel.tsx index 33efb335..d1e066be 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/liquidity-panel.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' import { CPMMContract } from 'common/contract' import { formatMoney } from 'common/util/format' import { useUser } from 'web/hooks/use-user' -import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/fn-call' +import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api-call' import { AmountInput } from './amount-input' import { Row } from './layout/row' import { useUserLiquidity } from 'web/hooks/use-liquidity' @@ -90,14 +90,10 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { setIsSuccess(false) addLiquidity({ amount, contractId }) - .then((r) => { - if (r.status === 'success') { - setIsSuccess(true) - setError(undefined) - setIsLoading(false) - } else { - setError('Server error') - } + .then((_) => { + setIsSuccess(true) + setError(undefined) + setIsLoading(false) }) .catch((_) => setError('Server error')) diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 341e92b0..d169ea72 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -54,6 +54,14 @@ export function changeUserInfo(params: any) { return call(getFunctionUrl('changeuserinfo'), 'POST', params) } +export function addLiquidity(params: any) { + return call(getFunctionUrl('addliquidity'), 'POST', params) +} + +export function withdrawLiquidity(params: any) { + return call(getFunctionUrl('withdrawliquidity'), 'POST', params) +} + export function createMarket(params: any) { return call(getFunctionUrl('createmarket'), 'POST', params) } diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index b9b771b5..3b16af70 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -9,11 +9,6 @@ import { safeLocalStorage } from '../util/local' export const cloudFunction = <RequestData, ResponseData>(name: string) => httpsCallable<RequestData, ResponseData>(functions, name) -export const withdrawLiquidity = cloudFunction< - { contractId: string }, - { status: 'error' | 'success'; userShares: { [outcome: string]: number } } ->('withdrawLiquidity') - export const transact = cloudFunction< Omit<Txn, 'id' | 'createdTime'>, { status: 'error' | 'success'; message?: string; txn?: Txn } @@ -42,12 +37,6 @@ export const createUser: () => Promise<User | null> = () => { .catch(() => null) } -export const addLiquidity = (data: { amount: number; contractId: string }) => { - return cloudFunction('addLiquidity')(data) - .then((r) => r.data as { status: string }) - .catch((e) => ({ status: 'error', message: e.message })) -} - export const claimManalink = cloudFunction< string, { status: 'error' | 'success'; message?: string } From fdde73710ee18400e22626b8fa99f3b55838ba18 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 8 Jul 2022 15:28:04 -0700 Subject: [PATCH 124/220] Migrate claimManalink function to v2 (#628) * Implement helpful `toString` on client `APIError` * Migrate claimManalink function to v2 --- functions/src/claim-manalink.ts | 166 ++++++++++++++++---------------- functions/src/index.ts | 2 +- web/lib/firebase/api-call.ts | 7 ++ web/lib/firebase/fn-call.ts | 5 - web/pages/link/[slug].tsx | 7 +- 5 files changed, 94 insertions(+), 93 deletions(-) diff --git a/functions/src/claim-manalink.ts b/functions/src/claim-manalink.ts index 4bcd8b16..3822bbf7 100644 --- a/functions/src/claim-manalink.ts +++ b/functions/src/claim-manalink.ts @@ -1,102 +1,104 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { User } from 'common/user' import { Manalink } from 'common/manalink' import { runTxn, TxnData } from './transact' +import { APIError, newEndpoint, validate } from './api' -export const claimManalink = functions - .runWith({ minInstances: 1 }) - .https.onCall(async (slug: string, context) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + slug: z.string(), +}) - // Run as transaction to prevent race conditions. - return await firestore.runTransaction(async (transaction) => { - // Look up the manalink - const manalinkDoc = firestore.doc(`manalinks/${slug}`) - const manalinkSnap = await transaction.get(manalinkDoc) - if (!manalinkSnap.exists) { - return { status: 'error', message: 'Manalink not found' } - } - const manalink = manalinkSnap.data() as Manalink +export const claimmanalink = newEndpoint({}, async (req, auth) => { + const { slug } = validate(bodySchema, req.body) - const { amount, fromId, claimedUserIds } = manalink + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + // Look up the manalink + const manalinkDoc = firestore.doc(`manalinks/${slug}`) + const manalinkSnap = await transaction.get(manalinkDoc) + if (!manalinkSnap.exists) { + throw new APIError(400, 'Manalink not found') + } + const manalink = manalinkSnap.data() as Manalink - if (amount <= 0 || isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } + const { amount, fromId, claimedUserIds } = manalink - const fromDoc = firestore.doc(`users/${fromId}`) - const fromSnap = await transaction.get(fromDoc) - if (!fromSnap.exists) { - return { status: 'error', message: `User ${fromId} not found` } - } - const fromUser = fromSnap.data() as User + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) + throw new APIError(500, 'Invalid amount') - // Only permit one redemption per user per link - if (claimedUserIds.includes(userId)) { - return { - status: 'error', - message: `${fromUser.name} already redeemed manalink ${slug}`, - } - } + const fromDoc = firestore.doc(`users/${fromId}`) + const fromSnap = await transaction.get(fromDoc) + if (!fromSnap.exists) { + throw new APIError(500, `User ${fromId} not found`) + } + const fromUser = fromSnap.data() as User - // Disallow expired or maxed out links - if (manalink.expiresTime != null && manalink.expiresTime < Date.now()) { - return { - status: 'error', - message: `Manalink ${slug} expired on ${new Date( - manalink.expiresTime - ).toLocaleString()}`, - } - } - if ( - manalink.maxUses != null && - manalink.maxUses <= manalink.claims.length - ) { - return { - status: 'error', - message: `Manalink ${slug} has reached its max uses of ${manalink.maxUses}`, - } - } + // Only permit one redemption per user per link + if (claimedUserIds.includes(auth.uid)) { + throw new APIError(400, `You already redeemed manalink ${slug}`) + } - if (fromUser.balance < amount) { - return { - status: 'error', - message: `Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} `, - } - } + // Disallow expired or maxed out links + if (manalink.expiresTime != null && manalink.expiresTime < Date.now()) { + throw new APIError( + 400, + `Manalink ${slug} expired on ${new Date( + manalink.expiresTime + ).toLocaleString()}` + ) + } + if ( + manalink.maxUses != null && + manalink.maxUses <= manalink.claims.length + ) { + throw new APIError( + 400, + `Manalink ${slug} has reached its max uses of ${manalink.maxUses}` + ) + } - // Actually execute the txn - const data: TxnData = { - fromId, - fromType: 'USER', - toId: userId, - toType: 'USER', - amount, - token: 'M$', - category: 'MANALINK', - description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${userId}`, - } - const result = await runTxn(transaction, data) - const txnId = result.txn?.id - if (!txnId) { - return { status: 'error', message: result.message } - } + if (fromUser.balance < amount) { + throw new APIError( + 400, + `Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} ` + ) + } - // Update the manalink object with this info - const claim = { - toId: userId, - txnId, - claimedTime: Date.now(), - } - transaction.update(manalinkDoc, { - claimedUserIds: [...claimedUserIds, userId], - claims: [...manalink.claims, claim], - }) + // Actually execute the txn + const data: TxnData = { + fromId, + fromType: 'USER', + toId: auth.uid, + toType: 'USER', + amount, + token: 'M$', + category: 'MANALINK', + description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${auth.uid}`, + } + const result = await runTxn(transaction, data) + const txnId = result.txn?.id + if (!txnId) { + throw new APIError( + 500, + result.message ?? 'An error occurred posting the transaction.' + ) + } - return { status: 'success', message: 'Manalink claimed' } + // Update the manalink object with this info + const claim = { + toId: auth.uid, + txnId, + claimedTime: Date.now(), + } + transaction.update(manalinkDoc, { + claimedUserIds: [...claimedUserIds, auth.uid], + claims: [...manalink.claims, claim], }) + + return { message: 'Manalink claimed' } }) +}) const firestore = admin.firestore() diff --git a/functions/src/index.ts b/functions/src/index.ts index 2800bb7d..7c839396 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,7 +3,6 @@ import * as admin from 'firebase-admin' admin.initializeApp() // v1 -export * from './claim-manalink' export * from './transact' export * from './stripe' export * from './create-user' @@ -34,6 +33,7 @@ export * from './change-user-info' export * from './place-bet' export * from './sell-bet' export * from './sell-shares' +export * from './claim-manalink' export * from './create-contract' export * from './add-liquidity' export * from './withdraw-liquidity' diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index d169ea72..04c6b7ce 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -10,6 +10,9 @@ export class APIError extends Error { this.name = 'APIError' this.details = details } + toString() { + return this.name + } } export async function call(url: string, method: string, params: any) { @@ -82,6 +85,10 @@ export function sellBet(params: any) { return call(getFunctionUrl('sellbet'), 'POST', params) } +export function claimManalink(params: any) { + return call(getFunctionUrl('claimmanalink'), 'POST', params) +} + export function createGroup(params: any) { return call(getFunctionUrl('creategroup'), 'POST', params) } diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index 3b16af70..6867b5bb 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -36,8 +36,3 @@ export const createUser: () => Promise<User | null> = () => { .then((r) => (r.data as any)?.user || null) .catch(() => null) } - -export const claimManalink = cloudFunction< - string, - { status: 'error' | 'success'; message?: string } ->('claimManalink') diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index eed68e1a..b36a9057 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/router' import { useState } from 'react' import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' -import { claimManalink } from 'web/lib/firebase/fn-call' +import { claimManalink } from 'web/lib/firebase/api-call' import { useManalink } from 'web/lib/firebase/manalinks' import { ManalinkCard } from 'web/components/manalink-card' import { useUser } from 'web/hooks/use-user' @@ -42,10 +42,7 @@ export default function ClaimPage() { if (user == null) { await firebaseLogin() } - const result = await claimManalink(manalink.slug) - if (result.data.status == 'error') { - throw new Error(result.data.message) - } + await claimManalink({ slug: manalink.slug }) user && router.push(`/${user.username}?claimed-mana=yes`) } catch (e) { console.log(e) From c1ca1471a1705ca2501af746d6a9df4a44604360 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 9 Jul 2022 00:26:56 -0700 Subject: [PATCH 125/220] Migrate createAnswer function to v2 (#634) * Migrate createAnswer function to v2 * Remove unhelpful toString on APIError --- functions/src/create-answer.ts | 185 ++++++++---------- functions/src/index.ts | 2 +- .../answers/create-answer-panel.tsx | 25 +-- web/lib/firebase/api-call.ts | 6 +- web/lib/firebase/fn-call.ts | 10 - 5 files changed, 101 insertions(+), 127 deletions(-) diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index cf3867b0..2abaf44d 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -1,5 +1,5 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { Contract } from '../../common/contract' import { User } from '../../common/user' @@ -7,122 +7,103 @@ import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { getContract, getValues } from './utils' import { sendNewAnswerEmail } from './emails' +import { APIError, newEndpoint, validate } from './api' -export const createAnswer = functions - .runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) - .https.onCall( - async ( - data: { - contractId: string - amount: number - text: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string().max(MAX_ANSWER_LENGTH), + amount: z.number().gt(0), + text: z.string(), +}) - const { contractId, amount, text } = data +const opts = { secrets: ['MAILGUN_KEY'] } - if (amount <= 0 || isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } +export const createanswer = newEndpoint(opts, async (req, auth) => { + const { contractId, amount, text } = validate(bodySchema, req.body) - if (!text || typeof text !== 'string' || text.length > MAX_ANSWER_LENGTH) - return { status: 'error', message: 'Invalid text' } + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') - // Run as transaction to prevent race conditions. - const result = await firestore.runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) - return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User + // Run as transaction to prevent race conditions. + const answer = await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User - if (user.balance < amount) - return { status: 'error', message: 'Insufficient balance' } + if (user.balance < amount) throw new APIError(400, 'Insufficient balance') - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract - if (contract.outcomeType !== 'FREE_RESPONSE') - return { - status: 'error', - message: 'Requires a free response contract', - } + if (contract.outcomeType !== 'FREE_RESPONSE') + throw new APIError(400, 'Requires a free response contract') - const { closeTime, volume } = contract - if (closeTime && Date.now() > closeTime) - return { status: 'error', message: 'Trading is closed' } + const { closeTime, volume } = contract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed') - const [lastAnswer] = await getValues<Answer>( - firestore - .collection(`contracts/${contractId}/answers`) - .orderBy('number', 'desc') - .limit(1) - ) + const [lastAnswer] = await getValues<Answer>( + firestore + .collection(`contracts/${contractId}/answers`) + .orderBy('number', 'desc') + .limit(1) + ) - if (!lastAnswer) - return { status: 'error', message: 'Could not fetch last answer' } + if (!lastAnswer) throw new APIError(500, 'Could not fetch last answer') - const number = lastAnswer.number + 1 - const id = `${number}` + const number = lastAnswer.number + 1 + const id = `${number}` - const newAnswerDoc = firestore - .collection(`contracts/${contractId}/answers`) - .doc(id) + const newAnswerDoc = firestore + .collection(`contracts/${contractId}/answers`) + .doc(id) - const answerId = newAnswerDoc.id - const { username, name, avatarUrl } = user + const answerId = newAnswerDoc.id + const { username, name, avatarUrl } = user - const answer: Answer = { - id, - number, - contractId, - createdTime: Date.now(), - userId: user.id, - username, - name, - avatarUrl, - text, - } - transaction.create(newAnswerDoc, answer) - - const loanAmount = 0 - - const { newBet, newPool, newTotalShares, newTotalBets } = - getNewMultiBetInfo(answerId, amount, contract, loanAmount) - - const newBalance = user.balance - amount - const betDoc = firestore - .collection(`contracts/${contractId}/bets`) - .doc() - transaction.create(betDoc, { - id: betDoc.id, - userId: user.id, - ...newBet, - }) - transaction.update(userDoc, { balance: newBalance }) - transaction.update(contractDoc, { - pool: newPool, - totalShares: newTotalShares, - totalBets: newTotalBets, - answers: [...(contract.answers ?? []), answer], - volume: volume + amount, - }) - - return { status: 'success', answerId, betId: betDoc.id, answer } - }) - - const { answer } = result - const contract = await getContract(contractId) - - if (answer && contract) await sendNewAnswerEmail(answer, contract) - - return result + const answer: Answer = { + id, + number, + contractId, + createdTime: Date.now(), + userId: user.id, + username, + name, + avatarUrl, + text, } - ) + transaction.create(newAnswerDoc, answer) + + const loanAmount = 0 + + const { newBet, newPool, newTotalShares, newTotalBets } = + getNewMultiBetInfo(answerId, amount, contract, loanAmount) + + const newBalance = user.balance - amount + const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc() + transaction.create(betDoc, { + id: betDoc.id, + userId: user.id, + ...newBet, + }) + transaction.update(userDoc, { balance: newBalance }) + transaction.update(contractDoc, { + pool: newPool, + totalShares: newTotalShares, + totalBets: newTotalBets, + answers: [...(contract.answers ?? []), answer], + volume: volume + amount, + }) + + return answer + }) + + const contract = await getContract(contractId) + + if (answer && contract) await sendNewAnswerEmail(answer, contract) + + return answer +}) const firestore = admin.firestore() diff --git a/functions/src/index.ts b/functions/src/index.ts index 7c839396..34fceaa7 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -6,7 +6,6 @@ admin.initializeApp() export * from './transact' export * from './stripe' export * from './create-user' -export * from './create-answer' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' @@ -30,6 +29,7 @@ export * from './on-create-txn' // v2 export * from './health' export * from './change-user-info' +export * from './create-answer' export * from './place-bet' export * from './sell-bet' export * from './sell-shares' diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index ed9012c9..41745b09 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -6,7 +6,7 @@ import { findBestMatch } from 'string-similarity' import { FreeResponseContract } from 'common/contract' import { BuyAmountInput } from '../amount-input' import { Col } from '../layout/col' -import { createAnswer } from 'web/lib/firebase/fn-call' +import { APIError, createAnswer } from 'web/lib/firebase/api-call' import { Row } from '../layout/row' import { formatMoney, @@ -46,20 +46,23 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { if (canSubmit) { setIsSubmitting(true) - const result = await createAnswer({ - contractId: contract.id, - text, - amount: betAmount, - }).then((r) => r.data) - - setIsSubmitting(false) - - if (result.status === 'success') { + try { + await createAnswer({ + contractId: contract.id, + text, + amount: betAmount, + }) setText('') setBetAmount(10) setAmountError(undefined) setPossibleDuplicateAnswer(undefined) - } else setAmountError(result.message) + } catch (e) { + if (e instanceof APIError) { + setAmountError(e.toString()) + } + } + + setIsSubmitting(false) } } diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 04c6b7ce..ef0cad1e 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -10,9 +10,6 @@ export class APIError extends Error { this.name = 'APIError' this.details = details } - toString() { - return this.name - } } export async function call(url: string, method: string, params: any) { @@ -53,6 +50,9 @@ export function getFunctionUrl(name: string) { } } +export function createAnswer(params: any) { + return call(getFunctionUrl('createanswer'), 'POST', params) +} export function changeUserInfo(params: any) { return call(getFunctionUrl('changeuserinfo'), 'POST', params) } diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index 6867b5bb..27a5e8f3 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -14,16 +14,6 @@ export const transact = cloudFunction< { status: 'error' | 'success'; message?: string; txn?: Txn } >('transact') -export const createAnswer = cloudFunction< - { contractId: string; text: string; amount: number }, - { - status: 'error' | 'success' - message?: string - answerId?: string - betId?: string - } ->('createAnswer') - export const createUser: () => Promise<User | null> = () => { const local = safeLocalStorage() let deviceToken = local?.getItem('device-token') From e7e686d5799190682b2ea6af802937f43724a079 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 9 Jul 2022 13:53:50 -0400 Subject: [PATCH 126/220] return creator liquidity after resolution --- common/calculate-cpmm.ts | 27 +++++++++++++++++---------- common/payouts-fixed.ts | 4 ++-- functions/src/withdraw-liquidity.ts | 7 ++++++- web/hooks/use-liquidity.ts | 7 ++++++- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index 92d95251..8a609970 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -1,4 +1,4 @@ -import { sum, groupBy, mapValues, sumBy, partition } from 'lodash' +import { sum, groupBy, mapValues, sumBy } from 'lodash' import { CPMMContract } from './contract' import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees' @@ -268,18 +268,20 @@ const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => { export function getCpmmLiquidityPoolWeights( contract: CPMMContract, - liquidities: LiquidityProvision[] + liquidities: LiquidityProvision[], + excludeAntes: boolean ) { - const [antes, nonAntes] = partition(liquidities, (l) => !!l.isAnte) - const calcLiqudity = calculateLiquidityDelta(contract.p) - const liquidityShares = nonAntes.map(calcLiqudity) + const liquidityShares = liquidities.map(calcLiqudity) + const shareSum = sum(liquidityShares) - const shareSum = sum(liquidityShares) + sum(antes.map(calcLiqudity)) + const includedLiquidities = excludeAntes + ? liquidityShares.filter((_, i) => !liquidities[i].isAnte) + : liquidityShares - const weights = liquidityShares.map((s, i) => ({ + const weights = includedLiquidities.map((s, i) => ({ weight: s / shareSum, - providerId: nonAntes[i].userId, + providerId: liquidities[i].userId, })) const userWeights = groupBy(weights, (w) => w.providerId) @@ -292,9 +294,14 @@ export function getCpmmLiquidityPoolWeights( export function getUserLiquidityShares( userId: string, contract: CPMMContract, - liquidities: LiquidityProvision[] + liquidities: LiquidityProvision[], + excludeAntes: boolean ) { - const weights = getCpmmLiquidityPoolWeights(contract, liquidities) + const weights = getCpmmLiquidityPoolWeights( + contract, + liquidities, + excludeAntes + ) const userWeight = weights[userId] ?? 0 return mapValues(contract.pool, (shares) => userWeight * shares) diff --git a/common/payouts-fixed.ts b/common/payouts-fixed.ts index 4e06042b..4b8de85a 100644 --- a/common/payouts-fixed.ts +++ b/common/payouts-fixed.ts @@ -72,7 +72,7 @@ export const getLiquidityPoolPayouts = ( const { pool } = contract const finalPool = pool[outcome] - const weights = getCpmmLiquidityPoolWeights(contract, liquidities) + const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false) return Object.entries(weights).map(([providerId, weight]) => ({ userId: providerId, @@ -123,7 +123,7 @@ export const getLiquidityPoolProbPayouts = ( const { pool } = contract const finalPool = p * pool.YES + (1 - p) * pool.NO - const weights = getCpmmLiquidityPoolWeights(contract, liquidities) + const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false) return Object.entries(weights).map(([providerId, weight]) => ({ userId: providerId, diff --git a/functions/src/withdraw-liquidity.ts b/functions/src/withdraw-liquidity.ts index 1bdb19de..53974f7d 100644 --- a/functions/src/withdraw-liquidity.ts +++ b/functions/src/withdraw-liquidity.ts @@ -42,7 +42,12 @@ export const withdrawliquidity = newEndpoint({}, async (req, auth) => { (doc) => doc.data() as LiquidityProvision ) - const userShares = getUserLiquidityShares(auth.uid, contract, liquidities) + const userShares = getUserLiquidityShares( + auth.uid, + contract, + liquidities, + true + ) // zero all added amounts for now // can add support for partial withdrawals in the future diff --git a/web/hooks/use-liquidity.ts b/web/hooks/use-liquidity.ts index 9c610f3b..9c7c2b6f 100644 --- a/web/hooks/use-liquidity.ts +++ b/web/hooks/use-liquidity.ts @@ -21,6 +21,11 @@ export const useLiquidity = (contractId: string) => { export const useUserLiquidity = (contract: CPMMContract, userId: string) => { const liquidities = useLiquidity(contract.id) - const userShares = getUserLiquidityShares(userId, contract, liquidities ?? []) + const userShares = getUserLiquidityShares( + userId, + contract, + liquidities ?? [], + true + ) return userShares } From 581a42f2885a53de275675667afacf7cf37f2320 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 9 Jul 2022 13:43:18 -0700 Subject: [PATCH 127/220] Migrate stripeWebhook and createCheckoutSession to v2 (#636) --- functions/src/stripe.ts | 22 ++++++++++++---------- web/lib/firebase/api-call.ts | 1 + web/lib/service/stripe.ts | 5 ++--- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/functions/src/stripe.ts b/functions/src/stripe.ts index 67309aa8..450bbe35 100644 --- a/functions/src/stripe.ts +++ b/functions/src/stripe.ts @@ -1,4 +1,4 @@ -import * as functions from 'firebase-functions' +import { onRequest } from 'firebase-functions/v2/https' import * as admin from 'firebase-admin' import Stripe from 'stripe' @@ -42,9 +42,9 @@ const manticDollarStripePrice = isProd() 10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE', } -export const createCheckoutSession = functions - .runWith({ minInstances: 1, secrets: ['STRIPE_APIKEY'] }) - .https.onRequest(async (req, res) => { +export const createcheckoutsession = onRequest( + { minInstances: 1, secrets: ['STRIPE_APIKEY'] }, + async (req, res) => { const userId = req.query.userId?.toString() const manticDollarQuantity = req.query.manticDollarQuantity?.toString() @@ -86,14 +86,15 @@ export const createCheckoutSession = functions }) res.redirect(303, session.url || '') - }) + } +) -export const stripeWebhook = functions - .runWith({ +export const stripewebhook = onRequest( + { minInstances: 1, secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'], - }) - .https.onRequest(async (req, res) => { + }, + async (req, res) => { const stripe = initStripe() let event @@ -115,7 +116,8 @@ export const stripeWebhook = functions } res.status(200).send('success') - }) + } +) const issueMoneys = async (session: StripeSession) => { const { id: sessionId } = session diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index ef0cad1e..d65a44f3 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -53,6 +53,7 @@ export function getFunctionUrl(name: string) { export function createAnswer(params: any) { return call(getFunctionUrl('createanswer'), 'POST', params) } + export function changeUserInfo(params: any) { return call(getFunctionUrl('changeuserinfo'), 'POST', params) } diff --git a/web/lib/service/stripe.ts b/web/lib/service/stripe.ts index 395f7093..bedd68aa 100644 --- a/web/lib/service/stripe.ts +++ b/web/lib/service/stripe.ts @@ -1,12 +1,11 @@ -import { PROJECT_ID } from 'common/envs/constants' +import { getFunctionUrl } from 'web/lib/firebase/api-call' export const checkoutURL = ( userId: string, manticDollarQuantity: number, referer = '' ) => { - const endpoint = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/createCheckoutSession` - + const endpoint = getFunctionUrl('createcheckoutsession') return `${endpoint}?userId=${userId}&manticDollarQuantity=${manticDollarQuantity}&referer=${encodeURIComponent( referer )}` From 67a05c2f1be80e987c51f135861c9d2f24af4a14 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 9 Jul 2022 13:54:15 -0700 Subject: [PATCH 128/220] Migrate transact function to v2 (#635) --- functions/src/index.ts | 2 +- functions/src/transact.ts | 46 ++++++++++++++--------------- web/components/tipper.tsx | 2 +- web/lib/firebase/api-call.ts | 4 +++ web/lib/firebase/fn-call.ts | 6 ---- web/pages/charity/[charitySlug].tsx | 2 +- 6 files changed, 30 insertions(+), 32 deletions(-) diff --git a/functions/src/index.ts b/functions/src/index.ts index 34fceaa7..35f29954 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,7 +3,6 @@ import * as admin from 'firebase-admin' admin.initializeApp() // v1 -export * from './transact' export * from './stripe' export * from './create-user' export * from './on-create-bet' @@ -28,6 +27,7 @@ export * from './on-create-txn' // v2 export * from './health' +export * from './transact' export * from './change-user-info' export * from './create-answer' export * from './place-bet' diff --git a/functions/src/transact.ts b/functions/src/transact.ts index cd091b83..113afc0b 100644 --- a/functions/src/transact.ts +++ b/functions/src/transact.ts @@ -1,40 +1,40 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { User } from '../../common/user' import { Txn } from '../../common/txn' import { removeUndefinedProps } from '../../common/util/object' +import { APIError, newEndpoint } from './api' export type TxnData = Omit<Txn, 'id' | 'createdTime'> -export const transact = functions - .runWith({ minInstances: 1 }) - .https.onCall(async (data: TxnData, context) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +// TODO: We totally fail to validate most of the input to this function, +// so anyone can spam our database with malformed transactions. - const { amount, fromType, fromId } = data +export const transact = newEndpoint({}, async (req, auth) => { + const data = req.body + const { amount, fromType, fromId } = data - if (fromType !== 'USER') - return { - status: 'error', - message: "From type is only implemented for type 'user'.", - } + if (fromType !== 'USER') + throw new APIError(400, "From type is only implemented for type 'user'.") - if (fromId !== userId) - return { - status: 'error', - message: 'Must be authenticated with userId equal to specified fromId.', - } + if (fromId !== auth.uid) + throw new APIError( + 403, + 'Must be authenticated with userId equal to specified fromId.' + ) - if (isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } + if (isNaN(amount) || !isFinite(amount)) + throw new APIError(400, 'Invalid amount') - // Run as transaction to prevent race conditions. - return await firestore.runTransaction(async (transaction) => { - await runTxn(transaction, data) - }) + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + const result = await runTxn(transaction, data) + if (result.status == 'error') { + throw new APIError(500, result.message ?? 'An unknown error occurred.') + } + return result }) +}) export async function runTxn( fbTransaction: admin.firestore.Transaction, diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index 6f7dfbcb..e4b6580f 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -11,7 +11,7 @@ import { debounce, sum } from 'lodash' import { useEffect, useRef, useState } from 'react' import { CommentTips } from 'web/hooks/use-tip-txns' import { useUser } from 'web/hooks/use-user' -import { transact } from 'web/lib/firebase/fn-call' +import { transact } from 'web/lib/firebase/api-call' import { track } from 'web/lib/service/analytics' import { Row } from './layout/row' import { Tooltip } from './tooltip' diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index d65a44f3..7882d9ba 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -54,6 +54,10 @@ export function createAnswer(params: any) { return call(getFunctionUrl('createanswer'), 'POST', params) } +export function transact(params: any) { + return call(getFunctionUrl('transact'), 'POST', params) +} + export function changeUserInfo(params: any) { return call(getFunctionUrl('changeuserinfo'), 'POST', params) } diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index 27a5e8f3..2f299aea 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -1,5 +1,4 @@ import { httpsCallable } from 'firebase/functions' -import { Txn } from 'common/txn' import { User } from 'common/user' import { randomString } from 'common/util/random' import './init' @@ -9,11 +8,6 @@ import { safeLocalStorage } from '../util/local' export const cloudFunction = <RequestData, ResponseData>(name: string) => httpsCallable<RequestData, ResponseData>(functions, name) -export const transact = cloudFunction< - Omit<Txn, 'id' | 'createdTime'>, - { status: 'error' | 'success'; message?: string; txn?: Txn } ->('transact') - export const createUser: () => Promise<User | null> = () => { const local = safeLocalStorage() let deviceToken = local?.getItem('device-token') diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index 7c3ce51b..c3e0912a 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -10,7 +10,7 @@ import { Spacer } from 'web/components/layout/spacer' import { User } from 'common/user' import { useUser } from 'web/hooks/use-user' import { Linkify } from 'web/components/linkify' -import { transact } from 'web/lib/firebase/fn-call' +import { transact } from 'web/lib/firebase/api-call' import { charities, Charity } from 'common/charity' import { useRouter } from 'next/router' import Custom404 from '../404' From 43b1096313a41f6186ade3f5eb6bac9bc52a4504 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 9 Jul 2022 17:27:36 -0400 Subject: [PATCH 129/220] expand search bar when typing on mobile --- web/components/contract-search.tsx | 54 +++++++++++++++++++----------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 220a95ab..7c0460b4 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -22,6 +22,7 @@ import { Spacer } from './layout/spacer' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { trackCallback } from 'web/lib/service/analytics' import ContractSearchFirestore from 'web/pages/contract-search-firestore' +import { useWindowSize } from 'web/hooks/use-window-size' const searchClient = algoliasearch( 'GJQPAYENIF', @@ -104,6 +105,10 @@ export function ContractSearch(props: { const indexName = `${indexPrefix}contracts-${sort}` + const [isSearching, setIsSearching] = useState(false) + const { width } = useWindowSize() + const showOptions = !isSearching || (width ?? 0) >= 500 + if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return ( <ContractSearchFirestore @@ -122,29 +127,38 @@ export function ContractSearch(props: { classNames={{ form: 'before:top-6', input: '!pl-10 !input !input-bordered shadow-none w-[100px]', - resetIcon: 'mt-2 hidden sm:flex', + resetIcon: 'mt-2 sm:flex', }} + onFocus={() => setIsSearching(true)} + onBlur={() => setIsSearching(false)} /> - <select - className="!select !select-bordered" - value={filter} - onChange={(e) => setFilter(e.target.value as filter)} - onBlur={trackCallback('select search filter')} - > - <option value="open">Open</option> - <option value="closed">Closed</option> - <option value="resolved">Resolved</option> - <option value="all">All</option> - </select> - {!hideOrderSelector && ( - <SortBy - items={sortIndexes} - classNames={{ - select: '!select !select-bordered', - }} - onBlur={trackCallback('select search sort')} - /> + + {showOptions && ( + <> + <select + className="!select !select-bordered" + value={filter} + onChange={(e) => setFilter(e.target.value as filter)} + onBlur={trackCallback('select search filter')} + > + <option value="open">Open</option> + <option value="closed">Closed</option> + <option value="resolved">Resolved</option> + <option value="all">All</option> + </select> + + {!hideOrderSelector && ( + <SortBy + items={sortIndexes} + classNames={{ + select: '!select !select-bordered', + }} + onBlur={trackCallback('select search sort')} + /> + )} + </> )} + <Configure facetFilters={filters} numericFilters={numericFilters} From 480b3e7c54c4f28f844bdfb4dab6a79d4dfe3f16 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 9 Jul 2022 14:38:23 -0700 Subject: [PATCH 130/220] Make referral stuff not busted (#632) --- web/lib/firebase/users.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index e72fe141..7f007031 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -39,6 +39,9 @@ import { filterDefined } from 'common/util/array' import { addUserToGroupViaSlug } from 'web/lib/firebase/groups' import { removeUndefinedProps } from 'common/util/object' import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +dayjs.extend(utc) + import { track } from '@amplitude/analytics-browser' export const users = coll<User>('users') From d063e209ddef09ecf9b523a3237d9c0d23980fb5 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 9 Jul 2022 22:04:50 -0400 Subject: [PATCH 131/220] Revert "expand search bar when typing on mobile" This reverts commit 43b1096313a41f6186ade3f5eb6bac9bc52a4504. --- web/components/contract-search.tsx | 54 +++++++++++------------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 7c0460b4..220a95ab 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -22,7 +22,6 @@ import { Spacer } from './layout/spacer' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { trackCallback } from 'web/lib/service/analytics' import ContractSearchFirestore from 'web/pages/contract-search-firestore' -import { useWindowSize } from 'web/hooks/use-window-size' const searchClient = algoliasearch( 'GJQPAYENIF', @@ -105,10 +104,6 @@ export function ContractSearch(props: { const indexName = `${indexPrefix}contracts-${sort}` - const [isSearching, setIsSearching] = useState(false) - const { width } = useWindowSize() - const showOptions = !isSearching || (width ?? 0) >= 500 - if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return ( <ContractSearchFirestore @@ -127,38 +122,29 @@ export function ContractSearch(props: { classNames={{ form: 'before:top-6', input: '!pl-10 !input !input-bordered shadow-none w-[100px]', - resetIcon: 'mt-2 sm:flex', + resetIcon: 'mt-2 hidden sm:flex', }} - onFocus={() => setIsSearching(true)} - onBlur={() => setIsSearching(false)} /> - - {showOptions && ( - <> - <select - className="!select !select-bordered" - value={filter} - onChange={(e) => setFilter(e.target.value as filter)} - onBlur={trackCallback('select search filter')} - > - <option value="open">Open</option> - <option value="closed">Closed</option> - <option value="resolved">Resolved</option> - <option value="all">All</option> - </select> - - {!hideOrderSelector && ( - <SortBy - items={sortIndexes} - classNames={{ - select: '!select !select-bordered', - }} - onBlur={trackCallback('select search sort')} - /> - )} - </> + <select + className="!select !select-bordered" + value={filter} + onChange={(e) => setFilter(e.target.value as filter)} + onBlur={trackCallback('select search filter')} + > + <option value="open">Open</option> + <option value="closed">Closed</option> + <option value="resolved">Resolved</option> + <option value="all">All</option> + </select> + {!hideOrderSelector && ( + <SortBy + items={sortIndexes} + classNames={{ + select: '!select !select-bordered', + }} + onBlur={trackCallback('select search sort')} + /> )} - <Configure facetFilters={filters} numericFilters={numericFilters} From fc06b03af8cd50cee8f3cd672333343faac2e7ff Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 9 Jul 2022 22:39:26 -0400 Subject: [PATCH 132/220] fix getCpmmLiquidityPoolWeights --- common/calculate-cpmm.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index 8a609970..66162132 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -1,4 +1,4 @@ -import { sum, groupBy, mapValues, sumBy } from 'lodash' +import { sum, groupBy, mapValues, sumBy, zip } from 'lodash' import { CPMMContract } from './contract' import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees' @@ -275,16 +275,16 @@ export function getCpmmLiquidityPoolWeights( const liquidityShares = liquidities.map(calcLiqudity) const shareSum = sum(liquidityShares) - const includedLiquidities = excludeAntes - ? liquidityShares.filter((_, i) => !liquidities[i].isAnte) - : liquidityShares - - const weights = includedLiquidities.map((s, i) => ({ - weight: s / shareSum, + const weights = liquidityShares.map((shares, i) => ({ + weight: shares / shareSum, providerId: liquidities[i].userId, })) - const userWeights = groupBy(weights, (w) => w.providerId) + const includedWeights = excludeAntes + ? weights.filter((_, i) => !liquidities[i].isAnte) + : weights + + const userWeights = groupBy(includedWeights, (w) => w.providerId) const totalUserWeights = mapValues(userWeights, (userWeight) => sumBy(userWeight, (w) => w.weight) ) From 80ae551ca9bdf125a4e384603488b17488a00df8 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 13:05:44 -0500 Subject: [PATCH 133/220] =?UTF-8?q?=F0=9F=A7=BE=20Limit=20orders!=20=20(#4?= =?UTF-8?q?95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Simple limit order UI * Update bet schema * Restrict bet panel / bet row to only CPMMBinaryContracts (all binary DPM are resolved) * Limit orders partway implemented * Update follow leaderboard copy * Change cpmm code to take some state instead of whole contract * Write more of matching algorithm * Fill in more of placebet * Use client side contract search for emulator * More correct matching * Merge branch 'main' into limit-orders * Some cleanup * Listen for unfilled bets in bet panel. Calculate how the probability moves based on open limit orders. * Simpler switching between bet & limit bet. * Render your open bets (unfilled limit orders) * Cancel bet endpoint. * Fix build error * Rename open bets to limit bets. Tweak payout calculation * Limit probability selector to 1-99 * Deduct user balance only on each fill. Store orderAmount of bet. Timestamp of fills. * Use floating equal to check if have shares * Add limit order switcher to mobile bet dialog * Support limit orders on numeric markets * Allow CORS exception for Vercel deployments * Remove console.logs * Update user balance by new bet amount * Tweak vercel cors * Try another regexp for vercel cors * Test another vercel regex * Slight notifications refactor * Fix docs edit link (#624) * Fix docs edit link * Update github links * Small groups UX changes * Groups UX on mobile * Leaderboards => Rankings on groups * Unused vars * create: remove automatic setting of log scale * Use react-query to cache notifications (#625) * Use react-query to cache notifications * Fix imports * Cleanup * Limit unseen notifs query * Catch the bounced query * Don't use interval * Unused var * Avoid flash of page nav * Give notification question priority & 2 lines * Right justify timestamps * Rewording * Margin * Simplify error msg * Be explicit about limit for unseen notifs * Pass limit > 0 * Remove category filters * Remove category selector references * Track notification clicks * Analyze tab usage * Bold more on new group chats * Add API route for listing a bets by user (#567) * Add API route for getting a user's bets * Refactor bets API to use /bets * Update /markets to use zod validation * Update docs * Clone missing indexes from firestore * Minor notif spacing adjustments * Enable tipping on group chats w/ notif (#629) * Tweak cors regex for vercel * Your limit bets * Implement selling shares * Merge branch 'main' into limit-orders * Fix lint * Move binary search to util file * Add note that there might be closed form * Add tooltip to explain limit probability * Tweak * Cancel your limit orders if you run out of money * Don't show amount error in probability input * Require limit prob to be >= .1% and <= 99.9% * Fix focus input bug * Simplify mobile betting dialog * Move mobile limit bets list into bet dialog. * Small fixes to existing sell shares client * Lint * Refactor useSaveShares to actually read from localStorage, use less bug-prone interface. * Fix NaN error * Remove TODO * Simple bet fill notification * Tweak wording * Sort limit bets by limit prob * Padding on limit bets * Match header size Co-authored-by: Ian Philips <iansphilips@gmail.com> Co-authored-by: ahalekelly <ahalekelly@gmail.com> Co-authored-by: mantikoros <sgrugett@gmail.com> Co-authored-by: Ben Congdon <ben@congdon.dev> Co-authored-by: Austin Chen <akrolsmir@gmail.com> --- common/bet.ts | 30 +- common/calculate-cpmm.ts | 215 +++++----- common/calculate.ts | 30 +- common/envs/constants.ts | 4 + common/new-bet.ts | 235 +++++++++-- common/notification.ts | 1 + common/sell-bet.ts | 30 +- common/util/algos.ts | 22 + common/util/math.ts | 14 + functions/src/api.ts | 3 +- functions/src/cancel-bet.ts | 35 ++ functions/src/create-notification.ts | 36 +- functions/src/index.ts | 1 + functions/src/on-create-bet.ts | 51 ++- functions/src/on-update-user.ts | 18 + functions/src/place-bet.ts | 104 ++++- functions/src/sell-shares.ts | 33 +- web/components/bet-panel.tsx | 390 +++++++++--------- web/components/bet-row.tsx | 21 +- web/components/bets-list.tsx | 35 +- web/components/bucket-input.tsx | 9 +- web/components/contract/contract-card.tsx | 5 +- web/components/contract/contract-overview.tsx | 9 +- web/components/contract/quick-bet.tsx | 51 ++- web/components/feed/contract-activity.tsx | 4 +- web/components/feed/feed-items.tsx | 7 +- web/components/limit-bets.tsx | 89 ++++ web/components/numeric-resolution-panel.tsx | 2 +- web/components/probability-input.tsx | 49 +++ web/components/sell-row.tsx | 17 +- web/components/use-save-binary-shares.ts | 56 +++ web/components/use-save-shares.ts | 59 --- web/hooks/use-bets.ts | 11 + web/hooks/use-focus.ts | 5 +- web/lib/firebase/api-call.ts | 4 + web/lib/firebase/bets.ts | 17 +- web/pages/[username]/[contractSlug].tsx | 7 +- web/pages/embed/[username]/[contractSlug].tsx | 7 +- web/pages/notifications.tsx | 15 +- 39 files changed, 1209 insertions(+), 522 deletions(-) create mode 100644 common/util/algos.ts create mode 100644 functions/src/cancel-bet.ts create mode 100644 web/components/limit-bets.tsx create mode 100644 web/components/probability-input.tsx create mode 100644 web/components/use-save-binary-shares.ts delete mode 100644 web/components/use-save-shares.ts diff --git a/common/bet.ts b/common/bet.ts index 993a2fac..d5072c0f 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -4,6 +4,7 @@ export type Bet = { id: string userId: string contractId: string + createdTime: number amount: number // bet size; negative if SELL bet loanAmount?: number @@ -25,9 +26,7 @@ export type Bet = { isAnte?: boolean isLiquidityProvision?: boolean isRedemption?: boolean - - createdTime: number -} +} & Partial<LimitProps> export type NumericBet = Bet & { value: number @@ -35,4 +34,29 @@ export type NumericBet = Bet & { allBetAmounts: { [outcome: string]: number } } +// Binary market limit order. +export type LimitBet = Bet & LimitProps + +type LimitProps = { + orderAmount: number // Amount of limit order. + limitProb: number // [0, 1]. Bet to this probability. + isFilled: boolean // Whether all of the bet amount has been filled. + isCancelled: boolean // Whether to prevent any further fills. + // A record of each transaction that partially (or fully) fills the orderAmount. + // I.e. A limit order could be filled by partially matching with several bets. + // Non-limit orders can also be filled by matching with multiple limit orders. + fills: fill[] +} + +export type fill = { + // The id the bet matched against, or null if the bet was matched by the pool. + matchedBetId: string | null + amount: number + shares: number + timestamp: number + // If the fill is a sale, it means the matching bet has shares of the same outcome. + // I.e. -fill.shares === matchedBet.shares + isSale?: boolean +} + export const MAX_LOAN_PER_CONTRACT = 20 diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index 66162132..493b5fa9 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -1,10 +1,17 @@ -import { sum, groupBy, mapValues, sumBy, zip } from 'lodash' +import { sum, groupBy, mapValues, sumBy } from 'lodash' +import { LimitBet } from './bet' -import { CPMMContract } from './contract' import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees' import { LiquidityProvision } from './liquidity-provision' +import { computeFills } from './new-bet' +import { binarySearch } from './util/algos' import { addObjects } from './util/object' +export type CpmmState = { + pool: { [outcome: string]: number } + p: number +} + export function getCpmmProbability( pool: { [outcome: string]: number }, p: number @@ -14,11 +21,11 @@ export function getCpmmProbability( } export function getCpmmProbabilityAfterBetBeforeFees( - contract: CPMMContract, + state: CpmmState, outcome: string, bet: number ) { - const { pool, p } = contract + const { pool, p } = state const shares = calculateCpmmShares(pool, p, bet, outcome) const { YES: y, NO: n } = pool @@ -31,12 +38,12 @@ export function getCpmmProbabilityAfterBetBeforeFees( } export function getCpmmOutcomeProbabilityAfterBet( - contract: CPMMContract, + state: CpmmState, outcome: string, bet: number ) { - const { newPool } = calculateCpmmPurchase(contract, bet, outcome) - const p = getCpmmProbability(newPool, contract.p) + const { newPool } = calculateCpmmPurchase(state, bet, outcome) + const p = getCpmmProbability(newPool, state.p) return outcome === 'NO' ? 1 - p : p } @@ -58,12 +65,8 @@ function calculateCpmmShares( : n + bet - (k * (bet + y) ** -p) ** (1 / (1 - p)) } -export function getCpmmFees( - contract: CPMMContract, - bet: number, - outcome: string -) { - const prob = getCpmmProbabilityAfterBetBeforeFees(contract, outcome, bet) +export function getCpmmFees(state: CpmmState, bet: number, outcome: string) { + const prob = getCpmmProbabilityAfterBetBeforeFees(state, outcome, bet) const betP = outcome === 'YES' ? 1 - prob : prob const liquidityFee = LIQUIDITY_FEE * betP * bet @@ -78,23 +81,23 @@ export function getCpmmFees( } export function calculateCpmmSharesAfterFee( - contract: CPMMContract, + state: CpmmState, bet: number, outcome: string ) { - const { pool, p } = contract - const { remainingBet } = getCpmmFees(contract, bet, outcome) + const { pool, p } = state + const { remainingBet } = getCpmmFees(state, bet, outcome) return calculateCpmmShares(pool, p, remainingBet, outcome) } export function calculateCpmmPurchase( - contract: CPMMContract, + state: CpmmState, bet: number, outcome: string ) { - const { pool, p } = contract - const { remainingBet, fees } = getCpmmFees(contract, bet, outcome) + const { pool, p } = state + const { remainingBet, fees } = getCpmmFees(state, bet, outcome) const shares = calculateCpmmShares(pool, p, remainingBet, outcome) const { YES: y, NO: n } = pool @@ -113,117 +116,111 @@ export function calculateCpmmPurchase( return { shares, newPool, newP, fees } } -function computeK(y: number, n: number, p: number) { - return y ** p * n ** (1 - p) -} - -function sellSharesK( - y: number, - n: number, - p: number, - s: number, - outcome: 'YES' | 'NO', - b: number -) { - return outcome === 'YES' - ? computeK(y - b + s, n - b, p) - : computeK(y - b, n - b + s, p) -} - -function calculateCpmmShareValue( - contract: CPMMContract, - shares: number, +// Note: there might be a closed form solution for this. +// If so, feel free to switch out this implementation. +export function calculateCpmmAmountToProb( + state: CpmmState, + prob: number, outcome: 'YES' | 'NO' ) { - const { pool, p } = contract + if (outcome === 'NO') prob = 1 - prob - // Find bet amount that preserves k after selling shares. - const k = computeK(pool.YES, pool.NO, p) - const otherPool = outcome === 'YES' ? pool.NO : pool.YES + // First, find an upper bound that leads to a more extreme probability than prob. + let maxGuess = 10 + let newProb = 0 + do { + maxGuess *= 10 + newProb = getCpmmOutcomeProbabilityAfterBet(state, outcome, maxGuess) + } while (newProb < prob) - // Constrain the max sale value to the lessor of 1. shares and 2. the other pool. - // This is because 1. the max value per share is M$ 1, - // and 2. The other pool cannot go negative and the sale value is subtracted from it. - // (Without this, there are multiple solutions for the same k.) - let highAmount = Math.min(shares, otherPool) - let lowAmount = 0 - let mid = 0 - let kGuess = 0 - while (true) { - mid = lowAmount + (highAmount - lowAmount) / 2 + // Then, binary search for the amount that gets closest to prob. + const amount = binarySearch(0, maxGuess, (amount) => { + const newProb = getCpmmOutcomeProbabilityAfterBet(state, outcome, amount) + return newProb - prob + }) - // Break once we've reached max precision. - if (mid === lowAmount || mid === highAmount) break + return amount +} - kGuess = sellSharesK(pool.YES, pool.NO, p, shares, outcome, mid) - if (kGuess < k) { - highAmount = mid - } else { - lowAmount = mid - } - } - return mid +function calculateAmountToBuyShares( + state: CpmmState, + shares: number, + outcome: 'YES' | 'NO', + unfilledBets: LimitBet[] +) { + // Search for amount between bounds (0, shares). + // Min share price is M$0, and max is M$1 each. + return binarySearch(0, shares, (amount) => { + const { takers } = computeFills( + outcome, + amount, + state, + undefined, + unfilledBets + ) + + const totalShares = sumBy(takers, (taker) => taker.shares) + return totalShares - shares + }) } export function calculateCpmmSale( - contract: CPMMContract, + state: CpmmState, shares: number, - outcome: string + outcome: 'YES' | 'NO', + unfilledBets: LimitBet[] ) { if (Math.round(shares) < 0) { throw new Error('Cannot sell non-positive shares') } - const rawSaleValue = calculateCpmmShareValue( - contract, + const oppositeOutcome = outcome === 'YES' ? 'NO' : 'YES' + const buyAmount = calculateAmountToBuyShares( + state, shares, - outcome as 'YES' | 'NO' + oppositeOutcome, + unfilledBets ) - const { fees, remainingBet: saleValue } = getCpmmFees( - contract, - rawSaleValue, - outcome === 'YES' ? 'NO' : 'YES' + const { cpmmState, makers, takers, totalFees } = computeFills( + oppositeOutcome, + buyAmount, + state, + undefined, + unfilledBets ) - const { pool } = contract - const { YES: y, NO: n } = pool + // Transform buys of opposite outcome into sells. + const saleTakers = takers.map((taker) => ({ + ...taker, + // You bought opposite shares, which combine with existing shares, removing them. + shares: -taker.shares, + // Opposite shares combine with shares you are selling for M$ of shares. + // You paid taker.amount for the opposite shares. + // Take the negative because this is money you gain. + amount: -(taker.shares - taker.amount), + isSale: true, + })) - const { liquidityFee: fee } = fees + const saleValue = -sumBy(saleTakers, (taker) => taker.amount) - const [newY, newN] = - outcome === 'YES' - ? [y + shares - saleValue + fee, n - saleValue + fee] - : [y - saleValue + fee, n + shares - saleValue + fee] - - if (newY < 0 || newN < 0) { - console.log('calculateCpmmSale', { - newY, - newN, - y, - n, - shares, - saleValue, - fee, - outcome, - }) - throw new Error('Cannot sell more than in pool') + return { + saleValue, + cpmmState, + fees: totalFees, + makers, + takers: saleTakers, } - - const postBetPool = { YES: newY, NO: newN } - - const { newPool, newP } = addCpmmLiquidity(postBetPool, contract.p, fee) - - return { saleValue, newPool, newP, fees } } export function getCpmmProbabilityAfterSale( - contract: CPMMContract, + state: CpmmState, shares: number, - outcome: 'YES' | 'NO' + outcome: 'YES' | 'NO', + unfilledBets: LimitBet[] ) { - const { newPool } = calculateCpmmSale(contract, shares, outcome) - return getCpmmProbability(newPool, contract.p) + const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets) + return getCpmmProbability(cpmmState.pool, cpmmState.p) } export function getCpmmLiquidity( @@ -267,11 +264,11 @@ const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => { } export function getCpmmLiquidityPoolWeights( - contract: CPMMContract, + state: CpmmState, liquidities: LiquidityProvision[], excludeAntes: boolean ) { - const calcLiqudity = calculateLiquidityDelta(contract.p) + const calcLiqudity = calculateLiquidityDelta(state.p) const liquidityShares = liquidities.map(calcLiqudity) const shareSum = sum(liquidityShares) @@ -293,16 +290,12 @@ export function getCpmmLiquidityPoolWeights( export function getUserLiquidityShares( userId: string, - contract: CPMMContract, + state: CpmmState, liquidities: LiquidityProvision[], excludeAntes: boolean ) { - const weights = getCpmmLiquidityPoolWeights( - contract, - liquidities, - excludeAntes - ) + const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes) const userWeight = weights[userId] ?? 0 - return mapValues(contract.pool, (shares) => userWeight * shares) + return mapValues(state.pool, (shares) => userWeight * shares) } diff --git a/common/calculate.ts b/common/calculate.ts index 482a0ccf..e1f3e239 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -1,5 +1,5 @@ import { maxBy } from 'lodash' -import { Bet } from './bet' +import { Bet, LimitBet } from './bet' import { calculateCpmmSale, getCpmmProbability, @@ -24,6 +24,7 @@ import { FreeResponseContract, PseudoNumericContract, } from './contract' +import { floatingEqual } from './util/math' export function getProbability( contract: BinaryContract | PseudoNumericContract @@ -73,11 +74,20 @@ export function calculateShares( : calculateDpmShares(contract.totalShares, bet, betChoice) } -export function calculateSaleAmount(contract: Contract, bet: Bet) { +export function calculateSaleAmount( + contract: Contract, + bet: Bet, + unfilledBets: LimitBet[] +) { return contract.mechanism === 'cpmm-1' && (contract.outcomeType === 'BINARY' || contract.outcomeType === 'PSEUDO_NUMERIC') - ? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue + ? calculateCpmmSale( + contract, + Math.abs(bet.shares), + bet.outcome as 'YES' | 'NO', + unfilledBets + ).saleValue : calculateDpmSaleAmount(contract, bet) } @@ -90,10 +100,16 @@ export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) { export function getProbabilityAfterSale( contract: Contract, outcome: string, - shares: number + shares: number, + unfilledBets: LimitBet[] ) { return contract.mechanism === 'cpmm-1' - ? getCpmmProbabilityAfterSale(contract, shares, outcome as 'YES' | 'NO') + ? getCpmmProbabilityAfterSale( + contract, + shares, + outcome as 'YES' | 'NO', + unfilledBets + ) : getDpmProbabilityAfterSale(contract.totalShares, outcome, shares) } @@ -157,7 +173,9 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const profit = payout + saleValue + redeemed - totalInvested const profitPercent = (profit / totalInvested) * 100 - const hasShares = Object.values(totalShares).some((shares) => shares > 0) + const hasShares = Object.values(totalShares).some( + (shares) => !floatingEqual(shares, 0) + ) return { invested: Math.max(0, currentInvested), diff --git a/common/envs/constants.ts b/common/envs/constants.ts index c03c44bc..7092d711 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -34,5 +34,9 @@ export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE' export const CORS_ORIGIN_MANIFOLD = new RegExp( '^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$' ) +// Vercel deployments, used for testing. +export const CORS_ORIGIN_VERCEL = new RegExp( + '^https?://[a-zA-Z0-9\\-]+' + escapeRegExp('mantic.vercel.app') + '$' +) // Any localhost server on any port export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/ diff --git a/common/new-bet.ts b/common/new-bet.ts index 57739af3..6c3e6856 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,6 +1,6 @@ -import { sumBy } from 'lodash' +import { sortBy, sumBy } from 'lodash' -import { Bet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' +import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' import { calculateDpmShares, getDpmProbability, @@ -8,7 +8,12 @@ import { getNumericBets, calculateNumericDpmShares, } from './calculate-dpm' -import { calculateCpmmPurchase, getCpmmProbability } from './calculate-cpmm' +import { + calculateCpmmAmountToProb, + calculateCpmmPurchase, + CpmmState, + getCpmmProbability, +} from './calculate-cpmm' import { CPMMBinaryContract, DPMBinaryContract, @@ -17,8 +22,13 @@ import { PseudoNumericContract, } from './contract' import { noFees } from './fees' -import { addObjects } from './util/object' +import { addObjects, removeUndefinedProps } from './util/object' import { NUMERIC_FIXED_VAR } from './numeric-constants' +import { + floatingEqual, + floatingGreaterEqual, + floatingLesserEqual, +} from './util/math' export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'> export type BetInfo = { @@ -30,38 +40,203 @@ export type BetInfo = { newP?: number } -export const getNewBinaryCpmmBetInfo = ( - outcome: 'YES' | 'NO', +const computeFill = ( amount: number, - contract: CPMMBinaryContract | PseudoNumericContract, - loanAmount: number + outcome: 'YES' | 'NO', + limitProb: number | undefined, + cpmmState: CpmmState, + matchedBet: LimitBet | undefined ) => { - const { shares, newPool, newP, fees } = calculateCpmmPurchase( - contract, - amount, - outcome - ) + const prob = getCpmmProbability(cpmmState.pool, cpmmState.p) - const { pool, p, totalLiquidity } = contract - const probBefore = getCpmmProbability(pool, p) - const probAfter = getCpmmProbability(newPool, newP) - - const newBet: CandidateBet = { - contractId: contract.id, - amount, - shares, - outcome, - fees, - loanAmount, - probBefore, - probAfter, - createdTime: Date.now(), + if ( + limitProb !== undefined && + (outcome === 'YES' + ? floatingGreaterEqual(prob, limitProb) && + (matchedBet?.limitProb ?? 1) > limitProb + : floatingLesserEqual(prob, limitProb) && + (matchedBet?.limitProb ?? 0) < limitProb) + ) { + // No fill. + return undefined } - const { liquidityFee } = fees - const newTotalLiquidity = (totalLiquidity ?? 0) + liquidityFee + const timestamp = Date.now() - return { newBet, newPool, newP, newTotalLiquidity } + if ( + !matchedBet || + (outcome === 'YES' + ? prob < matchedBet.limitProb + : prob > matchedBet.limitProb) + ) { + // Fill from pool. + const limit = !matchedBet + ? limitProb + : outcome === 'YES' + ? Math.min(matchedBet.limitProb, limitProb ?? 1) + : Math.max(matchedBet.limitProb, limitProb ?? 0) + + const buyAmount = + limit === undefined + ? amount + : Math.min(amount, calculateCpmmAmountToProb(cpmmState, limit, outcome)) + + const { shares, newPool, newP, fees } = calculateCpmmPurchase( + cpmmState, + buyAmount, + outcome + ) + const newState = { pool: newPool, p: newP } + + return { + maker: { + matchedBetId: null, + shares, + amount: buyAmount, + state: newState, + fees, + timestamp, + }, + taker: { + matchedBetId: null, + shares, + amount: buyAmount, + timestamp, + }, + } + } + + // Fill from matchedBet. + const matchRemaining = matchedBet.orderAmount - matchedBet.amount + const shares = Math.min( + amount / + (outcome === 'YES' ? matchedBet.limitProb : 1 - matchedBet.limitProb), + matchRemaining / + (outcome === 'YES' ? 1 - matchedBet.limitProb : matchedBet.limitProb) + ) + + const maker = { + bet: matchedBet, + matchedBetId: 'taker', + amount: + shares * + (outcome === 'YES' ? 1 - matchedBet.limitProb : matchedBet.limitProb), + shares, + timestamp, + } + const taker = { + matchedBetId: matchedBet.id, + amount: + shares * + (outcome === 'YES' ? matchedBet.limitProb : 1 - matchedBet.limitProb), + shares, + timestamp, + } + return { maker, taker } +} + +export const computeFills = ( + outcome: 'YES' | 'NO', + betAmount: number, + state: CpmmState, + limitProb: number | undefined, + unfilledBets: LimitBet[] +) => { + const sortedBets = sortBy( + unfilledBets.filter((bet) => bet.outcome !== outcome), + (bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb), + (bet) => bet.createdTime + ) + + const takers: fill[] = [] + const makers: { + bet: LimitBet + amount: number + shares: number + timestamp: number + }[] = [] + + let amount = betAmount + let cpmmState = { pool: state.pool, p: state.p } + let totalFees = noFees + + let i = 0 + while (true) { + const matchedBet: LimitBet | undefined = sortedBets[i] + const fill = computeFill(amount, outcome, limitProb, cpmmState, matchedBet) + if (!fill) break + + const { taker, maker } = fill + + if (maker.matchedBetId === null) { + // Matched against pool. + cpmmState = maker.state + totalFees = addObjects(totalFees, maker.fees) + takers.push(taker) + } else { + // Matched against bet. + takers.push(taker) + makers.push(maker) + i++ + } + + amount -= taker.amount + + if (floatingEqual(amount, 0)) break + } + + return { takers, makers, totalFees, cpmmState } +} + +export const getBinaryCpmmBetInfo = ( + outcome: 'YES' | 'NO', + betAmount: number, + contract: CPMMBinaryContract | PseudoNumericContract, + limitProb: number | undefined, + unfilledBets: LimitBet[] +) => { + const { pool, p } = contract + const { takers, makers, cpmmState, totalFees } = computeFills( + outcome, + betAmount, + { pool, p }, + limitProb, + unfilledBets + ) + const probBefore = getCpmmProbability(contract.pool, contract.p) + const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) + + const takerAmount = sumBy(takers, 'amount') + const takerShares = sumBy(takers, 'shares') + const isFilled = floatingEqual(betAmount, takerAmount) + + const newBet: CandidateBet = removeUndefinedProps({ + orderAmount: betAmount, + amount: takerAmount, + shares: takerShares, + limitProb, + isFilled, + isCancelled: false, + fills: takers, + contractId: contract.id, + outcome, + probBefore, + probAfter, + loanAmount: 0, + createdTime: Date.now(), + fees: totalFees, + }) + + const { liquidityFee } = totalFees + const newTotalLiquidity = (contract.totalLiquidity ?? 0) + liquidityFee + + return { + newBet, + newPool: cpmmState.pool, + newP: cpmmState.p, + newTotalLiquidity, + makers, + } } export const getNewBinaryDpmBetInfo = ( diff --git a/common/notification.ts b/common/notification.ts index da8a045a..63a44a52 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -62,3 +62,4 @@ export type notification_reason_types = | 'unique_bettors_on_your_contract' | 'on_group_you_are_member_of' | 'tip_received' + | 'bet_fill' diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 6d487ff2..e1fd9c5d 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -1,4 +1,4 @@ -import { Bet } from './bet' +import { Bet, LimitBet } from './bet' import { calculateDpmShareValue, deductDpmFees, @@ -7,6 +7,7 @@ import { import { calculateCpmmSale, getCpmmProbability } from './calculate-cpmm' import { CPMMContract, DPMContract } from './contract' import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees' +import { sumBy } from 'lodash' export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'> @@ -78,19 +79,24 @@ export const getCpmmSellBetInfo = ( shares: number, outcome: 'YES' | 'NO', contract: CPMMContract, - prevLoanAmount: number + prevLoanAmount: number, + unfilledBets: LimitBet[] ) => { const { pool, p } = contract - const { saleValue, newPool, newP, fees } = calculateCpmmSale( + const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale( contract, shares, - outcome + outcome, + unfilledBets ) const loanPaid = Math.min(prevLoanAmount, saleValue) const probBefore = getCpmmProbability(pool, p) - const probAfter = getCpmmProbability(newPool, p) + const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) + + const takerAmount = sumBy(takers, 'amount') + const takerShares = sumBy(takers, 'shares') console.log( 'SELL M$', @@ -104,20 +110,26 @@ export const getCpmmSellBetInfo = ( const newBet: CandidateBet<Bet> = { contractId: contract.id, - amount: -saleValue, - shares: -shares, + amount: takerAmount, + shares: takerShares, outcome, probBefore, probAfter, createdTime: Date.now(), loanAmount: -loanPaid, fees, + fills: takers, + isFilled: true, + isCancelled: false, + orderAmount: takerAmount, } return { newBet, - newPool, - newP, + newPool: cpmmState.pool, + newP: cpmmState.p, fees, + makers, + takers, } } diff --git a/common/util/algos.ts b/common/util/algos.ts new file mode 100644 index 00000000..dd450075 --- /dev/null +++ b/common/util/algos.ts @@ -0,0 +1,22 @@ +export function binarySearch( + min: number, + max: number, + comparator: (x: number) => number +) { + let mid = 0 + while (true) { + mid = min + (max - min) / 2 + + // Break once we've reached max precision. + if (mid === min || mid === max) break + + const comparison = comparator(mid) + if (comparison === 0) break + else if (comparison > 0) { + max = mid + } else { + min = mid + } + } + return mid +} diff --git a/common/util/math.ts b/common/util/math.ts index 66bcff1b..fb07afed 100644 --- a/common/util/math.ts +++ b/common/util/math.ts @@ -34,3 +34,17 @@ export function median(xs: number[]) { export function average(xs: number[]) { return sum(xs) / xs.length } + +const EPSILON = 0.00000001 + +export function floatingEqual(a: number, b: number, epsilon = EPSILON) { + return Math.abs(a - b) < epsilon +} + +export function floatingGreaterEqual(a: number, b: number, epsilon = EPSILON) { + return a + epsilon >= b +} + +export function floatingLesserEqual(a: number, b: number, epsilon = EPSILON) { + return a - epsilon <= b +} diff --git a/functions/src/api.ts b/functions/src/api.ts index 290ea3d8..6ebffc24 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -8,6 +8,7 @@ import { PrivateUser } from '../../common/user' import { CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST, + CORS_ORIGIN_VERCEL, } from '../../common/envs/constants' type Output = Record<string, unknown> @@ -118,7 +119,7 @@ const DEFAULT_OPTS = { concurrency: 100, memory: '2GiB', cpu: 1, - cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], + cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_VERCEL, CORS_ORIGIN_LOCALHOST], } export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { diff --git a/functions/src/cancel-bet.ts b/functions/src/cancel-bet.ts new file mode 100644 index 00000000..27e65ffb --- /dev/null +++ b/functions/src/cancel-bet.ts @@ -0,0 +1,35 @@ +import * as admin from 'firebase-admin' +import { z } from 'zod' +import { APIError, newEndpoint, validate } from './api' +import { LimitBet } from '../../common/bet' + +const bodySchema = z.object({ + betId: z.string(), +}) + +export const cancelbet = newEndpoint({}, async (req, auth) => { + const { betId } = validate(bodySchema, req.body) + + const result = await firestore.runTransaction(async (trans) => { + const snap = await trans.get( + firestore.collectionGroup('bets').where('id', '==', betId) + ) + const betDoc = snap.docs[0] + if (!betDoc?.exists) throw new APIError(400, 'Bet not found.') + + const bet = betDoc.data() as LimitBet + if (bet.userId !== auth.uid) + throw new APIError(400, 'Not authorized to cancel bet.') + if (bet.limitProb === undefined) + throw new APIError(400, 'Not a limit bet: Cannot cancel.') + if (bet.isCancelled) throw new APIError(400, 'Bet already cancelled.') + + trans.update(betDoc.ref, { isCancelled: true }) + + return { ...bet, isCancelled: true } + }) + + return result +}) + +const firestore = admin.firestore() diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 519720fd..0d3432a7 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -10,7 +10,7 @@ import { Contract } from '../../common/contract' import { getUserByUsername, getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' -import { Bet } from '../../common/bet' +import { Bet, LimitBet } from '../../common/bet' import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' @@ -382,3 +382,37 @@ export const createTipNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } + +export const createBetFillNotification = async ( + fromUser: User, + toUser: User, + bet: Bet, + userBet: LimitBet, + contract: Contract, + idempotencyKey: string +) => { + const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id) + const fillAmount = fill?.amount ?? 0 + + const notificationRef = firestore + .collection(`/users/${toUser.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUser.id, + reason: 'bet_fill', + createdTime: Date.now(), + isSeen: false, + sourceId: userBet.id, + sourceType: 'bet', + sourceUpdateType: 'updated', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: fillAmount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 35f29954..0d0de3ba 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -31,6 +31,7 @@ export * from './transact' export * from './change-user-info' export * from './create-answer' export * from './place-bet' +export * from './cancel-bet' export * from './sell-bet' export * from './sell-shares' export * from './claim-manalink' diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 3e615e42..5789ed0b 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -1,7 +1,11 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { keyBy } from 'lodash' -import { Bet } from '../../common/bet' +import { Bet, LimitBet } from '../../common/bet' +import { getContract, getUser, getValues } from './utils' +import { createBetFillNotification } from './create-notification' +import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() @@ -11,6 +15,8 @@ export const onCreateBet = functions.firestore const { contractId } = context.params as { contractId: string } + const { eventId } = context + const bet = change.data() as Bet const lastBetTime = bet.createdTime @@ -18,4 +24,47 @@ export const onCreateBet = functions.firestore .collection('contracts') .doc(contractId) .update({ lastBetTime, lastUpdatedTime: Date.now() }) + + await notifyFills(bet, contractId, eventId) }) + +const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { + if (!bet.fills) return + + const user = await getUser(bet.userId) + if (!user) return + const contract = await getContract(contractId) + if (!contract) return + + const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null) + const matchedBets = ( + await Promise.all( + matchedFills.map((fill) => + getValues<LimitBet>( + firestore.collectionGroup('bets').where('id', '==', fill.matchedBetId) + ) + ) + ) + ).flat() + + const betUsers = await Promise.all( + matchedBets.map((bet) => getUser(bet.userId)) + ) + const betUsersById = keyBy(filterDefined(betUsers), 'id') + + await Promise.all( + matchedBets.map((matchedBet) => { + const matchedUser = betUsersById[matchedBet.userId] + if (!matchedUser) return + + return createBetFillNotification( + user, + matchedUser, + bet, + matchedBet, + contract, + eventId + ) + }) + ) +} diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts index b6ba6e0b..0ace3c53 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -5,6 +5,8 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' import { createNotification } from './create-notification' import { ReferralTxn } from '../../common/txn' import { Contract } from '../../common/contract' +import { LimitBet } from 'common/bet' +import { QuerySnapshot } from 'firebase-admin/firestore' const firestore = admin.firestore() export const onUpdateUser = functions.firestore @@ -17,6 +19,10 @@ 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) { @@ -109,3 +115,15 @@ 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 43906f3c..52daf953 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -1,17 +1,25 @@ import * as admin from 'firebase-admin' import { z } from 'zod' +import { + DocumentReference, + FieldValue, + Query, + Transaction, +} from 'firebase-admin/firestore' +import { groupBy, mapValues, sumBy } from 'lodash' import { APIError, newEndpoint, validate } from './api' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { User } from '../../common/user' import { BetInfo, - getNewBinaryCpmmBetInfo, - getNewBinaryDpmBetInfo, + getBinaryCpmmBetInfo, getNewMultiBetInfo, getNumericBetsInfo, } from '../../common/new-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' +import { LimitBet } from '../../common/bet' +import { floatingEqual } from '../../common/util/math' import { redeemShares } from './redeem-shares' import { log } from './utils' @@ -22,6 +30,7 @@ const bodySchema = z.object({ const binarySchema = z.object({ outcome: z.enum(['YES', 'NO']), + limitProb: z.number().gte(0.001).lte(0.999).optional(), }) const freeResponseSchema = z.object({ @@ -63,16 +72,30 @@ export const placebet = newEndpoint({}, async (req, auth) => { newTotalBets, newTotalLiquidity, newP, - } = await (async (): Promise<BetInfo> => { - if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { - const { outcome } = validate(binarySchema, req.body) - return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount) - } else if ( - (outcomeType == 'BINARY' || outcomeType == 'PSEUDO_NUMERIC') && + makers, + } = await (async (): Promise< + BetInfo & { + makers?: maker[] + } + > => { + if ( + (outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && mechanism == 'cpmm-1' ) { - const { outcome } = validate(binarySchema, req.body) - return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount) + const { outcome, limitProb } = validate(binarySchema, req.body) + + const unfilledBetsSnap = await trans.get( + getUnfilledBetsQuery(contractDoc) + ) + const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + + return getBinaryCpmmBetInfo( + outcome, + amount, + contract, + limitProb, + unfilledBets + ) } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { const { outcome } = validate(freeResponseSchema, req.body) const answerDoc = contractDoc.collection('answers').doc(outcome) @@ -97,11 +120,15 @@ export const placebet = newEndpoint({}, async (req, auth) => { throw new APIError(400, 'Bet too large for current liquidity pool.') } - const newBalance = user.balance - amount - loanAmount const betDoc = contractDoc.collection('bets').doc() trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet }) log('Created new bet document.') - trans.update(userDoc, { balance: newBalance }) + + if (makers) { + updateMakers(makers, betDoc.id, contractDoc, trans) + } + + trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) }) log('Updated user balance.') trans.update( contractDoc, @@ -112,7 +139,7 @@ export const placebet = newEndpoint({}, async (req, auth) => { totalBets: newTotalBets, totalLiquidity: newTotalLiquidity, collectedFees: addObjects(newBet.fees, collectedFees), - volume: volume + amount, + volume: volume + newBet.amount, }) ) log('Updated contract properties.') @@ -127,3 +154,54 @@ export const placebet = newEndpoint({}, async (req, auth) => { }) const firestore = admin.firestore() + +export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { + return contractDoc + .collection('bets') + .where('isFilled', '==', false) + .where('isCancelled', '==', false) as Query<LimitBet> +} + +type maker = { + bet: LimitBet + amount: number + shares: number + timestamp: number +} +export const updateMakers = ( + makers: maker[], + takerBetId: string, + contractDoc: DocumentReference, + trans: Transaction +) => { + const makersByBet = groupBy(makers, (maker) => maker.bet.id) + for (const makers of Object.values(makersByBet)) { + const bet = makers[0].bet + const newFills = makers.map((maker) => { + const { amount, shares, timestamp } = maker + return { amount, shares, matchedBetId: takerBetId, timestamp } + }) + const fills = [...bet.fills, ...newFills] + const totalShares = sumBy(fills, 'shares') + const totalAmount = sumBy(fills, 'amount') + const isFilled = floatingEqual(totalAmount, bet.orderAmount) + + log('Updated a matched limit bet.') + trans.update(contractDoc.collection('bets').doc(bet.id), { + fills, + isFilled, + amount: totalAmount, + shares: totalShares, + }) + } + + // Deduct balance of makers. + const spentByUser = mapValues( + groupBy(makers, (maker) => maker.bet.userId), + (makers) => sumBy(makers, (maker) => maker.amount) + ) + for (const [userId, spent] of Object.entries(spentByUser)) { + const userDoc = firestore.collection('users').doc(userId) + trans.update(userDoc, { balance: FieldValue.increment(-spent) }) + } +} diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 62e43105..3407760b 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -9,6 +9,9 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' import { getValues } from './utils' import { Bet } from '../../common/bet' +import { floatingLesserEqual } from '../../common/util/math' +import { getUnfilledBetsQuery, updateMakers } from './place-bet' +import { FieldValue } from 'firebase-admin/firestore' const bodySchema = z.object({ contractId: z.string(), @@ -46,14 +49,22 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const outcomeBets = userBets.filter((bet) => bet.outcome == outcome) const maxShares = sumBy(outcomeBets, (bet) => bet.shares) - if (shares > maxShares) + if (!floatingLesserEqual(shares, maxShares)) throw new APIError(400, `You can only sell up to ${maxShares} shares.`) - const { newBet, newPool, newP, fees } = getCpmmSellBetInfo( - shares, + const soldShares = Math.min(shares, maxShares) + + const unfilledBetsSnap = await transaction.get( + getUnfilledBetsQuery(contractDoc) + ) + const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + + const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( + soldShares, outcome, contract, - prevLoanAmount + prevLoanAmount, + unfilledBets ) if ( @@ -65,11 +76,17 @@ export const sellshares = newEndpoint({}, async (req, auth) => { } const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc() - const newBalance = user.balance - newBet.amount + (newBet.loanAmount ?? 0) - const userId = user.id - transaction.update(userDoc, { balance: newBalance }) - transaction.create(newBetDoc, { id: newBetDoc.id, userId, ...newBet }) + updateMakers(makers, newBetDoc.id, contractDoc, transaction) + + transaction.update(userDoc, { + balance: FieldValue.increment(-newBet.amount), + }) + transaction.create(newBetDoc, { + id: newBetDoc.id, + userId: user.id, + ...newBet, + }) transaction.update( contractDoc, removeUndefinedProps({ diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index a43f6f12..271eeecc 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,13 +1,10 @@ import clsx from 'clsx' import React, { useEffect, useState } from 'react' import { partition, sumBy } from 'lodash' +import { SwitchHorizontalIcon } from '@heroicons/react/solid' import { useUser } from 'web/hooks/use-user' -import { - BinaryContract, - CPMMBinaryContract, - PseudoNumericContract, -} from 'common/contract' +import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' @@ -18,20 +15,16 @@ import { formatPercent, formatWithCommas, } from 'common/util/format' +import { getBinaryCpmmBetInfo } from 'common/new-bet' import { Title } from './title' import { User } from 'web/lib/firebase/users' -import { Bet } from 'common/bet' +import { Bet, LimitBet } from 'common/bet' import { APIError, placeBet } from 'web/lib/firebase/api-call' import { sellShares } from 'web/lib/firebase/api-call' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' -import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' -import { - calculatePayoutAfterCorrectBet, - calculateShares, - getProbability, - getOutcomeProbabilityAfterBet, -} from 'common/calculate' +import { BinaryOutcomeLabel } from './outcome-label' +import { getProbability } from 'common/calculate' import { useFocus } from 'web/hooks/use-focus' import { useUserContractBets } from 'web/hooks/use-user-bets' import { @@ -39,178 +32,153 @@ import { getCpmmProbability, getCpmmFees, } from 'common/calculate-cpmm' -import { getFormattedMappedValue } from 'common/pseudo-numeric' +import { + getFormattedMappedValue, + getPseudoProbability, +} from 'common/pseudo-numeric' import { SellRow } from './sell-row' -import { useSaveShares } from './use-save-shares' +import { useSaveBinaryShares } from './use-save-binary-shares' import { SignUpPrompt } from './sign-up-prompt' import { isIOS } from 'web/lib/util/device' +import { ProbabilityInput } from './probability-input' import { track } from 'web/lib/service/analytics' +import { removeUndefinedProps } from 'common/util/object' +import { useUnfilledBets } from 'web/hooks/use-bets' +import { LimitBets } from './limit-bets' +import { BucketInput } from './bucket-input' export function BetPanel(props: { - contract: BinaryContract | PseudoNumericContract + contract: CPMMBinaryContract | PseudoNumericContract className?: string }) { const { contract, className } = props const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) - const { yesFloorShares, noFloorShares } = useSaveShares(contract, userBets) - const sharesOutcome = yesFloorShares - ? 'YES' - : noFloorShares - ? 'NO' - : undefined + const unfilledBets = useUnfilledBets(contract.id) ?? [] + const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id) + const { sharesOutcome } = useSaveBinaryShares(contract, userBets) + + const [isLimitOrder, setIsLimitOrder] = useState(false) return ( <Col className={className}> <SellRow contract={contract} user={user} - className={'rounded-t-md bg-gray-100 px-6 py-6'} + className={'rounded-t-md bg-gray-100 px-4 py-5'} /> <Col className={clsx( - 'rounded-b-md bg-white px-8 py-6', + 'relative rounded-b-md bg-white px-8 py-6', !sharesOutcome && 'rounded-t-md', className )} > - <div className="mb-6 text-2xl">Place your bet</div> - {/* <Title className={clsx('!mt-0 text-neutral')} text="Place a trade" /> */} + <Row className="align-center justify-between"> + <div className="mb-6 text-2xl"> + {isLimitOrder ? <>Limit bet</> : <>Place your bet</>} + </div> + <button + className="btn btn-ghost btn-sm text-sm normal-case" + onClick={() => setIsLimitOrder(!isLimitOrder)} + > + <SwitchHorizontalIcon className="inline h-6 w-6" /> + </button> + </Row> - <BuyPanel contract={contract} user={user} /> + <BuyPanel + contract={contract} + user={user} + isLimitOrder={isLimitOrder} + unfilledBets={unfilledBets} + /> <SignUpPrompt /> </Col> + {yourUnfilledBets.length > 0 && ( + <LimitBets + className="mt-4" + contract={contract} + bets={yourUnfilledBets} + /> + )} </Col> ) } -export function BetPanelSwitcher(props: { - contract: BinaryContract | PseudoNumericContract +export function SimpleBetPanel(props: { + contract: CPMMBinaryContract | PseudoNumericContract className?: string - title?: string // Set if BetPanel is on a feed modal selected?: 'YES' | 'NO' onBetSuccess?: () => void }) { - const { contract, className, title, selected, onBetSuccess } = props - - const { mechanism, outcomeType } = contract - const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + const { contract, className, selected, onBetSuccess } = props const user = useUser() - const userBets = useUserContractBets(user?.id, contract.id) + const [isLimitOrder, setIsLimitOrder] = useState(false) - const [tradeType, setTradeType] = useState<'BUY' | 'SELL'>('BUY') - - const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( - contract, - userBets - ) - - const floorShares = yesFloorShares || noFloorShares - const sharesOutcome = yesFloorShares - ? 'YES' - : noFloorShares - ? 'NO' - : undefined - - useEffect(() => { - // Switch back to BUY if the user has sold all their shares. - if (tradeType === 'SELL' && sharesOutcome === undefined) { - setTradeType('BUY') - } - }, [tradeType, sharesOutcome]) + const unfilledBets = useUnfilledBets(contract.id) ?? [] + const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id) return ( <Col className={className}> - {sharesOutcome && mechanism === 'cpmm-1' && ( - <Col className="rounded-t-md bg-gray-100 px-6 py-6"> - <Row className="items-center justify-between gap-2"> - <div> - You have {formatWithCommas(floorShares)}{' '} - {isPseudoNumeric ? ( - <PseudoNumericOutcomeLabel outcome={sharesOutcome} /> - ) : ( - <BinaryOutcomeLabel outcome={sharesOutcome} /> - )}{' '} - shares - </div> - - {tradeType === 'BUY' && ( - <button - className="btn btn-sm" - style={{ - backgroundColor: 'white', - border: '2px solid', - color: '#3D4451', - }} - onClick={() => - tradeType === 'BUY' - ? setTradeType('SELL') - : setTradeType('BUY') - } - > - {tradeType === 'BUY' ? 'Sell' : 'Bet'} - </button> - )} - </Row> - </Col> - )} - - <Col - className={clsx( - 'rounded-b-md bg-white px-8 py-6', - !sharesOutcome && 'rounded-t-md' - )} - > - <Title - className={clsx( - '!mt-0', - tradeType === 'BUY' && title ? '!text-xl' : '' - )} - text={tradeType === 'BUY' ? title ?? 'Place a trade' : 'Sell shares'} - /> - - {tradeType === 'SELL' && - mechanism == 'cpmm-1' && - user && - sharesOutcome && ( - <SellPanel - contract={contract} - shares={yesShares || noShares} - sharesOutcome={sharesOutcome} - user={user} - userBets={userBets ?? []} - onSellSuccess={onBetSuccess} - /> - )} - - {tradeType === 'BUY' && ( - <BuyPanel - contract={contract} - user={user} - selected={selected} - onBuySuccess={onBetSuccess} + <Col className={clsx('rounded-b-md rounded-t-md bg-white px-8 py-6')}> + <Row className="justify-between"> + <Title + className={clsx('!mt-0')} + text={isLimitOrder ? 'Limit bet' : 'Place a trade'} /> - )} + + <button + className="btn btn-ghost btn-sm text-sm normal-case" + onClick={() => setIsLimitOrder(!isLimitOrder)} + > + <SwitchHorizontalIcon className="inline h-6 w-6" /> + </button> + </Row> + + <BuyPanel + contract={contract} + user={user} + unfilledBets={unfilledBets} + selected={selected} + onBuySuccess={onBetSuccess} + isLimitOrder={isLimitOrder} + /> <SignUpPrompt /> </Col> + + {yourUnfilledBets.length > 0 && ( + <LimitBets + className="mt-4" + contract={contract} + bets={yourUnfilledBets} + /> + )} </Col> ) } function BuyPanel(props: { - contract: BinaryContract | PseudoNumericContract + contract: CPMMBinaryContract | PseudoNumericContract user: User | null | undefined + unfilledBets: Bet[] + isLimitOrder?: boolean selected?: 'YES' | 'NO' onBuySuccess?: () => void }) { - const { contract, user, selected, onBuySuccess } = props + const { contract, user, unfilledBets, isLimitOrder, selected, onBuySuccess } = + props + + const initialProb = getProbability(contract) const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected) const [betAmount, setBetAmount] = useState<number | undefined>(undefined) + const [limitProb, setLimitProb] = useState<number | undefined>( + Math.round(100 * initialProb) + ) const [error, setError] = useState<string | undefined>() const [isSubmitting, setIsSubmitting] = useState(false) const [wasSubmitted, setWasSubmitted] = useState(false) @@ -240,15 +208,22 @@ function BuyPanel(props: { async function submitBet() { if (!user || !betAmount) return + if (isLimitOrder && limitProb === undefined) return + + const limitProbScaled = + isLimitOrder && limitProb !== undefined ? limitProb / 100 : undefined setError(undefined) setIsSubmitting(true) - placeBet({ - amount: betAmount, - outcome: betChoice, - contractId: contract.id, - }) + placeBet( + removeUndefinedProps({ + amount: betAmount, + outcome: betChoice, + contractId: contract.id, + limitProb: limitProbScaled, + }) + ) .then((r) => { console.log('placed bet. Result:', r) setIsSubmitting(false) @@ -278,42 +253,31 @@ function BuyPanel(props: { const betDisabled = isSubmitting || !betAmount || error - const initialProb = getProbability(contract) + const limitProbFrac = (limitProb ?? 0) / 100 - const outcomeProb = getOutcomeProbabilityAfterBet( + const { newPool, newP, newBet } = getBinaryCpmmBetInfo( + betChoice ?? 'YES', + betAmount ?? 0, contract, - betChoice || 'YES', - betAmount ?? 0 + isLimitOrder ? limitProbFrac : undefined, + unfilledBets as LimitBet[] ) - const resultProb = betChoice === 'NO' ? 1 - outcomeProb : outcomeProb - const shares = calculateShares(contract, betAmount ?? 0, betChoice || 'YES') - - const currentPayout = betAmount - ? calculatePayoutAfterCorrectBet(contract, { - outcome: betChoice, - amount: betAmount, - shares, - } as Bet) + const resultProb = getCpmmProbability(newPool, newP) + const remainingMatched = isLimitOrder + ? ((newBet.orderAmount ?? 0) - newBet.amount) / + (betChoice === 'YES' ? limitProbFrac : 1 - limitProbFrac) : 0 + const currentPayout = newBet.shares + remainingMatched const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = formatPercent(currentReturn) - const cpmmFees = - contract.mechanism === 'cpmm-1' && - getCpmmFees(contract, betAmount ?? 0, betChoice ?? 'YES').totalFees - - const dpmTooltip = - contract.mechanism === 'dpm-2' - ? `Current payout for ${formatWithCommas(shares)} / ${formatWithCommas( - shares + - contract.totalShares[betChoice ?? 'YES'] - - (contract.phantomShares - ? contract.phantomShares[betChoice ?? 'YES'] - : 0) - )} ${betChoice ?? 'YES'} shares` - : undefined + const cpmmFees = getCpmmFees( + contract, + betAmount ?? 0, + betChoice ?? 'YES' + ).totalFees const format = getFormattedMappedValue(contract) @@ -336,29 +300,62 @@ function BuyPanel(props: { disabled={isSubmitting} inputRef={inputRef} /> - + {isLimitOrder && ( + <> + <Row className="my-3 items-center gap-2 text-left text-sm text-gray-500"> + Limit {isPseudoNumeric ? 'value' : 'probability'} + <InfoTooltip + text={`Bet ${betChoice === 'NO' ? 'down' : 'up'} to this ${ + isPseudoNumeric ? 'value' : 'probability' + } and wait to match other bets.`} + /> + </Row> + {isPseudoNumeric ? ( + <BucketInput + contract={contract} + onBucketChange={(value) => + setLimitProb( + value === undefined + ? undefined + : 100 * + getPseudoProbability( + value, + contract.min, + contract.max, + contract.isLogScale + ) + ) + } + isSubmitting={isSubmitting} + /> + ) : ( + <ProbabilityInput + inputClassName="w-full max-w-none" + prob={limitProb} + onChange={setLimitProb} + disabled={isSubmitting} + /> + )} + </> + )} <Col className="mt-3 w-full gap-3"> - <Row className="items-center justify-between text-sm"> - <div className="text-gray-500"> - {isPseudoNumeric ? 'Estimated value' : 'Probability'} - </div> - <div> - {format(initialProb)} - <span className="mx-2">→</span> - {format(resultProb)} - </div> - </Row> + {!isLimitOrder && ( + <Row className="items-center justify-between text-sm"> + <div className="text-gray-500"> + {isPseudoNumeric ? 'Estimated value' : 'Probability'} + </div> + <div> + {format(initialProb)} + <span className="mx-2">→</span> + {format(resultProb)} + </div> + </Row> + )} <Row className="items-center justify-between gap-2 text-sm"> <Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500"> <div> - {contract.mechanism === 'dpm-2' ? ( - <> - Estimated - <br /> payout if{' '} - <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} /> - </> - ) : isPseudoNumeric ? ( + {isPseudoNumeric ? ( 'Max payout' ) : ( <> @@ -366,14 +363,9 @@ function BuyPanel(props: { </> )} </div> - - {cpmmFees !== false && ( - <InfoTooltip - text={`Includes ${formatMoneyWithDecimals(cpmmFees)} in fees`} - /> - )} - - {dpmTooltip && <InfoTooltip text={dpmTooltip} />} + <InfoTooltip + text={`Includes ${formatMoneyWithDecimals(cpmmFees)} in fees`} + /> </Row> <div> <span className="mr-2 whitespace-nowrap"> @@ -424,19 +416,21 @@ export function SellPanel(props: { const [isSubmitting, setIsSubmitting] = useState(false) const [wasSubmitted, setWasSubmitted] = useState(false) + const unfilledBets = useUnfilledBets(contract.id) ?? [] + const betDisabled = isSubmitting || !amount || error + // Sell all shares if remaining shares would be < 1 + const sellQuantity = amount === Math.floor(shares) ? shares : amount + async function submitSell() { if (!user || !amount) return setError(undefined) setIsSubmitting(true) - // Sell all shares if remaining shares would be < 1 - const sellAmount = amount === Math.floor(shares) ? shares : amount - await sellShares({ - shares: sellAmount, + shares: sellQuantity, outcome: sharesOutcome, contractId: contract.id, }) @@ -461,18 +455,19 @@ export function SellPanel(props: { outcomeType: contract.outcomeType, slug: contract.slug, contractId: contract.id, - shares: sellAmount, + shares: sellQuantity, outcome: sharesOutcome, }) } const initialProb = getProbability(contract) - const { newPool } = calculateCpmmSale( + const { cpmmState, saleValue } = calculateCpmmSale( contract, - Math.min(amount ?? 0, shares), - sharesOutcome + sellQuantity ?? 0, + sharesOutcome, + unfilledBets ) - const resultProb = getCpmmProbability(newPool, contract.p) + const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p) const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale) const [yesBets, noBets] = partition( @@ -484,17 +479,8 @@ export function SellPanel(props: { sumBy(noBets, (bet) => bet.shares), ] - const sellOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined const ownedShares = Math.round(yesShares) || Math.round(noShares) - const sharesSold = Math.min(amount ?? 0, ownedShares) - - const { saleValue } = calculateCpmmSale( - contract, - sharesSold, - sellOutcome as 'YES' | 'NO' - ) - const onAmountChange = (amount: number | undefined) => { setAmount(amount) diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index ae5e0b00..712d4a2c 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -1,18 +1,18 @@ import { useState } from 'react' import clsx from 'clsx' -import { BetPanelSwitcher } from './bet-panel' +import { SimpleBetPanel } from './bet-panel' import { YesNoSelector } from './yes-no-selector' -import { BinaryContract, PseudoNumericContract } from 'common/contract' +import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { Modal } from './layout/modal' import { SellButton } from './sell-button' import { useUser } from 'web/hooks/use-user' import { useUserContractBets } from 'web/hooks/use-user-bets' -import { useSaveShares } from './use-save-shares' +import { useSaveBinaryShares } from './use-save-binary-shares' // Inline version of a bet panel. Opens BetPanel in a new modal. export default function BetRow(props: { - contract: BinaryContract | PseudoNumericContract + contract: CPMMBinaryContract | PseudoNumericContract className?: string btnClassName?: string betPanelClassName?: string @@ -24,10 +24,8 @@ export default function BetRow(props: { ) const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) - const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( - contract, - userBets - ) + const { yesShares, noShares, hasYesShares, hasNoShares } = + useSaveBinaryShares(contract, userBets) return ( <> @@ -40,7 +38,7 @@ export default function BetRow(props: { setBetChoice(choice) }} replaceNoButton={ - yesFloorShares > 0 ? ( + hasYesShares ? ( <SellButton panelClassName={betPanelClassName} contract={contract} @@ -51,7 +49,7 @@ export default function BetRow(props: { ) : undefined } replaceYesButton={ - noFloorShares > 0 ? ( + hasNoShares ? ( <SellButton panelClassName={betPanelClassName} contract={contract} @@ -63,10 +61,9 @@ export default function BetRow(props: { } /> <Modal open={open} setOpen={setOpen}> - <BetPanelSwitcher + <SimpleBetPanel className={betPanelClassName} contract={contract} - title={contract.question} selected={betChoice} onBetSuccess={() => setOpen(false)} /> diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index b8fb7d31..72ac23db 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -44,6 +44,9 @@ import { NumericContract } from 'common/contract' import { formatNumericProbability } from 'common/pseudo-numeric' import { useUser } from 'web/hooks/use-user' import { SellSharesModal } from './sell-modal' +import { useUnfilledBets } from 'web/hooks/use-bets' +import { LimitBet } from 'common/bet' +import { floatingEqual } from 'common/util/math' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'sold' | 'closed' | 'resolved' | 'all' @@ -390,6 +393,12 @@ export function BetsSummary(props: { const [showSellModal, setShowSellModal] = useState(false) const user = useUser() + const sharesOutcome = floatingEqual(totalShares.YES, 0) + ? floatingEqual(totalShares.NO, 0) + ? undefined + : 'NO' + : 'YES' + return ( <Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}> <Row className="flex-wrap gap-4 sm:gap-6"> @@ -469,6 +478,7 @@ export function BetsSummary(props: { !isClosed && !resolution && hasShares && + sharesOutcome && user && ( <> <button @@ -482,8 +492,8 @@ export function BetsSummary(props: { contract={contract} user={user} userBets={bets} - shares={totalShares.YES || totalShares.NO} - sharesOutcome={totalShares.YES ? 'YES' : 'NO'} + shares={totalShares[sharesOutcome]} + sharesOutcome={sharesOutcome} setOpen={setShowSellModal} /> )} @@ -505,7 +515,7 @@ export function ContractBetsTable(props: { const { contract, className, isYourBets } = props const bets = sortBy( - props.bets.filter((b) => !b.isAnte), + props.bets.filter((b) => !b.isAnte && b.amount !== 0), (bet) => bet.createdTime ).reverse() @@ -531,6 +541,8 @@ export function ContractBetsTable(props: { const isNumeric = outcomeType === 'NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + const unfilledBets = useUnfilledBets(contract.id) ?? [] + return ( <div className={clsx('overflow-x-auto', className)}> {amountRedeemed > 0 && ( @@ -577,6 +589,7 @@ export function ContractBetsTable(props: { saleBet={salesDict[bet.id]} contract={contract} isYourBet={isYourBets} + unfilledBets={unfilledBets} /> ))} </tbody> @@ -590,8 +603,9 @@ function BetRow(props: { contract: Contract saleBet?: Bet isYourBet: boolean + unfilledBets: LimitBet[] }) { - const { bet, saleBet, contract, isYourBet } = props + const { bet, saleBet, contract, isYourBet, unfilledBets } = props const { amount, outcome, @@ -621,7 +635,7 @@ function BetRow(props: { formatMoney( isResolved ? resolvedPayout(contract, bet) - : calculateSaleAmount(contract, bet) + : calculateSaleAmount(contract, bet, unfilledBets) ) ) @@ -681,9 +695,16 @@ function SellButton(props: { contract: Contract; bet: Bet }) { outcome === 'NO' ? 'YES' : outcome ) - const outcomeProb = getProbabilityAfterSale(contract, outcome, shares) + const unfilledBets = useUnfilledBets(contract.id) ?? [] - const saleAmount = calculateSaleAmount(contract, bet) + const outcomeProb = getProbabilityAfterSale( + contract, + outcome, + shares, + unfilledBets + ) + + const saleAmount = calculateSaleAmount(contract, bet, unfilledBets) const profit = saleAmount - bet.amount return ( diff --git a/web/components/bucket-input.tsx b/web/components/bucket-input.tsx index 86456bff..195032dc 100644 --- a/web/components/bucket-input.tsx +++ b/web/components/bucket-input.tsx @@ -1,12 +1,12 @@ import { useState } from 'react' -import { NumericContract } from 'common/contract' +import { NumericContract, PseudoNumericContract } from 'common/contract' import { getMappedBucket } from 'common/calculate-dpm' import { NumberInput } from './number-input' export function BucketInput(props: { - contract: NumericContract + contract: NumericContract | PseudoNumericContract isSubmitting?: boolean onBucketChange: (value?: number, bucket?: string) => void }) { @@ -24,7 +24,10 @@ export function BucketInput(props: { return } - const bucket = getMappedBucket(value, contract) + const bucket = + contract.outcomeType === 'PSEUDO_NUMERIC' + ? '' + : getMappedBucket(value, contract) onBucketChange(value, bucket) } diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index c6cda43c..30c54363 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -52,10 +52,7 @@ export function ContractCard(props: { const showQuickBet = user && !marketClosed && - !( - outcomeType === 'FREE_RESPONSE' && getTopAnswer(contract) === undefined - ) && - outcomeType !== 'NUMERIC' && + (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && !hideQuickBet return ( diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 897bef04..1fc8e077 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -16,7 +16,7 @@ import { import { Bet } from 'common/bet' import BetRow from '../bet-row' import { AnswersGraph } from '../answers/answers-graph' -import { Contract } from 'common/contract' +import { Contract, CPMMBinaryContract } from 'common/contract' import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' import { ShareMarket } from '../share-market' @@ -70,6 +70,13 @@ export const ContractOverview = (props: { <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> + {tradingAllowed(contract) && ( + <BetRow contract={contract as CPMMBinaryContract} /> + )} + </Row> + ) : isPseudoNumeric ? ( + <Row className="items-center justify-between gap-4 xl:hidden"> + <PseudoNumericResolutionOrExpectation contract={contract} /> {tradingAllowed(contract) && <BetRow contract={contract} />} </Row> ) : isPseudoNumeric ? ( diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 76ee7536..0ce1c3f5 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -7,7 +7,13 @@ import { } from 'common/calculate' import { getExpectedValue } from 'common/calculate-dpm' import { User } from 'common/user' -import { Contract, NumericContract, resolution } from 'common/contract' +import { + BinaryContract, + Contract, + NumericContract, + PseudoNumericContract, + resolution, +} from 'common/contract' import { formatLargeNumber, formatMoney, @@ -22,33 +28,30 @@ import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon' import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon' import { Col } from '../layout/col' import { OUTCOME_TO_COLOR } from '../outcome-label' -import { useSaveShares } from '../use-save-shares' +import { useSaveBinaryShares } from '../use-save-binary-shares' import { sellShares } from 'web/lib/firebase/api-call' 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' const BET_SIZE = 10 -export function QuickBet(props: { contract: Contract; user: User }) { +export function QuickBet(props: { + contract: BinaryContract | PseudoNumericContract + user: User +}) { const { contract, user } = props const { mechanism, outcomeType } = contract const isCpmm = mechanism === 'cpmm-1' const userBets = useUserContractBets(user.id, contract.id) - const topAnswer = - outcomeType === 'FREE_RESPONSE' ? getTopAnswer(contract) : undefined + const unfilledBets = useUnfilledBets(contract.id) ?? [] - // TODO: yes/no from useSaveShares doesn't work on numeric contracts - const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( - contract, - userBets, - topAnswer?.number.toString() || undefined - ) - const hasUpShares = - yesFloorShares || (noFloorShares && outcomeType === 'NUMERIC') - const hasDownShares = - noFloorShares && yesFloorShares <= 0 && outcomeType !== 'NUMERIC' + const { hasYesShares, hasNoShares, yesShares, noShares } = + useSaveBinaryShares(contract, userBets) + const hasUpShares = hasYesShares + const hasDownShares = hasNoShares && !hasUpShares const [upHover, setUpHover] = useState(false) const [downHover, setDownHover] = useState(false) @@ -85,13 +88,14 @@ export function QuickBet(props: { contract: Contract; user: User }) { const maxSharesSold = BET_SIZE / (sellOutcome === 'YES' ? prob : 1 - prob) sharesSold = Math.min(oppositeShares, maxSharesSold) - const { newPool, saleValue } = calculateCpmmSale( + const { cpmmState, saleValue } = calculateCpmmSale( contract, sharesSold, - sellOutcome + sellOutcome, + unfilledBets ) saleAmount = saleValue - previewProb = getCpmmProbability(newPool, contract.p) + previewProb = getCpmmProbability(cpmmState.pool, cpmmState.p) } } @@ -131,13 +135,6 @@ export function QuickBet(props: { contract: Contract; user: User }) { }) } - if (outcomeType === 'FREE_RESPONSE') - return ( - <Col className="relative -my-4 -mr-5 min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle"> - <QuickOutcomeView contract={contract} previewProb={previewProb} /> - </Col> - ) - return ( <Col className={clsx( @@ -158,7 +155,7 @@ export function QuickBet(props: { contract: Contract; user: User }) { {formatMoney(10)} </div> - {hasUpShares > 0 ? ( + {hasUpShares ? ( <TriangleFillIcon className={clsx( 'mx-auto h-5 w-5', @@ -193,7 +190,7 @@ export function QuickBet(props: { contract: Contract; user: User }) { onMouseLeave={() => setDownHover(false)} onClick={() => placeQuickBet('DOWN')} ></div> - {hasDownShares > 0 ? ( + {hasDownShares ? ( <TriangleDownFillIcon className={clsx( 'mx-auto h-5 w-5', diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index 8f728d39..c60afa70 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -31,7 +31,9 @@ export function ContractActivity(props: { const comments = updatedComments ?? props.comments const updatedBets = useBets(contract.id) - const bets = (updatedBets ?? props.bets).filter((bet) => !bet.isRedemption) + const bets = (updatedBets ?? props.bets).filter( + (bet) => !bet.isRedemption && bet.amount !== 0 + ) const items = getSpecificContractActivityItems( contract, bets, diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 312190e4..a9618f8c 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -34,7 +34,7 @@ import { TruncatedComment, } from 'web/components/feed/feed-comments' import { FeedBet } from 'web/components/feed/feed-bets' -import { NumericContract } from 'common/contract' +import { CPMMBinaryContract, NumericContract } from 'common/contract' import { FeedLiquidity } from './feed-liquidity' export function FeedItems(props: { @@ -68,7 +68,10 @@ export function FeedItems(props: { ))} </div> {outcomeType === 'BINARY' && tradingAllowed(contract) && ( - <BetRow contract={contract} className={clsx('mb-2', betRowClassName)} /> + <BetRow + contract={contract as CPMMBinaryContract} + className={clsx('mb-2', betRowClassName)} + /> )} </div> ) diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx new file mode 100644 index 00000000..5a6a67c0 --- /dev/null +++ b/web/components/limit-bets.tsx @@ -0,0 +1,89 @@ +import clsx from 'clsx' +import { LimitBet } from 'common/bet' +import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' +import { getFormattedMappedValue } from 'common/pseudo-numeric' +import { formatMoney, formatPercent } from 'common/util/format' +import { sortBy } from 'lodash' +import { useState } from 'react' +import { cancelBet } from 'web/lib/firebase/api-call' +import { Col } from './layout/col' +import { LoadingIndicator } from './loading-indicator' +import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' + +export function LimitBets(props: { + contract: CPMMBinaryContract | PseudoNumericContract + bets: LimitBet[] + className?: string +}) { + const { contract, bets, className } = props + const recentBets = sortBy( + bets, + (bet) => -1 * bet.limitProb, + (bet) => -1 * bet.createdTime + ) + + return ( + <Col + className={clsx(className, 'gap-2 overflow-hidden rounded bg-white py-3')} + > + <div className="px-6 py-3 text-2xl">Your limit bets</div> + <div className="px-4"> + <table className="table-compact table w-full rounded text-gray-500"> + <tbody> + {recentBets.map((bet) => ( + <LimitBet key={bet.id} bet={bet} contract={contract} /> + ))} + </tbody> + </table> + </div> + </Col> + ) +} + +function LimitBet(props: { + contract: CPMMBinaryContract | PseudoNumericContract + bet: LimitBet +}) { + const { contract, bet } = props + const { orderAmount, amount, limitProb, outcome } = bet + const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' + + const [isCancelling, setIsCancelling] = useState(false) + + const onCancel = () => { + cancelBet({ betId: bet.id }) + setIsCancelling(true) + } + + return ( + <tr> + <td> + <div className="pl-2"> + {isPseudoNumeric ? ( + <PseudoNumericOutcomeLabel outcome={outcome as 'YES' | 'NO'} /> + ) : ( + <BinaryOutcomeLabel outcome={outcome as 'YES' | 'NO'} /> + )} + </div> + </td> + <td>{formatMoney(orderAmount - amount)}</td> + <td> + {isPseudoNumeric + ? getFormattedMappedValue(contract)(limitProb) + : formatPercent(limitProb)} + </td> + <td> + {isCancelling ? ( + <LoadingIndicator /> + ) : ( + <button + className="btn btn-xs btn-outline my-auto normal-case" + onClick={onCancel} + > + Cancel + </button> + )} + </td> + </tr> + ) +} diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index cf111281..98a2aabc 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -96,7 +96,7 @@ export function NumericResolutionPanel(props: { {outcomeMode === 'NUMBER' && ( <BucketInput - contract={contract as any} + contract={contract} isSubmitting={isSubmitting} onBucketChange={(v, o) => (setValue(v), setOutcome(o))} /> diff --git a/web/components/probability-input.tsx b/web/components/probability-input.tsx new file mode 100644 index 00000000..15f73799 --- /dev/null +++ b/web/components/probability-input.tsx @@ -0,0 +1,49 @@ +import clsx from 'clsx' +import { Col } from './layout/col' +import { Spacer } from './layout/spacer' + +export function ProbabilityInput(props: { + prob: number | undefined + onChange: (newProb: number | undefined) => void + disabled?: boolean + className?: string + inputClassName?: string +}) { + const { prob, onChange, disabled, className, inputClassName } = props + + const onProbChange = (str: string) => { + let prob = parseInt(str.replace(/\D/g, '')) + const isInvalid = !str || isNaN(prob) + if (prob.toString().length > 2) { + if (prob === 100) prob = 99 + else if (prob < 1) prob = 1 + else prob = +prob.toString().slice(-2) + } + onChange(isInvalid ? undefined : prob) + } + + return ( + <Col className={className}> + <label className="input-group"> + <input + className={clsx( + 'input input-bordered max-w-[200px] text-lg', + inputClassName + )} + type="number" + max={99} + min={1} + pattern="[0-9]*" + inputMode="numeric" + placeholder="0" + maxLength={2} + value={prob ?? ''} + disabled={disabled} + onChange={(e) => onProbChange(e.target.value)} + /> + <span className="bg-gray-200 text-sm">%</span> + </label> + <Spacer h={4} /> + </Col> + ) +} diff --git a/web/components/sell-row.tsx b/web/components/sell-row.tsx index a8cb2851..4c12c35c 100644 --- a/web/components/sell-row.tsx +++ b/web/components/sell-row.tsx @@ -6,7 +6,7 @@ import { Row } from './layout/row' import { formatWithCommas } from 'common/util/format' import { OutcomeLabel } from './outcome-label' import { useUserContractBets } from 'web/hooks/use-user-bets' -import { useSaveShares } from './use-save-shares' +import { useSaveBinaryShares } from './use-save-binary-shares' import { SellSharesModal } from './sell-modal' export function SellRow(props: { @@ -20,16 +20,7 @@ export function SellRow(props: { const [showSellModal, setShowSellModal] = useState(false) const { mechanism } = contract - const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( - contract, - userBets - ) - const floorShares = yesFloorShares || noFloorShares - const sharesOutcome = yesFloorShares - ? 'YES' - : noFloorShares - ? 'NO' - : undefined + const { sharesOutcome, shares } = useSaveBinaryShares(contract, userBets) if (sharesOutcome && user && mechanism === 'cpmm-1') { return ( @@ -37,7 +28,7 @@ export function SellRow(props: { <Col className={className}> <Row className="items-center justify-between gap-2 "> <div> - You have {formatWithCommas(floorShares)}{' '} + You have {formatWithCommas(shares)}{' '} <OutcomeLabel outcome={sharesOutcome} contract={contract} @@ -64,7 +55,7 @@ export function SellRow(props: { contract={contract} user={user} userBets={userBets ?? []} - shares={yesShares || noShares} + shares={shares} sharesOutcome={sharesOutcome} setOpen={setShowSellModal} /> diff --git a/web/components/use-save-binary-shares.ts b/web/components/use-save-binary-shares.ts new file mode 100644 index 00000000..fefa8a55 --- /dev/null +++ b/web/components/use-save-binary-shares.ts @@ -0,0 +1,56 @@ +import { BinaryContract, PseudoNumericContract } from 'common/contract' +import { Bet } from 'common/bet' +import { useEffect, useState } from 'react' +import { partition, sumBy } from 'lodash' +import { safeLocalStorage } from 'web/lib/util/local' + +export const useSaveBinaryShares = ( + contract: BinaryContract | PseudoNumericContract, + userBets: Bet[] | undefined +) => { + const [savedShares, setSavedShares] = useState({ yesShares: 0, noShares: 0 }) + + const [yesBets, noBets] = partition( + userBets ?? [], + (bet) => bet.outcome === 'YES' + ) + const [yesShares, noShares] = userBets + ? [sumBy(yesBets, (bet) => bet.shares), sumBy(noBets, (bet) => bet.shares)] + : [savedShares.yesShares, savedShares.noShares] + + useEffect(() => { + const local = safeLocalStorage() + + // Read shares from local storage. + const savedShares = local?.getItem(`${contract.id}-shares`) + if (savedShares) { + setSavedShares(JSON.parse(savedShares)) + } + + if (userBets) { + // Save shares to local storage. + const sharesData = JSON.stringify({ yesShares, noShares }) + local?.setItem(`${contract.id}-shares`, sharesData) + } + }, [contract.id, userBets, noShares, yesShares]) + + const hasYesShares = yesShares >= 1 + const hasNoShares = noShares >= 1 + + const sharesOutcome = hasYesShares + ? ('YES' as const) + : hasNoShares + ? ('NO' as const) + : undefined + const shares = + sharesOutcome === 'YES' ? yesShares : sharesOutcome === 'NO' ? noShares : 0 + + return { + yesShares, + noShares, + shares, + sharesOutcome, + hasYesShares, + hasNoShares, + } +} diff --git a/web/components/use-save-shares.ts b/web/components/use-save-shares.ts deleted file mode 100644 index 494c1f29..00000000 --- a/web/components/use-save-shares.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Contract } from 'common/contract' -import { Bet } from 'common/bet' -import { useEffect, useState } from 'react' -import { partition, sumBy } from 'lodash' -import { safeLocalStorage } from 'web/lib/util/local' - -export const useSaveShares = ( - contract: Contract, - userBets: Bet[] | undefined, - freeResponseAnswerOutcome?: string -) => { - const [savedShares, setSavedShares] = useState< - | { - yesShares: number - noShares: number - yesFloorShares: number - noFloorShares: number - } - | undefined - >() - - // TODO: How do we handle numeric yes / no bets? - maybe bet amounts above vs below the highest peak - const [yesBets, noBets] = partition(userBets ?? [], (bet) => - freeResponseAnswerOutcome - ? bet.outcome === freeResponseAnswerOutcome - : bet.outcome === 'YES' - ) - const [yesShares, noShares] = [ - sumBy(yesBets, (bet) => bet.shares), - sumBy(noBets, (bet) => bet.shares), - ] - - const yesFloorShares = Math.round(yesShares) === 0 ? 0 : Math.floor(yesShares) - const noFloorShares = Math.round(noShares) === 0 ? 0 : Math.floor(noShares) - - useEffect(() => { - const local = safeLocalStorage() - // Save yes and no shares to local storage. - const savedShares = local?.getItem(`${contract.id}-shares`) - if (!userBets && savedShares) { - setSavedShares(JSON.parse(savedShares)) - } - - if (userBets) { - const updatedShares = { yesShares, noShares } - local?.setItem(`${contract.id}-shares`, JSON.stringify(updatedShares)) - } - }, [contract.id, userBets, noShares, yesShares]) - - if (userBets) return { yesShares, noShares, yesFloorShares, noFloorShares } - return ( - savedShares ?? { - yesShares: 0, - noShares: 0, - yesFloorShares: 0, - noFloorShares: 0, - } - ) -} diff --git a/web/hooks/use-bets.ts b/web/hooks/use-bets.ts index 5cab16a7..68b296cd 100644 --- a/web/hooks/use-bets.ts +++ b/web/hooks/use-bets.ts @@ -4,8 +4,10 @@ import { Bet, listenForBets, listenForRecentBets, + listenForUnfilledBets, withoutAnteBets, } from 'web/lib/firebase/bets' +import { LimitBet } from 'common/bet' export const useBets = (contractId: string) => { const [bets, setBets] = useState<Bet[] | undefined>() @@ -36,3 +38,12 @@ export const useRecentBets = () => { useEffect(() => listenForRecentBets(setRecentBets), []) return recentBets } + +export const useUnfilledBets = (contractId: string) => { + const [unfilledBets, setUnfilledBets] = useState<LimitBet[] | undefined>() + useEffect( + () => listenForUnfilledBets(contractId, setUnfilledBets), + [contractId] + ) + return unfilledBets +} diff --git a/web/hooks/use-focus.ts b/web/hooks/use-focus.ts index a71a0292..f41f46a7 100644 --- a/web/hooks/use-focus.ts +++ b/web/hooks/use-focus.ts @@ -1,11 +1,12 @@ import { useRef } from 'react' +import { useEvent } from './use-event' // Focus helper from https://stackoverflow.com/a/54159564/1222351 export function useFocus(): [React.RefObject<HTMLElement>, () => void] { const htmlElRef = useRef<HTMLElement>(null) - const setFocus = () => { + const setFocus = useEvent(() => { htmlElRef.current && htmlElRef.current.focus() - } + }) return [htmlElRef, setFocus] } diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 7882d9ba..94da9f09 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -82,6 +82,10 @@ export function placeBet(params: any) { return call(getFunctionUrl('placebet'), 'POST', params) } +export function cancelBet(params: { betId: string }) { + return call(getFunctionUrl('cancelbet'), 'POST', params) +} + export function sellShares(params: any) { return call(getFunctionUrl('sellshares'), 'POST', params) } diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 6fc29d24..ef0ab55d 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -15,7 +15,7 @@ import { import { uniq } from 'lodash' import { db } from './init' -import { Bet } from 'common/bet' +import { Bet, LimitBet } from 'common/bet' import { Contract } from 'common/contract' import { getValues, listenForValues } from './utils' import { getContractFromId } from './contracts' @@ -166,6 +166,21 @@ export function listenForUserContractBets( }) } +export function listenForUnfilledBets( + contractId: string, + setBets: (bets: LimitBet[]) => void +) { + const betsQuery = query( + collection(db, 'contracts', contractId, 'bets'), + where('isFilled', '==', false), + where('isCancelled', '==', false) + ) + return listenForValues<LimitBet>(betsQuery, (bets) => { + bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) + setBets(bets) + }) +} + export function withoutAnteBets(contract: Contract, bets?: Bet[]) { const { createdTime } = contract diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index e33c116e..e8b290f3 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -39,6 +39,7 @@ import { FeedBet } from 'web/components/feed/feed-bets' import { useIsIframe } from 'web/hooks/use-is-iframe' import ContractEmbedPage from '../embed/[username]/[contractSlug]' import { useBets } from 'web/hooks/use-bets' +import { CPMMBinaryContract } from 'common/contract' import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' @@ -127,6 +128,7 @@ export function ContractPageContent( const tips = useTipTxns({ contractId: contract.id }) const user = useUser() + const { width, height } = useWindowSize() const [showConfetti, setShowConfetti] = useState(false) @@ -169,7 +171,10 @@ export function ContractPageContent( (isNumeric ? ( <NumericBetPanel className="hidden xl:flex" contract={contract} /> ) : ( - <BetPanel className="hidden xl:flex" contract={contract} /> + <BetPanel + className="hidden xl:flex" + contract={contract as CPMMBinaryContract} + /> ))} {allowResolve && (isNumeric || isPseudoNumeric ? ( diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 93439be7..dc8cb51d 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -1,5 +1,5 @@ import { Bet } from 'common/bet' -import { Contract } from 'common/contract' +import { Contract, CPMMBinaryContract } from 'common/contract' import { DOMAIN } from 'common/envs/constants' import { AnswersGraph } from 'web/components/answers/answers-graph' import BetRow from 'web/components/bet-row' @@ -112,7 +112,10 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { {isBinary && ( <Row className="items-center gap-4"> - <BetRow contract={contract} betPanelClassName="scale-75" /> + <BetRow + contract={contract as CPMMBinaryContract} + betPanelClassName="scale-75" + /> <BinaryResolutionOrChance contract={contract} /> </Row> )} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 3a8e4bc0..39cc2017 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -795,6 +795,8 @@ function getSourceIdForLinkComponent( return sourceId case 'contract': return '' + case 'bet': + return '' default: return sourceId } @@ -861,8 +863,16 @@ function NotificationTextLabel(props: { {'+' + formatMoney(parseInt(sourceText))} </span> ) + } else if (sourceType === 'bet' && sourceText) { + return ( + <> + <span className="text-primary"> + {formatMoney(parseInt(sourceText))} + </span>{' '} + <span>of your limit bet was filled</span> + </> + ) } - // return default text return ( <div className={className ? className : 'line-clamp-4 whitespace-pre-line'}> <Linkify text={defaultText} /> @@ -913,6 +923,9 @@ function getReasonForShowingNotification( else if (sourceSlug) reasonText = 'joined because you shared' else reasonText = 'joined because of you' break + case 'bet': + reasonText = 'bet against you' + break default: reasonText = '' } From 4de22acb3ea6cb168d669a194986b7ace5459143 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 13:24:54 -0500 Subject: [PATCH 134/220] Tweak check for matching with pool --- common/new-bet.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/new-bet.ts b/common/new-bet.ts index 6c3e6856..f484b9f7 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -66,8 +66,8 @@ const computeFill = ( if ( !matchedBet || (outcome === 'YES' - ? prob < matchedBet.limitProb - : prob > matchedBet.limitProb) + ? !floatingGreaterEqual(prob, matchedBet.limitProb) + : !floatingLesserEqual(prob, matchedBet.limitProb)) ) { // Fill from pool. const limit = !matchedBet From 900fc7550653b2fe07ffb7da2e29175f5767e8cb Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 13:45:32 -0500 Subject: [PATCH 135/220] Add sourceContractId to bet_fill notification --- functions/src/create-notification.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 0d3432a7..1fb6c3af 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -413,6 +413,7 @@ export const createBetFillNotification = async ( sourceContractCreatorUsername: contract.creatorUsername, sourceContractTitle: contract.question, sourceContractSlug: contract.slug, + sourceContractId: contract.id, } return await notificationRef.set(removeUndefinedProps(notification)) } From f2df32e71010868c7f3b2faf042570fe9d3d4f4d Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 14:52:31 -0500 Subject: [PATCH 136/220] PseudoNumeric markets store resolveValue in resolved notification and render it --- functions/src/on-update-contract.ts | 3 +++ web/components/outcome-label.tsx | 7 ++++++- web/pages/notifications.tsx | 5 +++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index f47c019c..4674bd82 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -24,6 +24,9 @@ export const onUpdateContract = functions.firestore if (resolutionText === 'MKT' && contract.resolutionProbability) resolutionText = `${contract.resolutionProbability}%` else if (resolutionText === 'MKT') resolutionText = 'PROB' + } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { + if (resolutionText === 'MKT' && contract.resolutionValue) + resolutionText = `${contract.resolutionValue}` } await createNotification( diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index a94618e4..9ecda16f 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -9,7 +9,7 @@ import { FreeResponseContract, resolution, } from 'common/contract' -import { formatPercent } from 'common/util/format' +import { formatLargeNumber, formatPercent } from 'common/util/format' import { ClientRender } from './client-render' export function OutcomeLabel(props: { @@ -140,6 +140,11 @@ export function ProbPercentLabel(props: { prob: number }) { return <span className="text-blue-400">{formatPercent(prob)}</span> } +export function NumericValueLabel(props: { value: number }) { + const { value } = props + return <span className="text-blue-400">{formatLargeNumber(value)}</span> +} + export function AnswerNumberLabel(props: { number: string }) { return <span className="text-primary">#{props.number}</span> } diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 39cc2017..aeeb9af0 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -21,7 +21,9 @@ import { BinaryOutcomeLabel, CancelLabel, MultiLabel, + NumericValueLabel, ProbPercentLabel, + PseudoNumericOutcomeLabel, } from 'web/components/outcome-label' import { NotificationGroup, @@ -828,6 +830,9 @@ function NotificationTextLabel(props: { ) if (sourceText === 'CANCEL') return <CancelLabel /> if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel /> + if (contract?.outcomeType === 'PSEUDO_NUMERIC') { + return <NumericValueLabel value={parseFloat(sourceText)} /> + } } } // Close date will be a number - it looks better without it From 83c5f9b323c4ef35e23833aa6bb63e1a3b643449 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 14:55:10 -0500 Subject: [PATCH 137/220] Fix unused var --- web/pages/notifications.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index aeeb9af0..dd43d64f 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -23,7 +23,6 @@ import { MultiLabel, NumericValueLabel, ProbPercentLabel, - PseudoNumericOutcomeLabel, } from 'web/components/outcome-label' import { NotificationGroup, From eb9b14d6d54aec19e0ede56a49a72121a1383e68 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sun, 10 Jul 2022 13:46:00 -0700 Subject: [PATCH 138/220] Migrate unsubscribe function to v2 (#637) * Migrate unsubscribe function to v2 * Move Stripe import because I forgot to do it before --- .../src/email-templates/market-close.html | 2 +- .../src/email-templates/market-resolved.html | 2 +- functions/src/emails.ts | 20 ++-- functions/src/index.ts | 4 +- functions/src/unsubscribe.ts | 109 +++++++++--------- 5 files changed, 69 insertions(+), 68 deletions(-) diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index 00e8b439..150987cc 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -613,7 +613,7 @@ >our Discord</a >! Or, <a - href="https://us-central1-mantic-markets.cloudfunctions.net/unsubscribe?id={{userId}}&type=market-resolve" + href="https://unsubscribe-nggbo3neva-uc.a.run.app?id={{userId}}&type=market-resolve" style=" font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html index 42d4e2d8..f109d31e 100644 --- a/functions/src/email-templates/market-resolved.html +++ b/functions/src/email-templates/market-resolved.html @@ -635,7 +635,7 @@ >our Discord</a >! Or, <a - href="https://us-central1-mantic-markets.cloudfunctions.net/unsubscribe?id={{userId}}&type=market-resolved" + href="https://unsubscribe-nggbo3neva-uc.a.run.app?id={{userId}}&type=market-resolved" style=" font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 40e8900c..e0e2da63 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,4 +1,4 @@ -import { DOMAIN, PROJECT_ID } from '../../common/envs/constants' +import { DOMAIN, ENV_CONFIG } from '../../common/envs/constants' import { Answer } from '../../common/answer' import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' @@ -141,7 +141,8 @@ export const sendWelcomeEmail = async ( const firstName = name.split(' ')[0] const emailType = 'generic' - const unsubscribeLink = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=${emailType}` + const { cloudRunId, cloudRunRegion } = ENV_CONFIG + const unsubscribeLink = `https://unsubscribe-${cloudRunId}-${cloudRunRegion}.a.run.app?id=${userId}&type=${emailType}` await sendTemplateEmail( privateUser.email, @@ -173,7 +174,8 @@ export const sendOneWeekBonusEmail = async ( const firstName = name.split(' ')[0] const emailType = 'generic' - const unsubscribeLink = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=${emailType}` + const { cloudRunId, cloudRunRegion } = ENV_CONFIG + const unsubscribeLink = `https://unsubscribe-${cloudRunId}-${cloudRunRegion}.a.run.app?id=${userId}&type=${emailType}` await sendTemplateEmail( privateUser.email, @@ -205,7 +207,8 @@ export const sendThankYouEmail = async ( const firstName = name.split(' ')[0] const emailType = 'generic' - const unsubscribeLink = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=${emailType}` + const { cloudRunId, cloudRunRegion } = ENV_CONFIG + const unsubscribeLink = `https://unsubscribe-${cloudRunId}-${cloudRunRegion}.a.run.app?id=${userId}&type=${emailType}` await sendTemplateEmail( privateUser.email, @@ -277,8 +280,9 @@ export const sendNewCommentEmail = async ( const { question, creatorUsername, slug } = contract const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}` - - const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-comment` + const emailType = 'market-comment' + const { cloudRunId, cloudRunRegion } = ENV_CONFIG + const unsubscribeUrl = `https://unsubscribe-${cloudRunId}-${cloudRunRegion}.a.run.app?id=${userId}&type=${emailType}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator const { text } = comment @@ -359,7 +363,9 @@ export const sendNewAnswerEmail = async ( const { name, avatarUrl, text } = answer const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` - const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-answer` + const emailType = 'market-answer' + const { cloudRunId, cloudRunRegion } = ENV_CONFIG + const unsubscribeUrl = `https://unsubscribe-${cloudRunId}-${cloudRunRegion}.a.run.app?id=${userId}&type=${emailType}` const subject = `New answer on ${question}` const from = `${name} <info@manifold.markets>` diff --git a/functions/src/index.ts b/functions/src/index.ts index 0d0de3ba..380e4f93 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,12 +3,10 @@ import * as admin from 'firebase-admin' admin.initializeApp() // v1 -export * from './stripe' export * from './create-user' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' -export * from './unsubscribe' export * from './update-metrics' export * from './update-stats' export * from './backup-db' @@ -41,3 +39,5 @@ export * from './withdraw-liquidity' export * from './create-group' export * from './resolve-market' export * from './get-daily-bonuses' +export * from './unsubscribe' +export * from './stripe' diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index a41a7155..48dd29c0 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -1,71 +1,66 @@ -import * as functions from 'firebase-functions' +import { onRequest } from 'firebase-functions/v2/https' import * as admin from 'firebase-admin' import { getUser } from './utils' import { PrivateUser } from '../../common/user' -export const unsubscribe = functions - .runWith({ minInstances: 1 }) - .https.onRequest(async (req, res) => { - const id = req.query.id as string - let type = req.query.type as string - if (!id || !type) { - res.status(400).send('Empty id or type parameter.') - return - } +export const unsubscribe = onRequest({ minInstances: 1 }, async (req, res) => { + const id = req.query.id as string + let type = req.query.type as string + if (!id || !type) { + res.status(400).send('Empty id or type parameter.') + return + } - if (type === 'market-resolved') type = 'market-resolve' + if (type === 'market-resolved') type = 'market-resolve' - if ( - ![ - 'market-resolve', - 'market-comment', - 'market-answer', - 'generic', - ].includes(type) - ) { - res.status(400).send('Invalid type parameter.') - return - } + if ( + !['market-resolve', 'market-comment', 'market-answer', 'generic'].includes( + type + ) + ) { + res.status(400).send('Invalid type parameter.') + return + } - const user = await getUser(id) + const user = await getUser(id) - if (!user) { - res.send('This user is not currently subscribed or does not exist.') - return - } + if (!user) { + res.send('This user is not currently subscribed or does not exist.') + return + } - const { name } = user + const { name } = user - const update: Partial<PrivateUser> = { - ...(type === 'market-resolve' && { - unsubscribedFromResolutionEmails: true, - }), - ...(type === 'market-comment' && { - unsubscribedFromCommentEmails: true, - }), - ...(type === 'market-answer' && { - unsubscribedFromAnswerEmails: true, - }), - ...(type === 'generic' && { - unsubscribedFromGenericEmails: true, - }), - } + const update: Partial<PrivateUser> = { + ...(type === 'market-resolve' && { + unsubscribedFromResolutionEmails: true, + }), + ...(type === 'market-comment' && { + unsubscribedFromCommentEmails: true, + }), + ...(type === 'market-answer' && { + unsubscribedFromAnswerEmails: true, + }), + ...(type === 'generic' && { + unsubscribedFromGenericEmails: true, + }), + } - await firestore.collection('private-users').doc(id).update(update) + await firestore.collection('private-users').doc(id).update(update) - if (type === 'market-resolve') - res.send( - `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.` - ) - else if (type === 'market-comment') - res.send( - `${name}, you have been unsubscribed from market comment emails on Manifold Markets.` - ) - else if (type === 'market-answer') - res.send( - `${name}, you have been unsubscribed from market answer emails on Manifold Markets.` - ) - else res.send(`${name}, you have been unsubscribed.`) - }) + if (type === 'market-resolve') + res.send( + `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.` + ) + else if (type === 'market-comment') + res.send( + `${name}, you have been unsubscribed from market comment emails on Manifold Markets.` + ) + else if (type === 'market-answer') + res.send( + `${name}, you have been unsubscribed from market answer emails on Manifold Markets.` + ) + else res.send(`${name}, you have been unsubscribed.`) +}) const firestore = admin.firestore() From 6462d4a2edfcafd6092aa7c16cc4e2416abf7980 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sun, 10 Jul 2022 14:02:32 -0700 Subject: [PATCH 139/220] Migrate createUser function to v2 (#633) --- functions/src/create-user.ts | 119 +++++++++++++++++------------------ functions/src/index.ts | 2 +- web/lib/firebase/api-call.ts | 4 ++ web/lib/firebase/fn-call.ts | 22 ------- web/lib/firebase/users.ts | 29 ++++----- 5 files changed, 77 insertions(+), 99 deletions(-) delete mode 100644 web/lib/firebase/fn-call.ts diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 189976ed..e70371ca 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,6 +1,5 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' - +import { z } from 'zod' import { PrivateUser, STARTING_BALANCE, @@ -18,83 +17,79 @@ import { isWhitelisted } from '../../common/envs/constants' import { DEFAULT_CATEGORIES } from '../../common/categories' import { track } from './analytics' +import { APIError, newEndpoint, validate } from './api' -export const createUser = functions - .runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) - .https.onCall(async (data: { deviceToken?: string }, context) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + deviceToken: z.string().optional(), +}) - const preexistingUser = await getUser(userId) - if (preexistingUser) - return { - status: 'error', - message: 'User already created', - user: preexistingUser, - } +const opts = { secrets: ['MAILGUN_KEY'] } - const fbUser = await admin.auth().getUser(userId) +export const createuser = newEndpoint(opts, async (req, auth) => { + const { deviceToken } = validate(bodySchema, req.body) + const preexistingUser = await getUser(auth.uid) + if (preexistingUser) + throw new APIError(400, 'User already exists', { user: preexistingUser }) - const email = fbUser.email - if (!isWhitelisted(email)) { - return { status: 'error', message: `${email} is not whitelisted` } - } - const emailName = email?.replace(/@.*$/, '') + const fbUser = await admin.auth().getUser(auth.uid) - const rawName = fbUser.displayName || emailName || 'User' + randomString(4) - const name = cleanDisplayName(rawName) - let username = cleanUsername(name) + const email = fbUser.email + if (!isWhitelisted(email)) { + throw new APIError(400, `${email} is not whitelisted`) + } + const emailName = email?.replace(/@.*$/, '') - const sameNameUser = await getUserByUsername(username) - if (sameNameUser) { - username += randomString(4) - } + const rawName = fbUser.displayName || emailName || 'User' + randomString(4) + const name = cleanDisplayName(rawName) + let username = cleanUsername(name) - const avatarUrl = fbUser.photoURL + const sameNameUser = await getUserByUsername(username) + if (sameNameUser) { + username += randomString(4) + } - const { deviceToken } = data - const deviceUsedBefore = - !deviceToken || (await isPrivateUserWithDeviceToken(deviceToken)) + const avatarUrl = fbUser.photoURL + const deviceUsedBefore = + !deviceToken || (await isPrivateUserWithDeviceToken(deviceToken)) - const ipAddress = context.rawRequest.ip - const ipCount = ipAddress ? await numberUsersWithIp(ipAddress) : 0 + const ipCount = req.ip ? await numberUsersWithIp(req.ip) : 0 - const balance = - deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE + const balance = + deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE - const user: User = { - id: userId, - name, - username, - avatarUrl, - balance, - totalDeposits: balance, - createdTime: Date.now(), - profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, - creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, - followerCountCached: 0, - followedCategories: DEFAULT_CATEGORIES, - } + const user: User = { + id: auth.uid, + name, + username, + avatarUrl, + balance, + totalDeposits: balance, + createdTime: Date.now(), + profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, + creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, + followerCountCached: 0, + followedCategories: DEFAULT_CATEGORIES, + } - await firestore.collection('users').doc(userId).create(user) - console.log('created user', username, 'firebase id:', userId) + await firestore.collection('users').doc(auth.uid).create(user) + console.log('created user', username, 'firebase id:', auth.uid) - const privateUser: PrivateUser = { - id: userId, - username, - email, - initialIpAddress: ipAddress, - initialDeviceToken: deviceToken, - } + const privateUser: PrivateUser = { + id: auth.uid, + username, + email, + initialIpAddress: req.ip, + initialDeviceToken: deviceToken, + } - await firestore.collection('private-users').doc(userId).create(privateUser) + await firestore.collection('private-users').doc(auth.uid).create(privateUser) - await sendWelcomeEmail(user, privateUser) + await sendWelcomeEmail(user, privateUser) - await track(userId, 'create user', { username }, { ip: ipAddress }) + await track(auth.uid, 'create user', { username }, { ip: req.ip }) - return { status: 'success', user } - }) + return user +}) const firestore = admin.firestore() diff --git a/functions/src/index.ts b/functions/src/index.ts index 380e4f93..e5ae78ec 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,7 +3,6 @@ import * as admin from 'firebase-admin' admin.initializeApp() // v1 -export * from './create-user' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' @@ -27,6 +26,7 @@ export * from './on-create-txn' export * from './health' export * from './transact' export * from './change-user-info' +export * from './create-user' export * from './create-answer' export * from './place-bet' export * from './cancel-bet' diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 94da9f09..fc1e78bd 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -58,6 +58,10 @@ export function transact(params: any) { return call(getFunctionUrl('transact'), 'POST', params) } +export function createUser(params: any) { + return call(getFunctionUrl('createuser'), 'POST', params) +} + export function changeUserInfo(params: any) { return call(getFunctionUrl('changeuserinfo'), 'POST', params) } diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts deleted file mode 100644 index 2f299aea..00000000 --- a/web/lib/firebase/fn-call.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { httpsCallable } from 'firebase/functions' -import { User } from 'common/user' -import { randomString } from 'common/util/random' -import './init' -import { functions } from './init' -import { safeLocalStorage } from '../util/local' - -export const cloudFunction = <RequestData, ResponseData>(name: string) => - httpsCallable<RequestData, ResponseData>(functions, name) - -export const createUser: () => Promise<User | null> = () => { - const local = safeLocalStorage() - let deviceToken = local?.getItem('device-token') - if (!deviceToken) { - deviceToken = randomString() - local?.setItem('device-token', deviceToken) - } - - return cloudFunction('createUser')({ deviceToken }) - .then((r) => (r.data as any)?.user || null) - .catch(() => null) -} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 7f007031..d2e1ee04 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -20,11 +20,10 @@ import { GoogleAuthProvider, signInWithPopup, } from 'firebase/auth' -import { throttle, zip } from 'lodash' - +import { zip } from 'lodash' import { app, db } from './init' import { PortfolioMetrics, PrivateUser, User } from 'common/user' -import { createUser } from './fn-call' +import { createUser } from './api-call' import { coll, getValue, @@ -38,6 +37,7 @@ import { safeLocalStorage } from '../util/local' import { filterDefined } from 'common/util/array' import { addUserToGroupViaSlug } from 'web/lib/firebase/groups' import { removeUndefinedProps } from 'common/util/object' +import { randomString } from 'common/util/random' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' dayjs.extend(utc) @@ -101,11 +101,6 @@ const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY' const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY' const CACHED_REFERRAL_GROUP_SLUG_KEY = 'CACHED_REFERRAL_GROUP_KEY' -// used to avoid weird race condition -let createUserPromise: Promise<User | null> | undefined = undefined - -const warmUpCreateUser = throttle(createUser, 5000 /* ms */) - export function writeReferralInfo( defaultReferrerUsername: string, contractId?: string, @@ -183,22 +178,29 @@ async function setCachedReferralInfoForUser(user: User | null) { local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY) } +// used to avoid weird race condition +let createUserPromise: Promise<User> | undefined = undefined + export function listenForLogin(onUser: (user: User | null) => void) { const local = safeLocalStorage() const cachedUser = local?.getItem(CACHED_USER_KEY) onUser(cachedUser && JSON.parse(cachedUser)) - if (!cachedUser) warmUpCreateUser() - return onAuthStateChanged(auth, async (fbUser) => { if (fbUser) { let user: User | null = await getUser(fbUser.uid) if (!user) { - if (!createUserPromise) { - createUserPromise = createUser() + if (createUserPromise == null) { + const local = safeLocalStorage() + let deviceToken = local?.getItem('device-token') + if (!deviceToken) { + deviceToken = randomString() + local?.setItem('device-token', deviceToken) + } + createUserPromise = createUser({ deviceToken }).then((r) => r as User) } - user = (await createUserPromise) || null + user = await createUserPromise } onUser(user) @@ -211,7 +213,6 @@ export function listenForLogin(onUser: (user: User | null) => void) { // User logged out; reset to null onUser(null) local?.removeItem(CACHED_USER_KEY) - createUserPromise = undefined } }) } From 4700ceb14c85c478cf94313a88a9f5b67e3816f7 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sun, 10 Jul 2022 15:03:15 -0700 Subject: [PATCH 140/220] Refactor some backend-related stuff (#639) * web/lib/firebase/api-call -> common/api, web/lib/firebase/api * Reuse `APIError` type in server code * Reuse `getFunctionUrl` in server code --- common/api.ts | 22 ++++++++++++++ functions/src/api.ts | 16 ++-------- .../src/email-templates/market-close.html | 2 +- .../src/email-templates/market-resolved.html | 2 +- functions/src/emails.ts | 28 +++++++++++------- web/components/answers/answer-bet-panel.tsx | 2 +- .../answers/answer-resolve-panel.tsx | 2 +- .../answers/create-answer-panel.tsx | 2 +- web/components/bet-panel.tsx | 4 +-- web/components/bets-list.tsx | 2 +- web/components/contract/quick-bet.tsx | 4 +-- web/components/groups/create-group-button.tsx | 2 +- web/components/limit-bets.tsx | 2 +- web/components/liquidity-panel.tsx | 2 +- web/components/notifications-icon.tsx | 2 +- web/components/numeric-bet-panel.tsx | 6 ++-- web/components/numeric-resolution-panel.tsx | 2 +- web/components/resolution-panel.tsx | 2 +- web/components/tipper.tsx | 2 +- web/lib/api/proxy.ts | 2 +- web/lib/firebase/{api-call.ts => api.ts} | 29 ++----------------- web/lib/firebase/users.ts | 2 +- web/lib/service/stripe.ts | 2 +- web/pages/charity/[charitySlug].tsx | 2 +- web/pages/create.tsx | 2 +- web/pages/link/[slug].tsx | 2 +- web/pages/make-predictions.tsx | 2 +- web/pages/profile.tsx | 2 +- 28 files changed, 72 insertions(+), 79 deletions(-) create mode 100644 common/api.ts rename web/lib/firebase/{api-call.ts => api.ts} (71%) diff --git a/common/api.ts b/common/api.ts new file mode 100644 index 00000000..02dba409 --- /dev/null +++ b/common/api.ts @@ -0,0 +1,22 @@ +import { ENV_CONFIG } from 'common/envs/constants' + +export class APIError extends Error { + code: number + details?: unknown + constructor(code: number, message: string, details?: unknown) { + super(message) + this.code = code + this.name = 'APIError' + this.details = details + } +} + +export function getFunctionUrl(name: string) { + if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { + const { projectId, region } = ENV_CONFIG.firebaseConfig + return `http://localhost:5001/${projectId}/${region}/${name}` + } else { + const { cloudRunId, cloudRunRegion } = ENV_CONFIG + return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app` + } +} diff --git a/functions/src/api.ts b/functions/src/api.ts index 6ebffc24..8c01ea05 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -3,13 +3,14 @@ import { logger } from 'firebase-functions/v2' import { HttpsOptions, onRequest, Request } from 'firebase-functions/v2/https' import { log } from './utils' import { z } from 'zod' - +import { APIError } from '../../common/api' import { PrivateUser } from '../../common/user' import { CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST, CORS_ORIGIN_VERCEL, } from '../../common/envs/constants' +export { APIError } from '../../common/api' type Output = Record<string, unknown> type AuthedUser = { @@ -21,17 +22,6 @@ type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken } type KeyCredentials = { kind: 'key'; data: string } type Credentials = JwtCredentials | KeyCredentials -export class APIError { - code: number - msg: string - details: unknown - constructor(code: number, msg: string, details?: unknown) { - this.code = code - this.msg = msg - this.details = details - } -} - const auth = admin.auth() const firestore = admin.firestore() const privateUsers = firestore.collection( @@ -136,7 +126,7 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { res.status(200).json(await fn(req, authedUser)) } catch (e) { if (e instanceof APIError) { - const output: { [k: string]: unknown } = { message: e.msg } + const output: { [k: string]: unknown } = { message: e.message } if (e.details != null) { output.details = e.details } diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index 150987cc..711f7ccb 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -613,7 +613,7 @@ >our Discord</a >! Or, <a - href="https://unsubscribe-nggbo3neva-uc.a.run.app?id={{userId}}&type=market-resolve" + href="{{unsubscribeUrl}}" style=" font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html index f109d31e..e8b090b5 100644 --- a/functions/src/email-templates/market-resolved.html +++ b/functions/src/email-templates/market-resolved.html @@ -635,7 +635,7 @@ >our Discord</a >! Or, <a - href="https://unsubscribe-nggbo3neva-uc.a.run.app?id={{userId}}&type=market-resolved" + href="{{unsubscribeUrl}}" style=" font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; diff --git a/functions/src/emails.ts b/functions/src/emails.ts index e0e2da63..60534679 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,4 +1,4 @@ -import { DOMAIN, ENV_CONFIG } from '../../common/envs/constants' +import { DOMAIN } from '../../common/envs/constants' import { Answer } from '../../common/answer' import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' @@ -16,6 +16,9 @@ import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' +import { getFunctionUrl } from '../../common/api' + +const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') export const sendMarketResolutionEmail = async ( userId: string, @@ -53,6 +56,9 @@ export const sendMarketResolutionEmail = async ( ? ` (plus ${formatMoney(creatorPayout)} in commissions)` : '' + const emailType = 'market-resolved' + const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const templateData: market_resolved_template = { userId: user.id, name: user.name, @@ -62,6 +68,7 @@ export const sendMarketResolutionEmail = async ( investment: `${Math.floor(investment)}`, payout: `${Math.floor(payout)}${creatorPayoutText}`, url: `https://${DOMAIN}/${creator.username}/${contract.slug}`, + unsubscribeUrl, } // Modify template here: @@ -85,6 +92,7 @@ type market_resolved_template = { investment: string payout: string url: string + unsubscribeUrl: string } const toDisplayResolution = ( @@ -141,8 +149,7 @@ export const sendWelcomeEmail = async ( const firstName = name.split(' ')[0] const emailType = 'generic' - const { cloudRunId, cloudRunRegion } = ENV_CONFIG - const unsubscribeLink = `https://unsubscribe-${cloudRunId}-${cloudRunRegion}.a.run.app?id=${userId}&type=${emailType}` + const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` await sendTemplateEmail( privateUser.email, @@ -174,8 +181,7 @@ export const sendOneWeekBonusEmail = async ( const firstName = name.split(' ')[0] const emailType = 'generic' - const { cloudRunId, cloudRunRegion } = ENV_CONFIG - const unsubscribeLink = `https://unsubscribe-${cloudRunId}-${cloudRunRegion}.a.run.app?id=${userId}&type=${emailType}` + const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` await sendTemplateEmail( privateUser.email, @@ -207,8 +213,7 @@ export const sendThankYouEmail = async ( const firstName = name.split(' ')[0] const emailType = 'generic' - const { cloudRunId, cloudRunRegion } = ENV_CONFIG - const unsubscribeLink = `https://unsubscribe-${cloudRunId}-${cloudRunRegion}.a.run.app?id=${userId}&type=${emailType}` + const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` await sendTemplateEmail( privateUser.email, @@ -242,6 +247,8 @@ export const sendMarketCloseEmail = async ( const { question, slug, volume, mechanism, collectedFees } = contract const url = `https://${DOMAIN}/${username}/${slug}` + const emailType = 'market-resolve' + const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` await sendTemplateEmail( privateUser.email, @@ -250,6 +257,7 @@ export const sendMarketCloseEmail = async ( { question, url, + unsubscribeUrl, userId, name: firstName, volume: formatMoney(volume), @@ -281,8 +289,7 @@ export const sendNewCommentEmail = async ( const { question, creatorUsername, slug } = contract const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}` const emailType = 'market-comment' - const { cloudRunId, cloudRunRegion } = ENV_CONFIG - const unsubscribeUrl = `https://unsubscribe-${cloudRunId}-${cloudRunRegion}.a.run.app?id=${userId}&type=${emailType}` + const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator const { text } = comment @@ -364,8 +371,7 @@ export const sendNewAnswerEmail = async ( const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` const emailType = 'market-answer' - const { cloudRunId, cloudRunRegion } = ENV_CONFIG - const unsubscribeUrl = `https://unsubscribe-${cloudRunId}-${cloudRunRegion}.a.run.app?id=${userId}&type=${emailType}` + const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const subject = `New answer on ${question}` const from = `${name} <info@manifold.markets>` diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 705433b1..6499ce36 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -6,7 +6,7 @@ import { Answer } from 'common/answer' import { FreeResponseContract } from 'common/contract' import { BuyAmountInput } from '../amount-input' import { Col } from '../layout/col' -import { APIError, placeBet } from 'web/lib/firebase/api-call' +import { APIError, placeBet } from 'web/lib/firebase/api' import { Row } from '../layout/row' import { Spacer } from '../layout/spacer' import { diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index 6b8e2885..5b59f050 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { Contract, FreeResponse } from 'common/contract' import { Col } from '../layout/col' -import { APIError, resolveMarket } from 'web/lib/firebase/api-call' +import { APIError, resolveMarket } from 'web/lib/firebase/api' import { Row } from '../layout/row' import { ChooseCancelSelector } from '../yes-no-selector' import { ResolveConfirmationButton } from '../confirmation-button' diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 41745b09..ce266778 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -6,7 +6,7 @@ import { findBestMatch } from 'string-similarity' import { FreeResponseContract } from 'common/contract' import { BuyAmountInput } from '../amount-input' import { Col } from '../layout/col' -import { APIError, createAnswer } from 'web/lib/firebase/api-call' +import { APIError, createAnswer } from 'web/lib/firebase/api' import { Row } from '../layout/row' import { formatMoney, diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 271eeecc..558697ef 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -19,8 +19,8 @@ import { getBinaryCpmmBetInfo } from 'common/new-bet' import { Title } from './title' import { User } from 'web/lib/firebase/users' import { Bet, LimitBet } from 'common/bet' -import { APIError, placeBet } from 'web/lib/firebase/api-call' -import { sellShares } from 'web/lib/firebase/api-call' +import { APIError, placeBet } from 'web/lib/firebase/api' +import { sellShares } from 'web/lib/firebase/api' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' import { BinaryOutcomeLabel } from './outcome-label' diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 72ac23db..40eaf04b 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -23,7 +23,7 @@ import { } from 'web/lib/firebase/contracts' import { Row } from './layout/row' import { UserLink } from './user-page' -import { sellBet } from 'web/lib/firebase/api-call' +import { sellBet } from 'web/lib/firebase/api' import { ConfirmationButton } from './confirmation-button' import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' import { filterDefined } from 'common/util/array' diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 0ce1c3f5..09a5d4bc 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -22,14 +22,14 @@ import { import { useState } from 'react' import toast from 'react-hot-toast' import { useUserContractBets } from 'web/hooks/use-user-bets' -import { placeBet } from 'web/lib/firebase/api-call' +import { placeBet } from 'web/lib/firebase/api' import { getBinaryProb, getBinaryProbPercent } from 'web/lib/firebase/contracts' import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon' import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon' import { Col } from '../layout/col' import { OUTCOME_TO_COLOR } from '../outcome-label' import { useSaveBinaryShares } from '../use-save-binary-shares' -import { sellShares } from 'web/lib/firebase/api-call' +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' diff --git a/web/components/groups/create-group-button.tsx b/web/components/groups/create-group-button.tsx index b6b11292..0685d8e4 100644 --- a/web/components/groups/create-group-button.tsx +++ b/web/components/groups/create-group-button.tsx @@ -9,7 +9,7 @@ import { Title } from '../title' import { FilterSelectUsers } from 'web/components/filter-select-users' import { User } from 'common/user' import { MAX_GROUP_NAME_LENGTH } from 'common/group' -import { createGroup } from 'web/lib/firebase/api-call' +import { createGroup } from 'web/lib/firebase/api' export function CreateGroupButton(props: { user: User diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx index 5a6a67c0..82ae627d 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -5,7 +5,7 @@ import { getFormattedMappedValue } from 'common/pseudo-numeric' import { formatMoney, formatPercent } from 'common/util/format' import { sortBy } from 'lodash' import { useState } from 'react' -import { cancelBet } from 'web/lib/firebase/api-call' +import { cancelBet } from 'web/lib/firebase/api' import { Col } from './layout/col' import { LoadingIndicator } from './loading-indicator' import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' diff --git a/web/components/liquidity-panel.tsx b/web/components/liquidity-panel.tsx index d1e066be..7ecadeb7 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/liquidity-panel.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' import { CPMMContract } from 'common/contract' import { formatMoney } from 'common/util/format' import { useUser } from 'web/hooks/use-user' -import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api-call' +import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api' import { AmountInput } from './amount-input' import { Row } from './layout/row' import { useUserLiquidity } from 'web/hooks/use-liquidity' diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index 2938fd17..478b4ad4 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -6,7 +6,7 @@ import { usePrivateUser, useUser } from 'web/hooks/use-user' import { useRouter } from 'next/router' import { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' -import { requestBonuses } from 'web/lib/firebase/api-call' +import { requestBonuses } from 'web/lib/firebase/api' import { PrivateUser } from 'common/user' export default function NotificationsIcon(props: { className?: string }) { diff --git a/web/components/numeric-bet-panel.tsx b/web/components/numeric-bet-panel.tsx index 9246bc89..e3b4bc29 100644 --- a/web/components/numeric-bet-panel.tsx +++ b/web/components/numeric-bet-panel.tsx @@ -10,9 +10,9 @@ import { import { NumericContract } from 'common/contract' import { formatPercent, formatMoney } from 'common/util/format' -import { useUser } from '../hooks/use-user' -import { APIError, placeBet } from '../lib/firebase/api-call' -import { User } from '../lib/firebase/users' +import { useUser } from 'web/hooks/use-user' +import { APIError, placeBet } from 'web/lib/firebase/api' +import { User } from 'web/lib/firebase/users' import { BuyAmountInput } from './amount-input' import { BucketInput } from './bucket-input' import { Col } from './layout/col' diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index 98a2aabc..371dd94b 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -7,7 +7,7 @@ import { NumberCancelSelector } from './yes-no-selector' import { Spacer } from './layout/spacer' import { ResolveConfirmationButton } from './confirmation-button' import { NumericContract, PseudoNumericContract } from 'common/contract' -import { APIError, resolveMarket } from 'web/lib/firebase/api-call' +import { APIError, resolveMarket } from 'web/lib/firebase/api' import { BucketInput } from './bucket-input' import { getPseudoProbability } from 'common/pseudo-numeric' diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index a46d9478..10dee789 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -6,7 +6,7 @@ import { User } from 'web/lib/firebase/users' import { YesNoCancelSelector } from './yes-no-selector' import { Spacer } from './layout/spacer' import { ResolveConfirmationButton } from './confirmation-button' -import { APIError, resolveMarket } from 'web/lib/firebase/api-call' +import { APIError, resolveMarket } from 'web/lib/firebase/api' import { ProbabilitySelector } from './probability-selector' import { DPM_CREATOR_FEE } from 'common/fees' import { getProbability } from 'common/calculate' diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index e4b6580f..68ca5308 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -11,7 +11,7 @@ import { debounce, sum } from 'lodash' import { useEffect, useRef, useState } from 'react' import { CommentTips } from 'web/hooks/use-tip-txns' import { useUser } from 'web/hooks/use-user' -import { transact } from 'web/lib/firebase/api-call' +import { transact } from 'web/lib/firebase/api' import { track } from 'web/lib/service/analytics' import { Row } from './layout/row' import { Tooltip } from './tooltip' diff --git a/web/lib/api/proxy.ts b/web/lib/api/proxy.ts index 294868ac..98ea161d 100644 --- a/web/lib/api/proxy.ts +++ b/web/lib/api/proxy.ts @@ -1,7 +1,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import { promisify } from 'util' import { pipeline } from 'stream' -import { getFunctionUrl } from 'web/lib/firebase/api-call' +import { getFunctionUrl } from 'common/api' import fetch, { Headers, Response } from 'node-fetch' function getProxiedRequestHeaders(req: NextApiRequest, whitelist: string[]) { diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api.ts similarity index 71% rename from web/lib/firebase/api-call.ts rename to web/lib/firebase/api.ts index fc1e78bd..a6bd4359 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api.ts @@ -1,16 +1,6 @@ import { auth } from './users' -import { ENV_CONFIG } from 'common/envs/constants' - -export class APIError extends Error { - code: number - details?: string - constructor(code: number, message: string, details?: string) { - super(message) - this.code = code - this.name = 'APIError' - this.details = details - } -} +import { APIError, getFunctionUrl } from 'common/api' +export { APIError } from 'common/api' export async function call(url: string, method: string, params: any) { const user = auth.currentUser @@ -35,21 +25,6 @@ export async function call(url: string, method: string, params: any) { }) } -// Our users access the API through the Vercel proxy routes at /api/v0/blah, -// but right now at least until we get performance under control let's have the -// app just hit the cloud functions directly -- there's no difference and it's -// one less hop - -export function getFunctionUrl(name: string) { - if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { - const { projectId, region } = ENV_CONFIG.firebaseConfig - return `http://localhost:5001/${projectId}/${region}/${name}` - } else { - const { cloudRunId, cloudRunRegion } = ENV_CONFIG - return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app` - } -} - export function createAnswer(params: any) { return call(getFunctionUrl('createanswer'), 'POST', params) } diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index d2e1ee04..29cc9266 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -23,7 +23,7 @@ import { import { zip } from 'lodash' import { app, db } from './init' import { PortfolioMetrics, PrivateUser, User } from 'common/user' -import { createUser } from './api-call' +import { createUser } from './api' import { coll, getValue, diff --git a/web/lib/service/stripe.ts b/web/lib/service/stripe.ts index bedd68aa..64d79487 100644 --- a/web/lib/service/stripe.ts +++ b/web/lib/service/stripe.ts @@ -1,4 +1,4 @@ -import { getFunctionUrl } from 'web/lib/firebase/api-call' +import { getFunctionUrl } from 'common/api' export const checkoutURL = ( userId: string, diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index c3e0912a..2cefa13b 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -10,7 +10,7 @@ import { Spacer } from 'web/components/layout/spacer' import { User } from 'common/user' import { useUser } from 'web/hooks/use-user' import { Linkify } from 'web/components/linkify' -import { transact } from 'web/lib/firebase/api-call' +import { transact } from 'web/lib/firebase/api' import { charities, Charity } from 'common/charity' import { useRouter } from 'next/router' import Custom404 from '../404' diff --git a/web/pages/create.tsx b/web/pages/create.tsx index f26d5687..f9b0dd00 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -6,7 +6,7 @@ import Textarea from 'react-expanding-textarea' import { Spacer } from 'web/components/layout/spacer' import { useUser } from 'web/hooks/use-user' import { Contract, contractPath } from 'web/lib/firebase/contracts' -import { createMarket } from 'web/lib/firebase/api-call' +import { createMarket } from 'web/lib/firebase/api' import { FIXED_ANTE } from 'common/antes' import { InfoTooltip } from 'web/components/info-tooltip' import { Page } from 'web/components/page' diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index b36a9057..01597a15 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/router' import { useState } from 'react' import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' -import { claimManalink } from 'web/lib/firebase/api-call' +import { claimManalink } from 'web/lib/firebase/api' import { useManalink } from 'web/lib/firebase/manalinks' import { ManalinkCard } from 'web/components/manalink-card' import { useUser } from 'web/hooks/use-user' diff --git a/web/pages/make-predictions.tsx b/web/pages/make-predictions.tsx index ce694278..b22fe371 100644 --- a/web/pages/make-predictions.tsx +++ b/web/pages/make-predictions.tsx @@ -16,7 +16,7 @@ import { Linkify } from 'web/components/linkify' import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { useUser } from 'web/hooks/use-user' -import { createMarket } from 'web/lib/firebase/api-call' +import { createMarket } from 'web/lib/firebase/api' import { contractPath } from 'web/lib/firebase/contracts' type Prediction = { diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index 62177825..b80698ae 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -9,7 +9,7 @@ import { Title } from 'web/components/title' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { cleanDisplayName, cleanUsername } from 'common/util/clean-username' -import { changeUserInfo } from 'web/lib/firebase/api-call' +import { changeUserInfo } from 'web/lib/firebase/api' import { uploadImage } from 'web/lib/firebase/storage' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' From 78ceac0659c0f1ef11068afa4fbf4a8963c5a06e Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 18:22:21 -0500 Subject: [PATCH 141/220] =?UTF-8?q?Don't=20load=20user=20bets=20twice=20?= =?UTF-8?q?=F0=9F=91=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/components/bets-list.tsx | 10 ++++++---- web/components/user-page.tsx | 14 +++++++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 40eaf04b..c240d422 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -4,7 +4,6 @@ import dayjs from 'dayjs' import { useEffect, useState } from 'react' import clsx from 'clsx' -import { useUserBets } from 'web/hooks/use-user-bets' import { Bet } from 'web/lib/firebase/bets' import { User } from 'web/lib/firebase/users' import { @@ -51,13 +50,16 @@ import { floatingEqual } from 'common/util/math' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'sold' | 'closed' | 'resolved' | 'all' -export function BetsList(props: { user: User; hideBetsBefore?: number }) { - const { user, hideBetsBefore } = props +export function BetsList(props: { + user: User + bets: Bet[] | undefined + hideBetsBefore?: number +}) { + const { user, bets: allBets, hideBetsBefore } = props const signedInUser = useUser() const isYourBets = user.id === signedInUser?.id - const allBets = useUserBets(user.id, { includeRedemptions: true }) // Hide bets before 06-01-2022 if this isn't your own profile // NOTE: This means public profits also begin on 06-01-2022 as well. const bets = allBets?.filter( diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index af03eb46..64eab05c 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -76,7 +76,12 @@ export function UserPage(props: { const [usersContracts, setUsersContracts] = useState<Contract[] | 'loading'>( 'loading' ) - const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading') + const [userBets, setUserBets] = useState<Bet[] | undefined>() + const betCount = + userBets === undefined + ? 0 + : userBets.filter((bet) => !bet.isRedemption && bet.amount !== 0).length + const [portfolioHistory, setUsersPortfolioHistory] = useState< PortfolioMetrics[] >([]) @@ -95,7 +100,7 @@ export function UserPage(props: { if (!user) return getUsersComments(user.id).then(setUsersComments) listContracts(user.id).then(setUsersContracts) - getUserBets(user.id, { includeRedemptions: false }).then(setUsersBets) + getUserBets(user.id, { includeRedemptions: true }).then(setUserBets) getPortfolioHistory(user.id).then(setUsersPortfolioHistory) }, [user]) @@ -307,13 +312,12 @@ export function UserPage(props: { /> <BetsList user={user} + bets={userBets} hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022} /> </div> ), - tabIcon: ( - <div className="px-0.5 font-bold">{usersBets.length}</div> - ), + tabIcon: <div className="px-0.5 font-bold">{betCount}</div>, }, ]} /> From 5c6a143614e771b07d2b279490a07f84e29f2ed2 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 18:26:06 -0500 Subject: [PATCH 142/220] Change portfolio graph option labels --- web/components/portfolio/portfolio-value-section.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index 55260bb5..903b3f3d 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -40,8 +40,8 @@ export const PortfolioValueSection = memo( }} > <option value="allTime">All time</option> - <option value="weekly">Weekly</option> - <option value="daily">Daily</option> + <option value="weekly">7 days</option> + <option value="daily">24 hours</option> </select> </Row> <PortfolioValueGraph From 162e73912ee51146cf250883881b8e3ef73b79ca Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 18:41:33 -0500 Subject: [PATCH 143/220] Paginate bets list --- web/components/bets-list.tsx | 29 +++++++++++++++++------- web/components/pagination.tsx | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 web/components/pagination.tsx diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index c240d422..6d19c1e4 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' import { uniq, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' import dayjs from 'dayjs' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import clsx from 'clsx' import { Bet } from 'web/lib/firebase/bets' @@ -46,10 +46,13 @@ import { SellSharesModal } from './sell-modal' import { useUnfilledBets } from 'web/hooks/use-bets' import { LimitBet } from 'common/bet' import { floatingEqual } from 'common/util/math' +import { Pagination } from './pagination' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'sold' | 'closed' | 'resolved' | 'all' +const CONTRACTS_PER_PAGE = 20 + export function BetsList(props: { user: User bets: Bet[] | undefined @@ -62,13 +65,17 @@ export function BetsList(props: { // Hide bets before 06-01-2022 if this isn't your own profile // NOTE: This means public profits also begin on 06-01-2022 as well. - const bets = allBets?.filter( - (bet) => bet.createdTime >= (hideBetsBefore ?? 0) + const bets = useMemo( + () => allBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)), + [allBets, hideBetsBefore] ) const [contracts, setContracts] = useState<Contract[] | undefined>() const [sort, setSort] = useState<BetSort>('newest') const [filter, setFilter] = useState<BetFilter>('open') + const [page, setPage] = useState(0) + const start = page * CONTRACTS_PER_PAGE + const end = start + CONTRACTS_PER_PAGE useEffect(() => { if (bets) { @@ -85,16 +92,14 @@ export function BetsList(props: { disposed = true } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allBets, hideBetsBefore]) + }, [bets]) const getTime = useTimeSinceFirstRender() useEffect(() => { if (bets && contracts) { trackLatency('portfolio', getTime()) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [!!bets, !!contracts]) + }, [bets, contracts, getTime]) if (!bets || !contracts) { return <LoadingIndicator /> @@ -130,7 +135,7 @@ export function BetsList(props: { (filter === 'open' ? -1 : 1) * (c.resolutionTime ?? c.closeTime ?? Infinity), } - const displayedContracts = sortBy(contracts, SORTS[sort]) + const filteredContracts = sortBy(contracts, SORTS[sort]) .reverse() .filter(FILTERS[filter]) .filter((c) => { @@ -141,6 +146,7 @@ export function BetsList(props: { if (filter === 'sold') return !hasShares return hasShares }) + const displayedContracts = filteredContracts.slice(start, end) const unsettled = contracts.filter( (c) => !c.isResolved && contractsMetrics[c.id].invested !== 0 @@ -227,6 +233,13 @@ export function BetsList(props: { )) )} </Col> + + <Pagination + page={page} + itemsPerPage={CONTRACTS_PER_PAGE} + totalItems={filteredContracts.length} + setPage={setPage} + /> </Col> ) } diff --git a/web/components/pagination.tsx b/web/components/pagination.tsx new file mode 100644 index 00000000..f5c5eeab --- /dev/null +++ b/web/components/pagination.tsx @@ -0,0 +1,42 @@ +export function Pagination(props: { + page: number + itemsPerPage: number + totalItems: number + setPage: (page: number) => void +}) { + const { page, itemsPerPage, totalItems, setPage } = props + + return ( + <nav + className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" + aria-label="Pagination" + > + <div className="hidden sm:block"> + <p className="text-sm text-gray-700"> + Showing{' '} + <span className="font-medium"> + {page === 0 ? page + 1 : page * itemsPerPage} + </span>{' '} + to <span className="font-medium">{(page + 1) * itemsPerPage}</span> of{' '} + <span className="font-medium">{totalItems}</span> results + </p> + </div> + <div className="flex flex-1 justify-between sm:justify-end"> + <a + href="#" + className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => page > 1 && setPage(page - 1)} + > + Previous + </a> + <a + href="#" + className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => page < totalItems / itemsPerPage && setPage(page + 1)} + > + Next + </a> + </div> + </nav> + ) +} From f294189e203bfda46c0ef1ccbcb43417318cca54 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 18:50:59 -0500 Subject: [PATCH 144/220] Refactor notifications to use Pagination component --- web/components/pagination.tsx | 9 +++--- web/pages/notifications.tsx | 54 +++++++---------------------------- 2 files changed, 15 insertions(+), 48 deletions(-) diff --git a/web/components/pagination.tsx b/web/components/pagination.tsx index f5c5eeab..968e49a8 100644 --- a/web/components/pagination.tsx +++ b/web/components/pagination.tsx @@ -3,8 +3,9 @@ export function Pagination(props: { itemsPerPage: number totalItems: number setPage: (page: number) => void + scrollToTop?: boolean }) { - const { page, itemsPerPage, totalItems, setPage } = props + const { page, itemsPerPage, totalItems, setPage, scrollToTop } = props return ( <nav @@ -23,14 +24,14 @@ export function Pagination(props: { </div> <div className="flex flex-1 justify-between sm:justify-end"> <a - href="#" + href={scrollToTop ? '#' : undefined} className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" - onClick={() => page > 1 && setPage(page - 1)} + onClick={() => page > 0 && setPage(page - 1)} > Previous </a> <a - href="#" + href={scrollToTop ? '#' : undefined} className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" onClick={() => page < totalItems / itemsPerPage && setPage(page + 1)} > diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index dd43d64f..7a8f6d2f 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -36,6 +36,7 @@ import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' import { groupBy, sum, uniq } from 'lodash' import Custom404 from 'web/pages/404' import { track } from '@amplitude/analytics-browser' +import { Pagination } from 'web/components/pagination' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' @@ -82,14 +83,14 @@ export default function Notifications() { function NotificationsList(props: { privateUser: PrivateUser }) { const { privateUser } = props - const [page, setPage] = useState(1) + const [page, setPage] = useState(0) const allGroupedNotifications = usePreferredGroupedNotifications(privateUser) const [paginatedGroupedNotifications, setPaginatedGroupedNotifications] = useState<NotificationGroup[] | undefined>(undefined) useEffect(() => { if (!allGroupedNotifications) return - const start = (page - 1) * NOTIFICATIONS_PER_PAGE + const start = page * NOTIFICATIONS_PER_PAGE const end = start + NOTIFICATIONS_PER_PAGE const maxNotificationsToShow = allGroupedNotifications.slice(start, end) const remainingNotification = allGroupedNotifications.slice(end) @@ -132,48 +133,13 @@ function NotificationsList(props: { privateUser: PrivateUser }) { )} {paginatedGroupedNotifications.length > 0 && allGroupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( - <nav - className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" - aria-label="Pagination" - > - <div className="hidden sm:block"> - <p className="text-sm text-gray-700"> - Showing{' '} - <span className="font-medium"> - {page === 1 ? page : (page - 1) * NOTIFICATIONS_PER_PAGE} - </span>{' '} - to{' '} - <span className="font-medium"> - {page * NOTIFICATIONS_PER_PAGE} - </span>{' '} - of{' '} - <span className="font-medium"> - {allGroupedNotifications.length} - </span>{' '} - results - </p> - </div> - <div className="flex flex-1 justify-between sm:justify-end"> - <a - href="#" - className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" - onClick={() => page > 1 && setPage(page - 1)} - > - Previous - </a> - <a - href="#" - className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" - onClick={() => - page < - allGroupedNotifications?.length / NOTIFICATIONS_PER_PAGE && - setPage(page + 1) - } - > - Next - </a> - </div> - </nav> + <Pagination + page={page} + itemsPerPage={NOTIFICATIONS_PER_PAGE} + totalItems={allGroupedNotifications.length} + setPage={setPage} + scrollToTop + /> )} </div> ) From 5e1ed17cdfaf8397183621468b32922fe5287f3a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 19:19:35 -0500 Subject: [PATCH 145/220] Load contracts at UserPage top level instead of in BetsList --- web/components/bets-list.tsx | 39 ++++++++------------------ web/components/comments-list.tsx | 15 ++++++++-- web/components/user-page.tsx | 47 ++++++++++++++++---------------- 3 files changed, 47 insertions(+), 54 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 6d19c1e4..17ba9fa3 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -1,5 +1,5 @@ import Link from 'next/link' -import { uniq, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' +import { groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' import dayjs from 'dayjs' import { useEffect, useMemo, useState } from 'react' import clsx from 'clsx' @@ -16,7 +16,6 @@ import { Col } from './layout/col' import { Spacer } from './layout/spacer' import { Contract, - getContractFromId, contractPath, getBinaryProbPercent, } from 'web/lib/firebase/contracts' @@ -25,7 +24,6 @@ import { UserLink } from './user-page' import { sellBet } from 'web/lib/firebase/api' import { ConfirmationButton } from './confirmation-button' import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' -import { filterDefined } from 'common/util/array' import { LoadingIndicator } from './loading-indicator' import { SiteLink } from './site-link' import { @@ -56,9 +54,10 @@ const CONTRACTS_PER_PAGE = 20 export function BetsList(props: { user: User bets: Bet[] | undefined + contractsById: { [id: string]: Contract } | undefined hideBetsBefore?: number }) { - const { user, bets: allBets, hideBetsBefore } = props + const { user, bets: allBets, contractsById, hideBetsBefore } = props const signedInUser = useUser() const isYourBets = user.id === signedInUser?.id @@ -69,7 +68,6 @@ export function BetsList(props: { () => allBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)), [allBets, hideBetsBefore] ) - const [contracts, setContracts] = useState<Contract[] | undefined>() const [sort, setSort] = useState<BetSort>('newest') const [filter, setFilter] = useState<BetFilter>('open') @@ -77,39 +75,26 @@ export function BetsList(props: { const start = page * CONTRACTS_PER_PAGE const end = start + CONTRACTS_PER_PAGE - useEffect(() => { - if (bets) { - const contractIds = uniq(bets.map((bet) => bet.contractId)) - - let disposed = false - Promise.all(contractIds.map((id) => getContractFromId(id))).then( - (contracts) => { - if (!disposed) setContracts(filterDefined(contracts)) - } - ) - - return () => { - disposed = true - } - } - }, [bets]) - const getTime = useTimeSinceFirstRender() useEffect(() => { - if (bets && contracts) { + if (bets && contractsById) { trackLatency('portfolio', getTime()) } - }, [bets, contracts, getTime]) + }, [bets, contractsById, getTime]) - if (!bets || !contracts) { + if (!bets || !contractsById) { return <LoadingIndicator /> } - if (bets.length === 0) return <NoBets user={user} /> + // Decending creation time. bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime) const contractBets = groupBy(bets, 'contractId') - const contractsById = Object.fromEntries(contracts.map((c) => [c.id, c])) + + // Keep only contracts that have bets. + const contracts = Object.values(contractsById).filter( + (c) => contractBets[c.id] + ) const contractsMetrics = mapValues(contractBets, (bets, contractId) => { const contract = contractsById[contractId] diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index bceb2d59..ab9ed523 100644 --- a/web/components/comments-list.tsx +++ b/web/components/comments-list.tsx @@ -9,16 +9,25 @@ import { UserLink } from './user-page' import { User } from 'common/user' import { Col } from './layout/col' import { Linkify } from './linkify' +import { groupBy } from 'lodash' export function UserCommentsList(props: { user: User - commentsByUniqueContracts: Map<Contract, Comment[]> + comments: Comment[] + contractsById: { [id: string]: Contract } }) { - const { commentsByUniqueContracts } = props + const { comments, contractsById } = props + const commentsByContract = groupBy(comments, 'contractId') + + const contractCommentPairs = Object.entries(commentsByContract) + .map( + ([contractId, comments]) => [contractsById[contractId], comments] as const + ) + .filter(([contract]) => contract) return ( <Col className={'bg-white'}> - {Array.from(commentsByUniqueContracts).map(([contract, comments]) => ( + {contractCommentPairs.map(([contract, comments]) => ( <div key={contract.id} className={'border-width-1 border-b p-5'}> <div className={'mb-2 text-sm text-indigo-700'}> <SiteLink href={contractPath(contract)}> diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 64eab05c..3e455b03 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { uniq } from 'lodash' +import { Dictionary, keyBy, uniq } from 'lodash' import { useEffect, useState } from 'react' import { useRouter } from 'next/router' import { LinkIcon } from '@heroicons/react/solid' @@ -39,6 +39,7 @@ import { PortfolioMetrics } from 'common/user' import { ReferralsButton } from 'web/components/referrals-button' import { GroupsButton } from 'web/components/groups/groups-button' import { PortfolioValueSection } from './portfolio/portfolio-value-section' +import { filterDefined } from 'common/util/array' export function UserLink(props: { name: string @@ -72,7 +73,7 @@ export function UserPage(props: { const router = useRouter() const isCurrentUser = user.id === currentUser?.id const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) - const [usersComments, setUsersComments] = useState<Comment[]>([] as Comment[]) + const [usersComments, setUsersComments] = useState<Comment[] | undefined>() const [usersContracts, setUsersContracts] = useState<Contract[] | 'loading'>( 'loading' ) @@ -85,9 +86,9 @@ export function UserPage(props: { const [portfolioHistory, setUsersPortfolioHistory] = useState< PortfolioMetrics[] >([]) - const [commentsByContract, setCommentsByContract] = useState< - Map<Contract, Comment[]> | 'loading' - >('loading') + const [contractsById, setContractsById] = useState< + Dictionary<Contract> | undefined + >() const [showConfetti, setShowConfetti] = useState(false) const { width, height } = useWindowSize() @@ -106,25 +107,21 @@ export function UserPage(props: { // TODO: display comments on groups useEffect(() => { - const uniqueContractIds = uniq( - usersComments.map((comment) => comment.contractId) - ) - Promise.all( - uniqueContractIds.map( - (contractId) => contractId && getContractFromId(contractId) - ) - ).then((contracts) => { - const commentsByContract = new Map<Contract, Comment[]>() - contracts.forEach((contract) => { - if (!contract) return - commentsByContract.set( - contract, - usersComments.filter((comment) => comment.contractId === contract.id) + if (usersComments && userBets) { + const uniqueContractIds = uniq([ + ...usersComments.map((comment) => comment.contractId), + ...(userBets?.map((bet) => bet.contractId) ?? []), + ]) + Promise.all( + uniqueContractIds.map((contractId) => + contractId ? getContractFromId(contractId) : undefined ) + ).then((contracts) => { + const contractsById = keyBy(filterDefined(contracts), 'id') + setContractsById(contractsById) }) - setCommentsByContract(commentsByContract) - }) - }, [usersComments]) + } + }, [userBets, usersComments]) const yourFollows = useFollows(currentUser?.id) const isFollowing = yourFollows?.includes(user.id) @@ -265,7 +262,7 @@ export function UserPage(props: { <Spacer h={10} /> - {usersContracts !== 'loading' && commentsByContract != 'loading' ? ( + {usersContracts !== 'loading' && contractsById && usersComments ? ( <Tabs currentPageForAnalytics={'profile'} labelClassName={'pb-2 pt-1 '} @@ -296,7 +293,8 @@ export function UserPage(props: { content: ( <UserCommentsList user={user} - commentsByUniqueContracts={commentsByContract} + contractsById={contractsById} + comments={usersComments} /> ), tabIcon: ( @@ -314,6 +312,7 @@ export function UserPage(props: { user={user} bets={userBets} hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022} + contractsById={contractsById} /> </div> ), From 67edc7b6390182a670f9d663647cd07bbf41ba62 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 19:42:34 -0500 Subject: [PATCH 146/220] UserPage: Load user with getStatic props --- web/pages/[username]/index.tsx | 35 ++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/web/pages/[username]/index.tsx b/web/pages/[username]/index.tsx index c2f56d78..3c44a5cc 100644 --- a/web/pages/[username]/index.tsx +++ b/web/pages/[username]/index.tsx @@ -1,30 +1,45 @@ import { useRouter } from 'next/router' -import React, { useEffect, useState } from 'react' +import React from 'react' import { getUserByUsername, User } from 'web/lib/firebase/users' import { UserPage } from 'web/components/user-page' import { useUser } from 'web/hooks/use-user' import Custom404 from '../404' import { useTracking } from 'web/hooks/use-tracking' +import { fromPropz, usePropz } from 'web/hooks/use-propz' + +export const getStaticProps = fromPropz(getStaticPropz) +export async function getStaticPropz(props: { params: { username: string } }) { + const { username } = props.params + const user = await getUserByUsername(username) + + return { + props: { + user, + }, + + revalidate: 60, // regenerate after a minute + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function UserProfile(props: { user: User | null }) { + props = usePropz(props, getStaticPropz) ?? { user: undefined } + const { user } = props -export default function UserProfile() { const router = useRouter() - const [user, setUser] = useState<User | null | 'loading'>('loading') const { username, tab } = router.query as { username: string tab?: string | undefined } - useEffect(() => { - if (username) { - getUserByUsername(username).then(setUser) - } - }, [username]) - const currentUser = useUser() useTracking('view user profile', { username }) - if (user === 'loading') return <div /> + if (user === undefined) return <div /> return user ? ( <UserPage From fd7384a099034b4cfb4ea1c406867ced498a63d7 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 19:59:23 -0500 Subject: [PATCH 147/220] Hide referrals button on user page --- web/components/user-page.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 3e455b03..aa6fd10d 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -36,7 +36,6 @@ import { FollowersButton, FollowingButton } from './following-button' import { useFollows } from 'web/hooks/use-follows' import { FollowButton } from './follow-button' import { PortfolioMetrics } from 'common/user' -import { ReferralsButton } from 'web/components/referrals-button' import { GroupsButton } from 'web/components/groups/groups-button' import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { filterDefined } from 'common/util/array' @@ -205,7 +204,7 @@ export function UserPage(props: { <Row className="gap-4"> <FollowingButton user={user} /> <FollowersButton user={user} /> - <ReferralsButton user={user} currentUser={currentUser} /> + {/* <ReferralsButton user={user} currentUser={currentUser} /> */} <GroupsButton user={user} /> </Row> From 9586e81e95139d9142b6c7c3a5da396dcd1e4477 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 22:07:42 -0500 Subject: [PATCH 148/220] Show limit bets in bets table --- web/components/bet-panel.tsx | 7 +++---- web/components/bets-list.tsx | 20 +++++++++++++++++++- web/components/limit-bets.tsx | 26 ++++++++++++++------------ 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 558697ef..17e41dff 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -124,10 +124,9 @@ export function SimpleBetPanel(props: { <Col className={className}> <Col className={clsx('rounded-b-md rounded-t-md bg-white px-8 py-6')}> <Row className="justify-between"> - <Title - className={clsx('!mt-0')} - text={isLimitOrder ? 'Limit bet' : 'Place a trade'} - /> + <div className="mb-6 text-2xl"> + {isLimitOrder ? <>Limit bet</> : <>Place your bet</>} + </div> <button className="btn btn-ghost btn-sm text-sm normal-case" diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 17ba9fa3..ffa536ca 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -45,6 +45,7 @@ import { useUnfilledBets } from 'web/hooks/use-bets' import { LimitBet } from 'common/bet' import { floatingEqual } from 'common/util/math' import { Pagination } from './pagination' +import { LimitBets } from './limit-bets' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'sold' | 'closed' | 'resolved' | 'all' @@ -256,6 +257,9 @@ function ContractBets(props: { const { bets, contract, metric, isYourBets } = props const { resolution, outcomeType } = contract + const limitBets = bets.filter( + (bet) => bet.limitProb !== undefined + ) as LimitBet[] const resolutionValue = (contract as NumericContract).resolutionValue const [collapsed, setCollapsed] = useState(true) @@ -350,7 +354,21 @@ function ContractBets(props: { isYourBets={isYourBets} /> - <Spacer h={8} /> + <Spacer h={4} /> + + {contract.mechanism === 'cpmm-1' && limitBets.length > 0 && ( + <> + <div className="bg-gray-50 px-4 py-2">Your limit bets</div> + <LimitBets + className="max-w-md px-2 py-0 sm:px-4" + contract={contract} + bets={limitBets} + hideLabel + /> + </> + )} + + <Spacer h={4} /> <ContractBetsTable contract={contract} diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx index 82ae627d..f25ce495 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -13,9 +13,10 @@ import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' export function LimitBets(props: { contract: CPMMBinaryContract | PseudoNumericContract bets: LimitBet[] + hideLabel?: boolean className?: string }) { - const { contract, bets, className } = props + const { contract, bets, hideLabel, className } = props const recentBets = sortBy( bets, (bet) => -1 * bet.limitProb, @@ -24,18 +25,19 @@ export function LimitBets(props: { return ( <Col - className={clsx(className, 'gap-2 overflow-hidden rounded bg-white py-3')} + className={clsx( + className, + 'gap-2 overflow-hidden rounded bg-white px-4 py-3' + )} > - <div className="px-6 py-3 text-2xl">Your limit bets</div> - <div className="px-4"> - <table className="table-compact table w-full rounded text-gray-500"> - <tbody> - {recentBets.map((bet) => ( - <LimitBet key={bet.id} bet={bet} contract={contract} /> - ))} - </tbody> - </table> - </div> + {!hideLabel && <div className="px-2 py-3 text-2xl">Your limit bets</div>} + <table className="table-compact table w-full rounded text-gray-500"> + <tbody> + {recentBets.map((bet) => ( + <LimitBet key={bet.id} bet={bet} contract={contract} /> + ))} + </tbody> + </table> </Col> ) } From 99fcfa6be7eebcfd54058928279eb5d02592d40a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 22:15:07 -0500 Subject: [PATCH 149/220] Add portfolio filter for limit bets. --- web/components/bet-panel.tsx | 1 - web/components/bets-list.tsx | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 17e41dff..4fa4774a 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -16,7 +16,6 @@ import { formatWithCommas, } from 'common/util/format' import { getBinaryCpmmBetInfo } from 'common/new-bet' -import { Title } from './title' import { User } from 'web/lib/firebase/users' import { Bet, LimitBet } from 'common/bet' import { APIError, placeBet } from 'web/lib/firebase/api' diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index ffa536ca..2a7da76e 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -48,7 +48,7 @@ import { Pagination } from './pagination' import { LimitBets } from './limit-bets' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' -type BetFilter = 'open' | 'sold' | 'closed' | 'resolved' | 'all' +type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' const CONTRACTS_PER_PAGE = 20 @@ -110,6 +110,7 @@ export function BetsList(props: { open: (c) => !(FILTERS.closed(c) || FILTERS.resolved(c)), all: () => true, sold: () => true, + limit_bet: (c) => FILTERS.open(c), } const SORTS: Record<BetSort, (c: Contract) => number> = { profit: (c) => contractsMetrics[c.id].profit, @@ -130,6 +131,8 @@ export function BetsList(props: { const { hasShares } = contractsMetrics[c.id] if (filter === 'sold') return !hasShares + if (filter === 'limit_bet') + return (contractBets[c.id] ?? []).some((b) => b.limitProb !== undefined) return hasShares }) const displayedContracts = filteredContracts.slice(start, end) @@ -185,6 +188,7 @@ export function BetsList(props: { onChange={(e) => setFilter(e.target.value as BetFilter)} > <option value="open">Open</option> + <option value="limit_bet">Limit bets</option> <option value="sold">Sold</option> <option value="closed">Closed</option> <option value="resolved">Resolved</option> From 89d48d6c34032b2a7d018ddec61be99f99b56e96 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 22:28:04 -0500 Subject: [PATCH 150/220] Use hook to fetch user bets --- web/components/user-page.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index aa6fd10d..be3f3ac4 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -30,8 +30,6 @@ import { Contract } from 'common/contract' import { getContractFromId, listContracts } from 'web/lib/firebase/contracts' import { LoadingIndicator } from './loading-indicator' import { BetsList } from './bets-list' -import { Bet } from 'common/bet' -import { getUserBets } from 'web/lib/firebase/bets' import { FollowersButton, FollowingButton } from './following-button' import { useFollows } from 'web/hooks/use-follows' import { FollowButton } from './follow-button' @@ -39,6 +37,7 @@ import { PortfolioMetrics } from 'common/user' import { GroupsButton } from 'web/components/groups/groups-button' import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { filterDefined } from 'common/util/array' +import { useUserBets } from 'web/hooks/use-user-bets' export function UserLink(props: { name: string @@ -76,7 +75,7 @@ export function UserPage(props: { const [usersContracts, setUsersContracts] = useState<Contract[] | 'loading'>( 'loading' ) - const [userBets, setUserBets] = useState<Bet[] | undefined>() + const userBets = useUserBets(user.id, { includeRedemptions: true }) const betCount = userBets === undefined ? 0 @@ -100,7 +99,6 @@ export function UserPage(props: { if (!user) return getUsersComments(user.id).then(setUsersComments) listContracts(user.id).then(setUsersContracts) - getUserBets(user.id, { includeRedemptions: true }).then(setUserBets) getPortfolioHistory(user.id).then(setUsersPortfolioHistory) }, [user]) From 098f20ccad23d81ffc8f90703bfa43a7112cc12d Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 22:28:29 -0500 Subject: [PATCH 151/220] Fix limit bet filter to exclude cancelled and filled bets --- web/components/bets-list.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 2a7da76e..d5e64c46 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -132,7 +132,9 @@ export function BetsList(props: { if (filter === 'sold') return !hasShares if (filter === 'limit_bet') - return (contractBets[c.id] ?? []).some((b) => b.limitProb !== undefined) + return (contractBets[c.id] ?? []).some( + (b) => b.limitProb !== undefined && !b.isCancelled && !b.isFilled + ) return hasShares }) const displayedContracts = filteredContracts.slice(start, end) @@ -262,7 +264,7 @@ function ContractBets(props: { const { resolution, outcomeType } = contract const limitBets = bets.filter( - (bet) => bet.limitProb !== undefined + (bet) => bet.limitProb !== undefined && !bet.isCancelled && !bet.isFilled ) as LimitBet[] const resolutionValue = (contract as NumericContract).resolutionValue From 1e68267e8ef18448de79500d40fb627a345dde15 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 10 Jul 2022 23:09:46 -0500 Subject: [PATCH 152/220] Use relative import --- common/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/api.ts b/common/api.ts index 02dba409..b9376be5 100644 --- a/common/api.ts +++ b/common/api.ts @@ -1,4 +1,4 @@ -import { ENV_CONFIG } from 'common/envs/constants' +import { ENV_CONFIG } from './envs/constants' export class APIError extends Error { code: number From a2a08b90ffc44365e9b06d30f9c8782cb44889cc Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 11 Jul 2022 07:51:48 -0600 Subject: [PATCH 153/220] Show numeric resolution contract value --- web/pages/notifications.tsx | 50 +++++++++++-------------------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 7a8f6d2f..362ed433 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -342,8 +342,6 @@ function IncomeNotificationItem(props: { <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> <div className={'mr-1 text-black'}> <NotificationTextLabel - contract={null} - defaultText={notification.sourceText ?? ''} className={'line-clamp-1'} notification={notification} justSummary={true} @@ -375,11 +373,7 @@ function IncomeNotificationItem(props: { <div className={'line-clamp-2 flex max-w-xl shrink '}> <div className={'inline'}> <span className={'mr-1'}> - <NotificationTextLabel - contract={null} - defaultText={notification.sourceText ?? ''} - notification={notification} - /> + <NotificationTextLabel notification={notification} /> </span> </div> <span> @@ -532,18 +526,6 @@ function NotificationItem(props: { sourceText, } = notification - const [defaultNotificationText, setDefaultNotificationText] = - useState<string>('') - - useEffect(() => { - if (sourceText) { - setDefaultNotificationText(sourceText) - } else if (reasonText) { - // Handle arbitrary notifications with reason text here. - setDefaultNotificationText(reasonText) - } - }, [reasonText, sourceText]) - const [highlighted] = useState(!notification.isSeen) useEffect(() => { @@ -569,8 +551,6 @@ function NotificationItem(props: { </span> <div className={'ml-1 text-black'}> <NotificationTextLabel - contract={null} - defaultText={defaultNotificationText} className={'line-clamp-1'} notification={notification} justSummary={true} @@ -648,11 +628,7 @@ function NotificationItem(props: { </div> </Row> <div className={'mt-1 ml-1 md:text-base'}> - <NotificationTextLabel - contract={null} - defaultText={defaultNotificationText} - notification={notification} - /> + <NotificationTextLabel notification={notification} /> </div> <div className={'mt-6 border-b border-gray-300'} /> @@ -770,18 +746,21 @@ function getSourceIdForLinkComponent( } function NotificationTextLabel(props: { - defaultText: string - contract?: Contract | null notification: Notification className?: string justSummary?: boolean }) { - const { contract, className, defaultText, notification, justSummary } = props - const { sourceUpdateType, sourceType, sourceText, sourceContractTitle } = - notification + const { className, notification, justSummary } = props + const { + sourceUpdateType, + sourceType, + sourceText, + sourceContractTitle, + reasonText, + } = notification + const defaultText = sourceText ?? reasonText ?? '' if (sourceType === 'contract') { - if (justSummary) - return <span>{contract?.question || sourceContractTitle}</span> + if (justSummary) return <span>{sourceContractTitle}</span> if (!sourceText) return <div /> // Resolved contracts if (sourceType === 'contract' && sourceUpdateType === 'resolved') { @@ -795,9 +774,8 @@ function NotificationTextLabel(props: { ) if (sourceText === 'CANCEL') return <CancelLabel /> if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel /> - if (contract?.outcomeType === 'PSEUDO_NUMERIC') { - return <NumericValueLabel value={parseFloat(sourceText)} /> - } + // Numeric market + return <NumericValueLabel value={parseFloat(sourceText)} /> } } // Close date will be a number - it looks better without it From 86c256cbf7ba9cbc0c797b6496a0c6c1ccaa56c8 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 11 Jul 2022 08:01:26 -0600 Subject: [PATCH 154/220] Unused var --- web/pages/notifications.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 362ed433..8cdd2cb1 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -10,7 +10,6 @@ import { doc, updateDoc } from 'firebase/firestore' import { db } from 'web/lib/firebase/init' import { UserLink } from 'web/components/user-page' import { notification_subscribe_types, PrivateUser } from 'common/user' -import { Contract } from 'common/contract' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users' import { LoadingIndicator } from 'web/components/loading-indicator' From 52d688885d65c9304210ccd9f8d6aa09f34e9f56 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 11 Jul 2022 08:11:52 -0600 Subject: [PATCH 155/220] Group income notifs by source title --- web/pages/notifications.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 8cdd2cb1..191747fe 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -179,15 +179,16 @@ function IncomeNotificationGroupItem(props: { (n) => n.sourceType ) for (const sourceType in groupedNotificationsBySourceType) { - const groupedNotificationsByContractId = groupBy( + // Source title splits by contracts and groups + const groupedNotificationsBySourceTitle = groupBy( groupedNotificationsBySourceType[sourceType], (notification) => { - return notification.sourceContractId + return notification.sourceTitle } ) - for (const contractId in groupedNotificationsByContractId) { + for (const contractId in groupedNotificationsBySourceTitle) { const notificationsForContractId = - groupedNotificationsByContractId[contractId] + groupedNotificationsBySourceTitle[contractId] if (notificationsForContractId.length === 1) { newNotifications.push(notificationsForContractId[0]) continue From dd6f5e5ef4f598b913781f3700803d030af2e272 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 11 Jul 2022 10:49:33 -0500 Subject: [PATCH 156/220] Show better limit order stats in bets table --- web/components/bets-list.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index d5e64c46..8a461658 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -668,6 +668,14 @@ function BetRow(props: { ? 'N/A' : formatMoney(calculatePayout(contract, bet, bet.outcome)) + const hadPoolMatch = + bet.fills?.some((fill) => fill.matchedBetId === null) ?? false + + const ofTotalAmount = + bet.limitProb === undefined || bet.orderAmount === undefined + ? '' + : ` / ${formatMoney(bet.orderAmount)}` + return ( <tr> <td className="text-neutral"> @@ -694,13 +702,22 @@ function BetRow(props: { {isPseudoNumeric && ' than ' + formatNumericProbability(bet.probAfter, contract)} </td> - <td>{formatMoney(Math.abs(amount))}</td> + <td> + {formatMoney(Math.abs(amount))} + {ofTotalAmount} + </td> {!isCPMM && !isNumeric && <td>{saleDisplay}</td>} {!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>} <td>{formatWithCommas(Math.abs(shares))}</td> {!isPseudoNumeric && ( <td> - {formatPercent(probBefore)} → {formatPercent(probAfter)} + {outcomeType === 'FREE_RESPONSE' || hadPoolMatch ? ( + <> + {formatPercent(probBefore)} → {formatPercent(probAfter)} + </> + ) : ( + formatPercent(bet.limitProb ?? 0) + )} </td> )} <td>{dayjs(createdTime).format('MMM D, h:mma')}</td> From 9b252b93ab4ad63f2880d4890a59a3339e9d3ba2 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 11 Jul 2022 10:54:37 -0500 Subject: [PATCH 157/220] Fix fee calculation in bet panel tooltip --- web/components/bet-panel.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 4fa4774a..78f98390 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx' import React, { useEffect, useState } from 'react' -import { partition, sumBy } from 'lodash' +import { partition, sum, sumBy } from 'lodash' import { SwitchHorizontalIcon } from '@heroicons/react/solid' import { useUser } from 'web/hooks/use-user' @@ -26,11 +26,7 @@ import { BinaryOutcomeLabel } from './outcome-label' import { getProbability } from 'common/calculate' import { useFocus } from 'web/hooks/use-focus' import { useUserContractBets } from 'web/hooks/use-user-bets' -import { - calculateCpmmSale, - getCpmmProbability, - getCpmmFees, -} from 'common/calculate-cpmm' +import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { getFormattedMappedValue, getPseudoProbability, @@ -271,11 +267,7 @@ function BuyPanel(props: { const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = formatPercent(currentReturn) - const cpmmFees = getCpmmFees( - contract, - betAmount ?? 0, - betChoice ?? 'YES' - ).totalFees + const totalFees = sum(Object.values(newBet.fees)) const format = getFormattedMappedValue(contract) @@ -362,7 +354,7 @@ function BuyPanel(props: { )} </div> <InfoTooltip - text={`Includes ${formatMoneyWithDecimals(cpmmFees)} in fees`} + text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`} /> </Row> <div> From 7b60cc63ce0b8e19e4600f50f3415b10ae5809f1 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 11 Jul 2022 09:56:10 -0600 Subject: [PATCH 158/220] Fix annoying create description scrolling on firefox --- web/pages/create.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index f9b0dd00..fd071310 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -101,7 +101,7 @@ export function NewContract(props: { (params?.outcomeType as outcomeType) ?? 'BINARY' ) const [initialProb] = useState(50) - + const [bottomRef, setBottomRef] = useState<HTMLDivElement | null>(null) const [minString, setMinString] = useState(params?.min ?? '') const [maxString, setMaxString] = useState(params?.max ?? '') const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale) @@ -185,7 +185,6 @@ export function NewContract(props: { if (!creator || !isValid) return setIsSubmitting(true) - // TODO: add contract id to the group contractIds try { const result = await createMarket( removeUndefinedProps({ @@ -410,8 +409,12 @@ export function NewContract(props: { value={description} disabled={isSubmitting} onClick={(e) => e.stopPropagation()} - onChange={(e) => setDescription(e.target.value || '')} + onChange={(e) => { + setDescription(e.target.value || '') + bottomRef?.scrollIntoView() + }} /> + <div ref={setBottomRef} /> </div> <Spacer h={6} /> From 61300e93a4101397251b1bea54878ae5f312a0be Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 11 Jul 2022 11:38:51 -0500 Subject: [PATCH 159/220] more validation for creating numeric markets --- functions/src/create-contract.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 0d78ab5c..9f14ea7a 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -46,7 +46,8 @@ const binarySchema = z.object({ initialProb: z.number().min(1).max(99), }) -const finite = () => z.number().gte(Number.MIN_SAFE_INTEGER).lte(Number.MAX_SAFE_INTEGER) +const finite = () => + z.number().gte(Number.MIN_SAFE_INTEGER).lte(Number.MAX_SAFE_INTEGER) const numericSchema = z.object({ min: finite(), @@ -67,10 +68,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => { numericSchema, req.body )) - if (max - min <= 0.01 || initialValue < min || initialValue > max) + if (max - min <= 0.01 || initialValue <= min || initialValue >= max) throw new APIError(400, 'Invalid range.') initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100 + + if (initialProb < 1 || initialProb > 99) + throw new APIError(400, 'Invalid initial value.') } if (outcomeType === 'BINARY') { ;({ initialProb } = validate(binarySchema, req.body)) From 90a75985dd112747906cfc33ee7916cef824f394 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 11 Jul 2022 11:46:07 -0500 Subject: [PATCH 160/220] In market bets tab, show limit orders' total order amount --- web/components/feed/feed-bets.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index 2ffdae8e..83656f8e 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -78,11 +78,19 @@ export function BetStatusText(props: { const { bet, contract, bettor, isSelf, hideOutcome } = props const { outcomeType } = contract const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + const isFreeResponse = outcomeType === 'FREE_RESPONSE' const { amount, outcome, createdTime } = bet const bought = amount >= 0 ? 'bought' : 'sold' + const outOfTotalAmount = + bet.limitProb !== undefined && bet.orderAmount !== undefined + ? ` / ${formatMoney(bet.orderAmount)}` + : '' const money = formatMoney(Math.abs(amount)) + const hadPoolMatch = + bet.fills?.some((fill) => fill.matchedBetId === null) ?? false + return ( <div className="text-sm text-gray-500"> {bettor ? ( @@ -91,6 +99,7 @@ export function BetStatusText(props: { <span>{isSelf ? 'You' : 'A trader'}</span> )}{' '} {bought} {money} + {outOfTotalAmount} {!hideOutcome && ( <> {' '} @@ -103,7 +112,12 @@ export function BetStatusText(props: { />{' '} {isPseudoNumeric ? ' than ' + formatNumericProbability(bet.probAfter, contract) - : ' at ' + formatPercent(bet.probAfter)} + : ' at ' + + formatPercent( + hadPoolMatch || isFreeResponse + ? bet.probAfter + : bet.limitProb ?? bet.probAfter + )} </> )} <RelativeTimestamp time={createdTime} /> From ed9a2c0d355d2ce261ba737197f43408f2e303f8 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 11 Jul 2022 14:52:16 -0600 Subject: [PATCH 161/220] Set min height for group chat --- web/components/groups/group-chat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 6e82b05c..4a7e9e90 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -105,7 +105,7 @@ export function GroupChat(props: { <Col className={'mt-2 flex-1'}> <Col className={ - 'max-h-[65vh] w-full space-y-2 overflow-x-hidden overflow-y-scroll' + 'max-h-[65vh] min-h-[65vh] w-full space-y-2 overflow-x-hidden overflow-y-scroll' } ref={setScrollToBottomRef} > From 24fac1fc0b65c58b134230a2fcfe0c608c84c710 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 11 Jul 2022 15:53:11 -0500 Subject: [PATCH 162/220] Fix erronous 0 prob shown in table --- web/components/bets-list.tsx | 4 +++- web/components/feed/feed-bets.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 8a461658..158d14de 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -669,7 +669,9 @@ function BetRow(props: { : formatMoney(calculatePayout(contract, bet, bet.outcome)) const hadPoolMatch = - bet.fills?.some((fill) => fill.matchedBetId === null) ?? false + (bet.limitProb === undefined || + bet.fills?.some((fill) => fill.matchedBetId === null)) ?? + false const ofTotalAmount = bet.limitProb === undefined || bet.orderAmount === undefined diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index 83656f8e..1520e57c 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -89,7 +89,9 @@ export function BetStatusText(props: { const money = formatMoney(Math.abs(amount)) const hadPoolMatch = - bet.fills?.some((fill) => fill.matchedBetId === null) ?? false + (bet.limitProb === undefined || + bet.fills?.some((fill) => fill.matchedBetId === null)) ?? + false return ( <div className="text-sm text-gray-500"> From b8d7c2ee17966bcc94b4ee15db2e960b31a6f8f1 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 11 Jul 2022 18:40:25 -0500 Subject: [PATCH 163/220] Size group chat window & nav bar list of groups precisely. Update Page margin/padding. --- web/components/groups/group-chat.tsx | 16 +++++++++++++--- web/components/nav/nav-bar.tsx | 4 ++-- web/components/nav/sidebar.tsx | 16 +++++++++++++--- web/components/page.tsx | 16 +++++++++++----- web/pages/group/[...slugs]/index.tsx | 6 +++--- web/pages/notifications.tsx | 2 +- 6 files changed, 43 insertions(+), 17 deletions(-) diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 4a7e9e90..c98f1af1 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -22,6 +22,7 @@ import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { Tipper } from 'web/components/tipper' import { sum } from 'lodash' import { formatMoney } from 'common/util/format' +import { useWindowSize } from 'web/hooks/use-window-size' export function GroupChat(props: { messages: Comment[] @@ -101,11 +102,20 @@ export function GroupChat(props: { inputRef?.focus() } + const { width, height } = useWindowSize() + const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) + // Subtract bottom bar when it's showing (less than lg screen) + const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0 + const remainingHeight = + (height ?? window.innerHeight) - + (containerRef?.offsetTop ?? 0) - + bottomBarHeight + return ( - <Col className={'mt-2 flex-1'}> + <Col ref={setContainerRef} style={{ height: remainingHeight }}> <Col className={ - 'max-h-[65vh] min-h-[65vh] w-full space-y-2 overflow-x-hidden overflow-y-scroll' + 'w-full flex-1 space-y-2 overflow-x-hidden overflow-y-scroll pt-2' } ref={setScrollToBottomRef} > @@ -138,7 +148,7 @@ export function GroupChat(props: { )} </Col> {user && group.memberIds.includes(user.id) && ( - <div className=" flex w-full justify-start gap-2 p-2"> + <div className="flex w-full justify-start gap-2 p-2"> <div className="mt-1"> <Avatar username={user?.username} diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 2b065f1c..971aa89a 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -170,7 +170,7 @@ export function MobileSidebar(props: { leaveFrom="translate-x-0" leaveTo="-translate-x-full" > - <div className="relative flex w-full max-w-xs flex-1 flex-col bg-white pt-5 pb-4"> + <div className="relative flex w-full max-w-xs flex-1 flex-col bg-white"> <Transition.Child as={Fragment} enter="ease-in-out duration-300" @@ -191,7 +191,7 @@ export function MobileSidebar(props: { </button> </div> </Transition.Child> - <div className="mx-2 mt-5 h-0 flex-1 overflow-y-auto"> + <div className="mx-2 h-0 flex-1 overflow-y-auto"> <Sidebar className="pl-2" /> </div> </div> diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 6ab095ef..253a58be 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -18,7 +18,7 @@ import { ManifoldLogo } from './manifold-logo' import { MenuButton } from './menu' import { ProfileSummary } from './profile-menu' import NotificationsIcon from 'web/components/notifications-icon' -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { CreateQuestionButton } from 'web/components/create-question-button' import { useMemberGroups } from 'web/hooks/use-group' @@ -29,6 +29,7 @@ import { Spacer } from '../layout/spacer' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { setNotificationsAsSeen } from 'web/pages/notifications' import { PrivateUser } from 'common/user' +import { useWindowSize } from 'web/hooks/use-window-size' function getNavigation() { return [ @@ -199,7 +200,7 @@ export default function Sidebar(props: { className?: string }) { return ( <nav aria-label="Sidebar" className={className}> - <ManifoldLogo className="pb-6" twoLine /> + <ManifoldLogo className="py-6" twoLine /> <CreateQuestionButton user={user} /> <Spacer h={4} /> @@ -282,6 +283,11 @@ function GroupsList(props: { }) }, [currentPage, preferredNotifications]) + const { height } = useWindowSize() + const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) + const remainingHeight = + (height ?? window.innerHeight) - (containerRef?.offsetTop ?? 0) + return ( <> <SidebarItem @@ -289,7 +295,11 @@ function GroupsList(props: { currentPage={currentPage} /> - <div className="mt-1 space-y-0.5"> + <div + className="flex-1 space-y-0.5 overflow-y-scroll" + style={{ height: remainingHeight }} + ref={setContainerRef} + > {memberItems.map((item) => ( <a key={item.href} diff --git a/web/components/page.tsx b/web/components/page.tsx index 78a3d5e7..24c866b8 100644 --- a/web/components/page.tsx +++ b/web/components/page.tsx @@ -7,28 +7,34 @@ import { Toaster } from 'react-hot-toast' export function Page(props: { rightSidebar?: ReactNode suspend?: boolean + className?: string children?: ReactNode }) { - const { children, rightSidebar, suspend } = props + const { children, rightSidebar, suspend, className } = props + const bottomBarPadding = 'pb-[58px] lg:pb-0 ' return ( <> <div - className="mx-auto w-full pb-14 lg:grid lg:grid-cols-12 lg:gap-2 lg:pt-6 xl:max-w-7xl xl:gap-8" + className={clsx( + className, + bottomBarPadding, + 'mx-auto w-full lg:grid lg:grid-cols-12 lg:gap-x-2 xl:max-w-7xl xl:gap-x-8' + )} style={suspend ? visuallyHiddenStyle : undefined} > <Toaster /> - <Sidebar className="sticky top-4 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:block" /> + <Sidebar className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:block" /> <main className={clsx( - 'lg:col-span-8', + 'pt-6 lg:col-span-8', rightSidebar ? 'xl:col-span-7' : 'xl:col-span-8' )} > {children} {/* If right sidebar is hidden, place its content at the bottom of the page. */} - <div className="mt-4 block xl:hidden">{rightSidebar}</div> + <div className="block xl:hidden">{rightSidebar}</div> </main> <aside className="hidden xl:col-span-3 xl:block"> <div className="sticky top-4 space-y-4">{rightSidebar}</div> diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index dec25ab1..5882159d 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -215,14 +215,14 @@ export default function GroupPage(props: { </Col> ) return ( - <Page rightSidebar={rightSidebar}> + <Page rightSidebar={rightSidebar} className="!pb-0"> <SEO title={group.name} description={`Created by ${creator.name}. ${group.about}`} url={groupPath(group.slug)} /> - <Col className="px-3 lg:px-1"> + <Col className="px-3"> <Row className={'items-center justify-between gap-4'}> <div className={'sm:mb-1'}> <div @@ -251,7 +251,7 @@ export default function GroupPage(props: { <Tabs currentPageForAnalytics={groupPath(group.slug)} - className={'mb-0 sm:mb-2'} + className={'mx-3 mb-0'} defaultIndex={ page === 'rankings' ? 2 diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 191747fe..6e32eb88 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -47,7 +47,7 @@ export default function Notifications() { if (!user) return <Custom404 /> return ( <Page> - <div className={'p-2 sm:p-4'}> + <div className={'px-2 sm:px-4'}> <Title text={'Notifications'} className={'hidden md:block'} /> <div> <Tabs From 0882f1c0d669ea5373ad7c98fa6ff8ee14274f73 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 11 Jul 2022 19:07:37 -0500 Subject: [PATCH 164/220] Remove top Pagepadding on small screens --- web/components/page.tsx | 2 +- web/pages/notifications.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/page.tsx b/web/components/page.tsx index 24c866b8..e76a4dc2 100644 --- a/web/components/page.tsx +++ b/web/components/page.tsx @@ -27,7 +27,7 @@ export function Page(props: { <Sidebar className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:block" /> <main className={clsx( - 'pt-6 lg:col-span-8', + 'lg:col-span-8 lg:pt-6', rightSidebar ? 'xl:col-span-7' : 'xl:col-span-8' )} > diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 6e32eb88..db6c382d 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -47,7 +47,7 @@ export default function Notifications() { if (!user) return <Custom404 /> return ( <Page> - <div className={'px-2 sm:px-4'}> + <div className={'px-2 pt-4 sm:px-4 lg:pt-0'}> <Title text={'Notifications'} className={'hidden md:block'} /> <div> <Tabs From 43b30e6d0465273f16a8ce0f209182e79fe2a3ab Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 12 Jul 2022 12:36:10 -0700 Subject: [PATCH 165/220] Don't "warm up" resolveMarket anymore (#638) --- web/components/numeric-resolution-panel.tsx | 7 +------ web/components/resolution-panel.tsx | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index 371dd94b..dce36ab9 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import { Col } from './layout/col' import { User } from 'web/lib/firebase/users' @@ -16,11 +16,6 @@ export function NumericResolutionPanel(props: { contract: NumericContract | PseudoNumericContract className?: string }) { - useEffect(() => { - // warm up cloud function - resolveMarket({} as any).catch(() => {}) - }, []) - const { contract, className } = props const { min, max, outcomeType } = contract diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 10dee789..7bb9f2d4 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import { Col } from './layout/col' import { User } from 'web/lib/firebase/users' @@ -18,11 +18,6 @@ export function ResolutionPanel(props: { contract: BinaryContract className?: string }) { - useEffect(() => { - // warm up cloud function - resolveMarket({} as any).catch(() => {}) - }, []) - const { contract, className } = props const earnedFees = From 5fd42df1edc474b19d66bb7a63dcefa39307694b Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 12 Jul 2022 12:36:31 -0700 Subject: [PATCH 166/220] Don't run share redemption after adding liquidity (#631) --- functions/src/add-liquidity.ts | 114 ++++++++++++++++----------------- 1 file changed, 54 insertions(+), 60 deletions(-) diff --git a/functions/src/add-liquidity.ts b/functions/src/add-liquidity.ts index 3ef453c2..6746486e 100644 --- a/functions/src/add-liquidity.ts +++ b/functions/src/add-liquidity.ts @@ -4,7 +4,6 @@ import { z } from 'zod' import { Contract } from '../../common/contract' import { User } from '../../common/user' import { removeUndefinedProps } from '../../common/util/object' -import { redeemShares } from './redeem-shares' import { getNewLiquidityProvision } from '../../common/add-liquidity' import { APIError, newEndpoint, validate } from './api' @@ -19,78 +18,73 @@ export const addliquidity = newEndpoint({}, async (req, auth) => { if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') // run as transaction to prevent race conditions - return await firestore - .runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${auth.uid}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) throw new APIError(400, 'User not found') - const user = userSnap.data() as User + return await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) - if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') - const contract = contractSnap.data() as Contract - if ( - contract.mechanism !== 'cpmm-1' || - (contract.outcomeType !== 'BINARY' && - contract.outcomeType !== 'PSEUDO_NUMERIC') - ) - throw new APIError(400, 'Invalid contract') + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract + if ( + contract.mechanism !== 'cpmm-1' || + (contract.outcomeType !== 'BINARY' && + contract.outcomeType !== 'PSEUDO_NUMERIC') + ) + throw new APIError(400, 'Invalid contract') - const { closeTime } = contract - if (closeTime && Date.now() > closeTime) - throw new APIError(400, 'Trading is closed') + const { closeTime } = contract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed') - if (user.balance < amount) throw new APIError(400, 'Insufficient balance') + if (user.balance < amount) throw new APIError(400, 'Insufficient balance') - const newLiquidityProvisionDoc = firestore - .collection(`contracts/${contractId}/liquidity`) - .doc() + const newLiquidityProvisionDoc = firestore + .collection(`contracts/${contractId}/liquidity`) + .doc() - const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = - getNewLiquidityProvision( - user, - amount, - contract, - newLiquidityProvisionDoc.id - ) - - if (newP !== undefined && !isFinite(newP)) { - return { - status: 'error', - message: 'Liquidity injection rejected due to overflow error.', - } - } - - transaction.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - p: newP, - totalLiquidity: newTotalLiquidity, - }) + const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = + getNewLiquidityProvision( + user, + amount, + contract, + newLiquidityProvisionDoc.id ) - const newBalance = user.balance - amount - const newTotalDeposits = user.totalDeposits - amount - - if (!isFinite(newBalance)) { - throw new APIError(500, 'Invalid user balance for ' + user.username) + if (newP !== undefined && !isFinite(newP)) { + return { + status: 'error', + message: 'Liquidity injection rejected due to overflow error.', } + } - transaction.update(userDoc, { - balance: newBalance, - totalDeposits: newTotalDeposits, + transaction.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + p: newP, + totalLiquidity: newTotalLiquidity, }) + ) - transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) + const newBalance = user.balance - amount + const newTotalDeposits = user.totalDeposits - amount - return newLiquidityProvision - }) - .then(async (result) => { - await redeemShares(auth.uid, contractId) - return result + if (!isFinite(newBalance)) { + throw new APIError(500, 'Invalid user balance for ' + user.username) + } + + transaction.update(userDoc, { + balance: newBalance, + totalDeposits: newTotalDeposits, }) + + transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) + + return newLiquidityProvision + }) }) const firestore = admin.firestore() From 24896e44b42fab8c7b867a09ac8927b6177fa5df Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 12 Jul 2022 16:46:03 -0500 Subject: [PATCH 167/220] "limit bet" => "limit order" --- functions/src/cancel-bet.ts | 2 +- functions/src/place-bet.ts | 2 +- web/components/bet-panel.tsx | 8 +++++--- web/components/bets-list.tsx | 4 ++-- web/components/limit-bets.tsx | 2 +- web/pages/notifications.tsx | 2 +- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/functions/src/cancel-bet.ts b/functions/src/cancel-bet.ts index 27e65ffb..d29a6cee 100644 --- a/functions/src/cancel-bet.ts +++ b/functions/src/cancel-bet.ts @@ -21,7 +21,7 @@ export const cancelbet = newEndpoint({}, async (req, auth) => { if (bet.userId !== auth.uid) throw new APIError(400, 'Not authorized to cancel bet.') if (bet.limitProb === undefined) - throw new APIError(400, 'Not a limit bet: Cannot cancel.') + throw new APIError(400, 'Not a limit order: Cannot cancel.') if (bet.isCancelled) throw new APIError(400, 'Bet already cancelled.') trans.update(betDoc.ref, { isCancelled: true }) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 52daf953..3c428f43 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -186,7 +186,7 @@ export const updateMakers = ( const totalAmount = sumBy(fills, 'amount') const isFilled = floatingEqual(totalAmount, bet.orderAmount) - log('Updated a matched limit bet.') + log('Updated a matched limit order.') trans.update(contractDoc.collection('bets').doc(bet.id), { fills, isFilled, diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 78f98390..57218844 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -71,7 +71,7 @@ export function BetPanel(props: { > <Row className="align-center justify-between"> <div className="mb-6 text-2xl"> - {isLimitOrder ? <>Limit bet</> : <>Place your bet</>} + {isLimitOrder ? <>Limit order</> : <>Place your bet</>} </div> <button className="btn btn-ghost btn-sm text-sm normal-case" @@ -120,7 +120,7 @@ export function SimpleBetPanel(props: { <Col className={clsx('rounded-b-md rounded-t-md bg-white px-8 py-6')}> <Row className="justify-between"> <div className="mb-6 text-2xl"> - {isLimitOrder ? <>Limit bet</> : <>Place your bet</>} + {isLimitOrder ? <>Limit order</> : <>Place your bet</>} </div> <button @@ -385,7 +385,9 @@ function BuyPanel(props: { </button> )} - {wasSubmitted && <div className="mt-4">Bet submitted!</div>} + {wasSubmitted && ( + <div className="mt-4">{isLimitOrder ? 'Order' : 'Bet'} submitted!</div> + )} </> ) } diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 158d14de..a1164798 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -190,7 +190,7 @@ export function BetsList(props: { onChange={(e) => setFilter(e.target.value as BetFilter)} > <option value="open">Open</option> - <option value="limit_bet">Limit bets</option> + <option value="limit_bet">Limit orders</option> <option value="sold">Sold</option> <option value="closed">Closed</option> <option value="resolved">Resolved</option> @@ -364,7 +364,7 @@ function ContractBets(props: { {contract.mechanism === 'cpmm-1' && limitBets.length > 0 && ( <> - <div className="bg-gray-50 px-4 py-2">Your limit bets</div> + <div className="bg-gray-50 px-4 py-2">Your limit orders</div> <LimitBets className="max-w-md px-2 py-0 sm:px-4" contract={contract} diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx index f25ce495..22ac115e 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -30,7 +30,7 @@ export function LimitBets(props: { 'gap-2 overflow-hidden rounded bg-white px-4 py-3' )} > - {!hideLabel && <div className="px-2 py-3 text-2xl">Your limit bets</div>} + {!hideLabel && <div className="px-2 py-3 text-2xl">Your limit orders</div>} <table className="table-compact table w-full rounded text-gray-500"> <tbody> {recentBets.map((bet) => ( diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index db6c382d..10ae1ec8 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -817,7 +817,7 @@ function NotificationTextLabel(props: { <span className="text-primary"> {formatMoney(parseInt(sourceText))} </span>{' '} - <span>of your limit bet was filled</span> + <span>of your limit order was filled</span> </> ) } From dd9fdc381fe51f1c4e84cbf2dd70e09aceb8257a Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 12 Jul 2022 16:55:00 -0500 Subject: [PATCH 168/220] track limit orders --- web/components/bet-panel.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 57218844..6279fe2f 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -54,6 +54,10 @@ export function BetPanel(props: { const { sharesOutcome } = useSaveBinaryShares(contract, userBets) const [isLimitOrder, setIsLimitOrder] = useState(false) + const toggleLimitOrder = () => { + setIsLimitOrder(!isLimitOrder) + track('toggle limit order') + } return ( <Col className={className}> @@ -75,7 +79,7 @@ export function BetPanel(props: { </div> <button className="btn btn-ghost btn-sm text-sm normal-case" - onClick={() => setIsLimitOrder(!isLimitOrder)} + onClick={toggleLimitOrder} > <SwitchHorizontalIcon className="inline h-6 w-6" /> </button> @@ -242,6 +246,8 @@ function BuyPanel(props: { contractId: contract.id, amount: betAmount, outcome: betChoice, + isLimitOrder, + limitProb: limitProbScaled, }) } From 38aad405691875eb11d2c67b49d2b14ac41f4f21 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Tue, 12 Jul 2022 17:34:10 -0500 Subject: [PATCH 169/220] Simplify bet buttons (#644) * mono-button bet row * "bet yes" => "yes" * prettier --- web/components/bet-panel.tsx | 15 +++++++- web/components/bet-row.tsx | 61 ++++++++++++------------------ web/components/limit-bets.tsx | 4 +- web/components/yes-no-selector.tsx | 4 +- 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 6279fe2f..91c6fe00 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -109,9 +109,10 @@ export function SimpleBetPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract className?: string selected?: 'YES' | 'NO' + hasShares?: boolean onBetSuccess?: () => void }) { - const { contract, className, selected, onBetSuccess } = props + const { contract, className, selected, hasShares, onBetSuccess } = props const user = useUser() const [isLimitOrder, setIsLimitOrder] = useState(false) @@ -121,7 +122,17 @@ export function SimpleBetPanel(props: { return ( <Col className={className}> - <Col className={clsx('rounded-b-md rounded-t-md bg-white px-8 py-6')}> + <SellRow + contract={contract} + user={user} + className={'rounded-t-md bg-gray-100 px-4 py-5'} + /> + <Col + className={clsx( + !hasShares && 'rounded-t-md', + 'rounded-b-md bg-white px-8 py-6' + )} + > <Row className="justify-between"> <div className="mb-6 text-2xl"> {isLimitOrder ? <>Limit order</> : <>Place your bet</>} diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index 712d4a2c..f8624130 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -2,13 +2,12 @@ import { useState } from 'react' import clsx from 'clsx' import { SimpleBetPanel } from './bet-panel' -import { YesNoSelector } from './yes-no-selector' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { Modal } from './layout/modal' -import { SellButton } from './sell-button' import { useUser } from 'web/hooks/use-user' import { useUserContractBets } from 'web/hooks/use-user-bets' import { useSaveBinaryShares } from './use-save-binary-shares' +import { Col } from './layout/col' // Inline version of a bet panel. Opens BetPanel in a new modal. export default function BetRow(props: { @@ -19,9 +18,7 @@ export default function BetRow(props: { }) { const { className, btnClassName, betPanelClassName, contract } = props const [open, setOpen] = useState(false) - const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>( - undefined - ) + const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) const { yesShares, noShares, hasYesShares, hasNoShares } = @@ -29,43 +26,33 @@ export default function BetRow(props: { return ( <> - <YesNoSelector - isPseudoNumeric={contract.outcomeType === 'PSEUDO_NUMERIC'} - className={clsx('justify-end', className)} - btnClassName={clsx('btn-sm w-24', btnClassName)} - onSelect={(choice) => { - setOpen(true) - setBetChoice(choice) - }} - replaceNoButton={ - hasYesShares ? ( - <SellButton - panelClassName={betPanelClassName} - contract={contract} - user={user} - sharesOutcome={'YES'} - shares={yesShares} - /> - ) : undefined - } - replaceYesButton={ - hasNoShares ? ( - <SellButton - panelClassName={betPanelClassName} - contract={contract} - user={user} - sharesOutcome={'NO'} - shares={noShares} - /> - ) : undefined - } - /> + <Col className={clsx('items-center', className)}> + <button + className={clsx( + 'btn btn-lg btn-outline my-auto inline-flex h-10 min-h-0 w-24', + btnClassName + )} + onClick={() => setOpen(true)} + > + Bet + </button> + + <div className={'mt-1 w-24 text-center text-sm text-gray-500'}> + {hasYesShares + ? `(${Math.floor(yesShares)} YES)` + : hasNoShares + ? `(${Math.floor(noShares)} NO)` + : ''} + </div> + </Col> + <Modal open={open} setOpen={setOpen}> <SimpleBetPanel className={betPanelClassName} contract={contract} - selected={betChoice} + selected={undefined} onBetSuccess={() => setOpen(false)} + hasShares={hasYesShares || hasNoShares} /> </Modal> </> diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx index 22ac115e..503b3d17 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -30,7 +30,9 @@ export function LimitBets(props: { 'gap-2 overflow-hidden rounded bg-white px-4 py-3' )} > - {!hideLabel && <div className="px-2 py-3 text-2xl">Your limit orders</div>} + {!hideLabel && ( + <div className="px-2 py-3 text-2xl">Your limit orders</div> + )} <table className="table-compact table w-full rounded text-gray-500"> <tbody> {recentBets.map((bet) => ( diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index cac7bf74..3b3cc21d 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -43,7 +43,7 @@ export function YesNoSelector(props: { )} onClick={() => onSelect('YES')} > - {isPseudoNumeric ? 'HIGHER' : 'Bet YES'} + {isPseudoNumeric ? 'HIGHER' : 'YES'} </button> )} {replaceNoButton ? ( @@ -60,7 +60,7 @@ export function YesNoSelector(props: { )} onClick={() => onSelect('NO')} > - {isPseudoNumeric ? 'LOWER' : 'Bet NO'} + {isPseudoNumeric ? 'LOWER' : 'NO'} </button> )} </Row> From 5c166b9dd52d87b69148bc1e375ca949043a1369 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 12 Jul 2022 17:47:28 -0500 Subject: [PATCH 170/220] bet row: 'higher' 'lower' labels --- web/components/bet-row.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index f8624130..974d9a63 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -24,6 +24,9 @@ export default function BetRow(props: { const { yesShares, noShares, hasYesShares, hasNoShares } = useSaveBinaryShares(contract, userBets) + const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + return ( <> <Col className={clsx('items-center', className)}> @@ -39,9 +42,9 @@ export default function BetRow(props: { <div className={'mt-1 w-24 text-center text-sm text-gray-500'}> {hasYesShares - ? `(${Math.floor(yesShares)} YES)` + ? `(${Math.floor(yesShares)} ${isPseudoNumeric ? 'HIGHER' : 'YES'})` : hasNoShares - ? `(${Math.floor(noShares)} NO)` + ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'YES'})` : ''} </div> </Col> From 68343701caac30092897123fad5f07caa3b61853 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 12 Jul 2022 17:47:48 -0500 Subject: [PATCH 171/220] answer bet panel: scroll up on ios --- web/components/answers/answer-bet-panel.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 6499ce36..8c1d0430 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -25,6 +25,7 @@ import { import { Bet } from 'common/bet' import { track } from 'web/lib/service/analytics' import { SignUpPrompt } from '../sign-up-prompt' +import { isIOS } from 'web/lib/util/device' export function AnswerBetPanel(props: { answer: Answer @@ -44,6 +45,7 @@ export function AnswerBetPanel(props: { const inputRef = useRef<HTMLElement>(null) useEffect(() => { + if (isIOS()) window.scrollTo(0, window.scrollY + 200) inputRef.current && inputRef.current.focus() }, []) From 10c510fc6bccda41a165583d9ee389ae68b9eed3 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 12 Jul 2022 18:27:22 -0700 Subject: [PATCH 172/220] Feature Wild Animal Initiative --- common/charity.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index 0d8a0aa6..8c33cb17 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -300,10 +300,21 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Wild Animal Initiative', website: 'https://www.wildanimalinitiative.org/', ein: '82-2281466', + tags: ['Featured'] as CharityTag[], photo: 'https://i.imgur.com/bOVUnDm.png', - preview: 'We want to make life better for wild animals.', - description: - 'Wild Animal Initiative (WAI) currently operates in the U.S., where they work to strengthen the animal advocacy movement through creating an academic field dedicated to wild animal welfare. They compile literature reviews, write theoretical and opinion articles, and publish research results on their website and/or in peer-reviewed journals. WAI focuses on identifying and sharing possible research avenues and connecting with more established fields. They also work with researchers from various academic and non-academic institutions to identify potential collaborators, and they recently launched a grant assistance program.', + preview: + 'Our mission is to understand and improve the lives of wild animals.', + description: `Although the natural world is a source of great beauty and happiness, vast numbers of animals routinely face serious challenges such as disease, hunger, or natural disasters. There is no “one-size-fits-all” solution to these threats. However, even as we recognize that improving the welfare of free-ranging wild animals is difficult, we believe that humans have a responsibility to help whenever we can. + +Our staff explores how humans can beneficially coexist with animals through the lens of wild animal welfare. + +We respect wild animals as individuals with their own needs and preferences, rather than seeing them as mere parts of ecosystems. But this approach demands a richer understanding of wild animals’ lives. + +We want to take a proactive approach to managing the welfare benefits, threats, and uncertainties that are inherent to complex natural and urban environments. Yet, to take action safely, we must conduct research to understand the impacts of our actions. The transdisciplinary perspective of wild animal welfare draws upon ethics, ecology, and animal welfare science to gather the knowledge we need, facilitating evidence-based improvements to wild animals’ quality of life. + +Without sufficient public interest or research activity, solutions to the problems wild animals face will go undiscovered. + +Wild Animal Initiative currently focuses on helping scientists, grantors, and decision-makers investigate important and understudied questions about wild animal welfare. Our work catalyzes research and applied projects that will open the door to a clearer picture of wild animals’ needs and how to enhance their well-being. Ultimately, we envision a world in which people actively choose to help wild animals — and have the knowledge they need to do so responsibly.`, }, { name: 'New Incentives', From 1f2bdf40d0d94c19a4771fd8a2f159d7c0c9d33c Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 13 Jul 2022 00:07:12 -0500 Subject: [PATCH 173/220] bet row: fix labels --- web/components/bet-row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index 974d9a63..e538acbf 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -44,7 +44,7 @@ export default function BetRow(props: { {hasYesShares ? `(${Math.floor(yesShares)} ${isPseudoNumeric ? 'HIGHER' : 'YES'})` : hasNoShares - ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'YES'})` + ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})` : ''} </div> </Col> From 96a378f25fc8f367e361cdefe037d48e5a1c5017 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 13 Jul 2022 07:41:58 -0600 Subject: [PATCH 174/220] Handle free response resolution --- web/pages/notifications.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 10ae1ec8..2001c557 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -774,8 +774,17 @@ function NotificationTextLabel(props: { ) if (sourceText === 'CANCEL') return <CancelLabel /> if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel /> + // Numeric market - return <NumericValueLabel value={parseFloat(sourceText)} /> + if (parseFloat(sourceText)) + return <NumericValueLabel value={parseFloat(sourceText)} /> + + // Free response market + return ( + <div className={className ? className : 'line-clamp-1 text-blue-400'}> + <Linkify text={sourceText} /> + </div> + ) } } // Close date will be a number - it looks better without it From 9e90f849a812f01a96a4a570cf8646db17e4630d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 13 Jul 2022 07:57:51 -0600 Subject: [PATCH 175/220] Show group scrollbars only when needed --- web/components/nav/sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 253a58be..430e98d2 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -296,7 +296,7 @@ function GroupsList(props: { /> <div - className="flex-1 space-y-0.5 overflow-y-scroll" + className="flex-1 space-y-0.5 overflow-auto" style={{ height: remainingHeight }} ref={setContainerRef} > From b3f4c2f0098c923673b5bbe10e0ba08a56821833 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 13 Jul 2022 08:34:14 -0600 Subject: [PATCH 176/220] Disable enter to submit on mobile group chat --- web/components/feed/feed-comments.tsx | 14 +++++++++----- web/components/groups/group-chat.tsx | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index c327d8af..198e9c36 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -31,6 +31,7 @@ import { track } from 'web/lib/service/analytics' import { useEvent } from 'web/hooks/use-event' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' +import useMediaQuery from 'react-query/types/devtools/useMediaQuery' export function FeedCommentThread(props: { contract: Contract @@ -472,7 +473,7 @@ export function CommentInputTextArea(props: { isSubmitting: boolean setRef?: (ref: HTMLTextAreaElement) => void presetId?: string - enterToSubmit?: boolean + enterToSubmitOnDesktop?: boolean }) { const { isReply, @@ -484,9 +485,9 @@ export function CommentInputTextArea(props: { presetId, isSubmitting, replyToUsername, - enterToSubmit, + enterToSubmitOnDesktop, } = props - + const isMobile = innerWidth < 768 const memoizedSetComment = useEvent(setComment) useEffect(() => { if (!replyToUsername || !user || replyToUsername === user.username) return @@ -507,7 +508,7 @@ export function CommentInputTextArea(props: { placeholder={ isReply ? 'Write a reply... ' - : enterToSubmit + : enterToSubmitOnDesktop ? 'Send a message' : 'Write a comment...' } @@ -516,7 +517,10 @@ export function CommentInputTextArea(props: { disabled={isSubmitting} onKeyDown={(e) => { if ( - (enterToSubmit && e.key === 'Enter' && !e.shiftKey) || + (enterToSubmitOnDesktop && + e.key === 'Enter' && + !e.shiftKey && + !isMobile) || (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) ) { e.preventDefault() diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index c98f1af1..2cf2d73d 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -165,7 +165,7 @@ export function GroupChat(props: { replyToUsername={replyToUsername} submitComment={submitMessage} isSubmitting={isSubmitting} - enterToSubmit={true} + enterToSubmitOnDesktop={true} setRef={setInputRef} /> </div> From e3f7f0efdad7320117ebd109b2c2630e2b7fecc8 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 13 Jul 2022 08:44:27 -0600 Subject: [PATCH 177/220] Revert "Disable enter to submit on mobile group chat" This reverts commit b3f4c2f0098c923673b5bbe10e0ba08a56821833. --- web/components/feed/feed-comments.tsx | 14 +++++--------- web/components/groups/group-chat.tsx | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 198e9c36..c327d8af 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -31,7 +31,6 @@ import { track } from 'web/lib/service/analytics' import { useEvent } from 'web/hooks/use-event' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' -import useMediaQuery from 'react-query/types/devtools/useMediaQuery' export function FeedCommentThread(props: { contract: Contract @@ -473,7 +472,7 @@ export function CommentInputTextArea(props: { isSubmitting: boolean setRef?: (ref: HTMLTextAreaElement) => void presetId?: string - enterToSubmitOnDesktop?: boolean + enterToSubmit?: boolean }) { const { isReply, @@ -485,9 +484,9 @@ export function CommentInputTextArea(props: { presetId, isSubmitting, replyToUsername, - enterToSubmitOnDesktop, + enterToSubmit, } = props - const isMobile = innerWidth < 768 + const memoizedSetComment = useEvent(setComment) useEffect(() => { if (!replyToUsername || !user || replyToUsername === user.username) return @@ -508,7 +507,7 @@ export function CommentInputTextArea(props: { placeholder={ isReply ? 'Write a reply... ' - : enterToSubmitOnDesktop + : enterToSubmit ? 'Send a message' : 'Write a comment...' } @@ -517,10 +516,7 @@ export function CommentInputTextArea(props: { disabled={isSubmitting} onKeyDown={(e) => { if ( - (enterToSubmitOnDesktop && - e.key === 'Enter' && - !e.shiftKey && - !isMobile) || + (enterToSubmit && e.key === 'Enter' && !e.shiftKey) || (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) ) { e.preventDefault() diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 2cf2d73d..c98f1af1 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -165,7 +165,7 @@ export function GroupChat(props: { replyToUsername={replyToUsername} submitComment={submitMessage} isSubmitting={isSubmitting} - enterToSubmitOnDesktop={true} + enterToSubmit={true} setRef={setInputRef} /> </div> From 490eabf977239e6728ab3f6982fbfef3f5676b8e Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 13 Jul 2022 09:08:32 -0600 Subject: [PATCH 178/220] Revert "Revert "Disable enter to submit on mobile group chat"" This reverts commit e3f7f0efdad7320117ebd109b2c2630e2b7fecc8. --- web/components/feed/feed-comments.tsx | 14 +++++++++----- web/components/groups/group-chat.tsx | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index c327d8af..198e9c36 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -31,6 +31,7 @@ import { track } from 'web/lib/service/analytics' import { useEvent } from 'web/hooks/use-event' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' +import useMediaQuery from 'react-query/types/devtools/useMediaQuery' export function FeedCommentThread(props: { contract: Contract @@ -472,7 +473,7 @@ export function CommentInputTextArea(props: { isSubmitting: boolean setRef?: (ref: HTMLTextAreaElement) => void presetId?: string - enterToSubmit?: boolean + enterToSubmitOnDesktop?: boolean }) { const { isReply, @@ -484,9 +485,9 @@ export function CommentInputTextArea(props: { presetId, isSubmitting, replyToUsername, - enterToSubmit, + enterToSubmitOnDesktop, } = props - + const isMobile = innerWidth < 768 const memoizedSetComment = useEvent(setComment) useEffect(() => { if (!replyToUsername || !user || replyToUsername === user.username) return @@ -507,7 +508,7 @@ export function CommentInputTextArea(props: { placeholder={ isReply ? 'Write a reply... ' - : enterToSubmit + : enterToSubmitOnDesktop ? 'Send a message' : 'Write a comment...' } @@ -516,7 +517,10 @@ export function CommentInputTextArea(props: { disabled={isSubmitting} onKeyDown={(e) => { if ( - (enterToSubmit && e.key === 'Enter' && !e.shiftKey) || + (enterToSubmitOnDesktop && + e.key === 'Enter' && + !e.shiftKey && + !isMobile) || (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) ) { e.preventDefault() diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index c98f1af1..2cf2d73d 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -165,7 +165,7 @@ export function GroupChat(props: { replyToUsername={replyToUsername} submitComment={submitMessage} isSubmitting={isSubmitting} - enterToSubmit={true} + enterToSubmitOnDesktop={true} setRef={setInputRef} /> </div> From cc1431da605eba1012059345eb3c24469a652662 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 13 Jul 2022 09:12:43 -0600 Subject: [PATCH 179/220] Disable enter submit on mobile on group chat --- web/components/feed/feed-comments.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 198e9c36..a9f30ee5 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -32,6 +32,7 @@ import { useEvent } from 'web/hooks/use-event' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import useMediaQuery from 'react-query/types/devtools/useMediaQuery' +import { useWindowSize } from 'web/hooks/use-window-size' export function FeedCommentThread(props: { contract: Contract @@ -487,7 +488,7 @@ export function CommentInputTextArea(props: { replyToUsername, enterToSubmitOnDesktop, } = props - const isMobile = innerWidth < 768 + const { width } = useWindowSize() const memoizedSetComment = useEvent(setComment) useEffect(() => { if (!replyToUsername || !user || replyToUsername === user.username) return @@ -520,7 +521,8 @@ export function CommentInputTextArea(props: { (enterToSubmitOnDesktop && e.key === 'Enter' && !e.shiftKey && - !isMobile) || + width && + width > 768) || (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) ) { e.preventDefault() From 18abad38b6f84d7ecf924664474801641149e54d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 13 Jul 2022 09:13:34 -0600 Subject: [PATCH 180/220] Unused var --- web/components/feed/feed-comments.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index a9f30ee5..195c5343 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -31,7 +31,6 @@ import { track } from 'web/lib/service/analytics' import { useEvent } from 'web/hooks/use-event' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' -import useMediaQuery from 'react-query/types/devtools/useMediaQuery' import { useWindowSize } from 'web/hooks/use-window-size' export function FeedCommentThread(props: { From 737d80390365f1ac392c37a0d0e561a7822ce208 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 13 Jul 2022 11:20:25 -0500 Subject: [PATCH 181/220] bet row: default to YES --- web/components/bet-row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index e538acbf..56fff9bd 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -53,7 +53,7 @@ export default function BetRow(props: { <SimpleBetPanel className={betPanelClassName} contract={contract} - selected={undefined} + selected="YES" onBetSuccess={() => setOpen(false)} hasShares={hasYesShares || hasNoShares} /> From f1eea665885075b54aaeb5a0d09b84898631da1a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 13 Jul 2022 12:14:58 -0500 Subject: [PATCH 182/220] Show all limit orders in a tab --- web/components/bet-panel.tsx | 21 +++---- web/components/limit-bets.tsx | 105 ++++++++++++++++++++++++++-------- 2 files changed, 89 insertions(+), 37 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 91c6fe00..c8356a06 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -59,6 +59,9 @@ export function BetPanel(props: { track('toggle limit order') } + const showLimitOrders = + (isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0 + return ( <Col className={className}> <SellRow @@ -94,12 +97,8 @@ export function BetPanel(props: { <SignUpPrompt /> </Col> - {yourUnfilledBets.length > 0 && ( - <LimitBets - className="mt-4" - contract={contract} - bets={yourUnfilledBets} - /> + {showLimitOrders && ( + <LimitBets className="mt-4" contract={contract} bets={unfilledBets} /> )} </Col> ) @@ -119,6 +118,8 @@ export function SimpleBetPanel(props: { const unfilledBets = useUnfilledBets(contract.id) ?? [] const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id) + const showLimitOrders = + (isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0 return ( <Col className={className}> @@ -158,12 +159,8 @@ export function SimpleBetPanel(props: { <SignUpPrompt /> </Col> - {yourUnfilledBets.length > 0 && ( - <LimitBets - className="mt-4" - contract={contract} - bets={yourUnfilledBets} - /> + {showLimitOrders && ( + <LimitBets className="mt-4" contract={contract} bets={unfilledBets} /> )} </Col> ) diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx index 503b3d17..4f1f1893 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -5,8 +5,11 @@ import { getFormattedMappedValue } from 'common/pseudo-numeric' import { formatMoney, formatPercent } from 'common/util/format' import { sortBy } from 'lodash' import { useState } from 'react' +import { useUser, useUserById } from 'web/hooks/use-user' import { cancelBet } from 'web/lib/firebase/api' +import { Avatar } from './avatar' import { Col } from './layout/col' +import { Tabs } from './layout/tabs' import { LoadingIndicator } from './loading-indicator' import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' @@ -16,12 +19,14 @@ export function LimitBets(props: { hideLabel?: boolean className?: string }) { - const { contract, bets, hideLabel, className } = props - const recentBets = sortBy( + const { contract, bets, className } = props + const sortedBets = sortBy( bets, (bet) => -1 * bet.limitProb, (bet) => -1 * bet.createdTime ) + const user = useUser() + const yourBets = sortedBets.filter((bet) => bet.userId === user?.id) return ( <Col @@ -30,25 +35,62 @@ export function LimitBets(props: { 'gap-2 overflow-hidden rounded bg-white px-4 py-3' )} > - {!hideLabel && ( - <div className="px-2 py-3 text-2xl">Your limit orders</div> - )} - <table className="table-compact table w-full rounded text-gray-500"> - <tbody> - {recentBets.map((bet) => ( - <LimitBet key={bet.id} bet={bet} contract={contract} /> - ))} - </tbody> - </table> + <Tabs + tabs={[ + ...(yourBets.length > 0 + ? [ + { + title: 'Your limit orders', + content: ( + <LimitOrderTable + limitBets={yourBets} + contract={contract} + isYou={true} + /> + ), + }, + ] + : []), + { + title: 'All limit orders', + content: ( + <LimitOrderTable + limitBets={sortedBets} + contract={contract} + isYou={false} + /> + ), + }, + ]} + /> </Col> ) } +function LimitOrderTable(props: { + limitBets: LimitBet[] + contract: CPMMBinaryContract | PseudoNumericContract + isYou: boolean +}) { + const { limitBets, contract, isYou } = props + + return ( + <table className="table-compact table w-full rounded text-gray-500"> + <tbody> + {limitBets.map((bet) => ( + <LimitBet key={bet.id} bet={bet} contract={contract} isYou={isYou} /> + ))} + </tbody> + </table> + ) +} + function LimitBet(props: { contract: CPMMBinaryContract | PseudoNumericContract bet: LimitBet + isYou: boolean }) { - const { contract, bet } = props + const { contract, bet, isYou } = props const { orderAmount, amount, limitProb, outcome } = bet const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' @@ -59,8 +101,19 @@ function LimitBet(props: { setIsCancelling(true) } + const user = useUserById(bet.userId) + return ( <tr> + {!isYou && ( + <td> + <Avatar + size={'sm'} + avatarUrl={user?.avatarUrl} + username={user?.username} + /> + </td> + )} <td> <div className="pl-2"> {isPseudoNumeric ? ( @@ -76,18 +129,20 @@ function LimitBet(props: { ? getFormattedMappedValue(contract)(limitProb) : formatPercent(limitProb)} </td> - <td> - {isCancelling ? ( - <LoadingIndicator /> - ) : ( - <button - className="btn btn-xs btn-outline my-auto normal-case" - onClick={onCancel} - > - Cancel - </button> - )} - </td> + {isYou && ( + <td> + {isCancelling ? ( + <LoadingIndicator /> + ) : ( + <button + className="btn btn-xs btn-outline my-auto normal-case" + onClick={onCancel} + > + Cancel + </button> + )} + </td> + )} </tr> ) } From 50eee33a6e13b1cca4e52985ace15c9c45572975 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 13 Jul 2022 12:51:19 -0500 Subject: [PATCH 183/220] Redeem shares of makers after matching with limit bets --- functions/src/place-bet.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 3c428f43..73c60187 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -144,13 +144,20 @@ export const placebet = newEndpoint({}, async (req, auth) => { ) log('Updated contract properties.') - return { betId: betDoc.id } + return { betId: betDoc.id, makers } }) log('Main transaction finished.') await redeemShares(auth.uid, contractId) + + const userIds = [ + auth.uid, + ...(result.makers ?? []).map((maker) => maker.bet.userId), + ] + await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) log('Share redemption transaction finished.') - return result + + return { betId: result.betId } }) const firestore = admin.firestore() From 83d8f18bd7627cd09bd7c73301d6798f562d7d08 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 13 Jul 2022 11:56:59 -0500 Subject: [PATCH 184/220] fix bet summary selling --- web/components/bets-list.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index a1164798..e57b0c38 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -417,8 +417,8 @@ export function BetsSummary(props: { const [showSellModal, setShowSellModal] = useState(false) const user = useUser() - const sharesOutcome = floatingEqual(totalShares.YES, 0) - ? floatingEqual(totalShares.NO, 0) + const sharesOutcome = floatingEqual(totalShares.YES ?? 0, 0) + ? floatingEqual(totalShares.NO ?? 0, 0) ? undefined : 'NO' : 'YES' @@ -498,7 +498,7 @@ export function BetsSummary(props: { {formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} /> {isYourBets && isCpmm && - isBinary && + (isBinary || isPseudoNumeric) && !isClosed && !resolution && hasShares && From 9a11f557624867bf3d593d68deb69f7daa820c4a Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 13 Jul 2022 11:58:22 -0700 Subject: [PATCH 185/220] Rich content (#620) * Add TipTap editor and renderer components * Change market description editor to rich text * Type description as JSON, fix string-based logic - Delete make-predictions.tsx - Delete feed logic that showed descriptions * wip Fix API validation * fix type error * fix extension import (backend) In firebase, typescript compiles imports into common js imports like `const StarterKit = require("@tiptap/starter-kit")` Even though StarterKit is exported from the cjs file, it gets imported as undefined. But it magically works if we import * If you're reading this in the future, consider replacing StarterKit with the entire list of extensions. * Stop load on fail create market, improve warning * Refactor editor as hook / fix infinite submit bug Move state of editor back up to parent We have to do this later anyways to allow parent to edit * Add images - display, paste + uploading * add uploading state of image * Fix placeholder, misc styling min height, quote * Fix appending to description * code review fixes: rename, refactor, chop carets * Add hint & upload button on new lines - bump to Tailwind 3.1 for arbitrary variants * clean up, run prettier * rename FileButton to FileUploadButton * add image extension as functions dependency --- common/contract.ts | 3 +- common/new-contract.ts | 13 +- common/package.json | 2 + common/util/parse.ts | 47 ++ functions/package.json | 3 + functions/src/create-contract.ts | 29 +- functions/src/on-create-contract.ts | 4 +- .../contract/contract-description.tsx | 24 +- web/components/contract/contract-details.tsx | 15 +- web/components/editor.tsx | 152 +++++++ web/components/feed/activity-items.ts | 1 - web/components/feed/feed-items.tsx | 11 +- web/components/file-upload-button.tsx | 26 ++ web/lib/firebase/storage.ts | 1 + web/package.json | 7 +- web/pages/[username]/[contractSlug].tsx | 10 +- web/pages/api/v0/_types.ts | 3 +- web/pages/contract-search-firestore.tsx | 1 - web/pages/create.tsx | 52 +-- web/pages/group/[...slugs]/index.tsx | 1 - web/pages/make-predictions.tsx | 292 ------------- yarn.lock | 404 ++++++++++++++++-- 22 files changed, 700 insertions(+), 401 deletions(-) create mode 100644 web/components/editor.tsx create mode 100644 web/components/file-upload-button.tsx delete mode 100644 web/pages/make-predictions.tsx diff --git a/common/contract.ts b/common/contract.ts index 3a90d01f..52ca91d6 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -1,5 +1,6 @@ import { Answer } from './answer' import { Fees } from './fees' +import { JSONContent } from '@tiptap/core' export type AnyMechanism = DPM | CPMM export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric @@ -20,7 +21,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = { creatorAvatarUrl?: string question: string - description: string // More info about what the contract is about + description: string | JSONContent // More info about what the contract is about tags: string[] lowercaseTags: string[] visibility: 'public' | 'unlisted' diff --git a/common/new-contract.ts b/common/new-contract.ts index 6c89c8c4..abfafaf8 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -10,8 +10,9 @@ import { PseudoNumeric, } from './contract' import { User } from './user' -import { parseTags } from './util/parse' +import { parseTags, richTextToString } from './util/parse' import { removeUndefinedProps } from './util/object' +import { JSONContent } from '@tiptap/core' export function getNewContract( id: string, @@ -19,7 +20,7 @@ export function getNewContract( creator: User, question: string, outcomeType: outcomeType, - description: string, + description: JSONContent, initialProb: number, ante: number, closeTime: number, @@ -32,7 +33,11 @@ export function getNewContract( isLogScale: boolean ) { const tags = parseTags( - `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` + [ + question, + richTextToString(description), + ...extraTags.map((tag) => `#${tag}`), + ].join(' ') ) const lowercaseTags = tags.map((tag) => tag.toLowerCase()) @@ -56,7 +61,7 @@ export function getNewContract( creatorAvatarUrl: creator.avatarUrl, question: question.trim(), - description: description.trim(), + description, tags, lowercaseTags, visibility: 'public', diff --git a/common/package.json b/common/package.json index c8115d84..25992cb6 100644 --- a/common/package.json +++ b/common/package.json @@ -8,6 +8,8 @@ }, "sideEffects": false, "dependencies": { + "@tiptap/extension-image": "2.0.0-beta.30", + "@tiptap/starter-kit": "2.0.0-beta.190", "lodash": "4.17.21" }, "devDependencies": { diff --git a/common/util/parse.ts b/common/util/parse.ts index b73bdfb3..48b68fdd 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,4 +1,24 @@ import { MAX_TAG_LENGTH } from '../contract' +import { generateText, JSONContent } from '@tiptap/core' +// Tiptap starter extensions +import { Blockquote } from '@tiptap/extension-blockquote' +import { Bold } from '@tiptap/extension-bold' +import { BulletList } from '@tiptap/extension-bullet-list' +import { Code } from '@tiptap/extension-code' +import { CodeBlock } from '@tiptap/extension-code-block' +import { Document } from '@tiptap/extension-document' +import { HardBreak } from '@tiptap/extension-hard-break' +import { Heading } from '@tiptap/extension-heading' +import { History } from '@tiptap/extension-history' +import { HorizontalRule } from '@tiptap/extension-horizontal-rule' +import { Italic } from '@tiptap/extension-italic' +import { ListItem } from '@tiptap/extension-list-item' +import { OrderedList } from '@tiptap/extension-ordered-list' +import { Paragraph } from '@tiptap/extension-paragraph' +import { Strike } from '@tiptap/extension-strike' +import { Text } from '@tiptap/extension-text' +// other tiptap extensions +import { Image } from '@tiptap/extension-image' export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi @@ -27,3 +47,30 @@ export function parseWordsAsTags(text: string) { .join(' ') return parseTags(taggedText) } + +// can't just do [StarterKit, Image...] because it doesn't work with cjs imports +export const exhibitExts = [ + Blockquote, + Bold, + BulletList, + Code, + CodeBlock, + Document, + HardBreak, + Heading, + History, + HorizontalRule, + Italic, + ListItem, + OrderedList, + Paragraph, + Strike, + Text, + + Image, +] +// export const exhibitExts = [StarterKit as unknown as Extension, Image] + +export function richTextToString(text?: JSONContent) { + return !text ? '' : generateText(text, exhibitExts) +} diff --git a/functions/package.json b/functions/package.json index 4c9f4338..d7ebb663 100644 --- a/functions/package.json +++ b/functions/package.json @@ -24,6 +24,9 @@ "dependencies": { "@amplitude/node": "1.10.0", "@google-cloud/functions-framework": "3.1.2", + "@tiptap/core": "2.0.0-beta.181", + "@tiptap/extension-image": "2.0.0-beta.30", + "@tiptap/starter-kit": "2.0.0-beta.190", "firebase-admin": "10.0.0", "firebase-functions": "3.21.2", "lodash": "4.17.21", diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 9f14ea7a..c8cfc7c4 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -5,7 +5,6 @@ import { CPMMBinaryContract, Contract, FreeResponseContract, - MAX_DESCRIPTION_LENGTH, MAX_QUESTION_LENGTH, MAX_TAG_LENGTH, NumericContract, @@ -29,10 +28,34 @@ import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { User } from '../../common/user' import { Group, MAX_ID_LENGTH } from '../../common/group' import { getPseudoProbability } from '../../common/pseudo-numeric' +import { JSONContent } from '@tiptap/core' + +const descScehma: z.ZodType<JSONContent> = z.lazy(() => + z.intersection( + z.record(z.any()), + z.object({ + type: z.string().optional(), + attrs: z.record(z.any()).optional(), + content: z.array(descScehma).optional(), + marks: z + .array( + z.intersection( + z.record(z.any()), + z.object({ + type: z.string(), + attrs: z.record(z.any()).optional(), + }) + ) + ) + .optional(), + text: z.string().optional(), + }) + ) +) const bodySchema = z.object({ question: z.string().min(1).max(MAX_QUESTION_LENGTH), - description: z.string().max(MAX_DESCRIPTION_LENGTH), + description: descScehma.optional(), tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(), closeTime: zTimestamp().refine( (date) => date.getTime() > new Date().getTime(), @@ -131,7 +154,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { user, question, outcomeType, - description, + description ?? {}, initialProb ?? 0, ante, closeTime.getTime(), diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index 20c7ceba..28682793 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -2,6 +2,8 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' import { createNotification } from './create-notification' import { Contract } from '../../common/contract' +import { richTextToString } from '../../common/util/parse' +import { JSONContent } from '@tiptap/core' export const onCreateContract = functions.firestore .document('contracts/{contractId}') @@ -18,7 +20,7 @@ export const onCreateContract = functions.firestore 'created', contractCreator, eventId, - contract.description, + richTextToString(contract.description as JSONContent), contract ) }) diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index d8b657cb..a427afe1 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -5,12 +5,13 @@ import Textarea from 'react-expanding-textarea' import { CATEGORY_LIST } from '../../../common/categories' import { Contract } from 'common/contract' -import { parseTags } from 'common/util/parse' +import { parseTags, exhibitExts } from 'common/util/parse' import { useAdmin } from 'web/hooks/use-admin' import { updateContract } from 'web/lib/firebase/contracts' import { Row } from '../layout/row' -import { Linkify } from '../linkify' import { TagsList } from '../tags-list' +import { Content } from '../editor' +import { Editor } from '@tiptap/react' export function ContractDescription(props: { contract: Contract @@ -21,22 +22,31 @@ export function ContractDescription(props: { const descriptionTimestamp = () => `${dayjs().format('MMM D, h:mma')}: ` const isAdmin = useAdmin() + const desc = contract.description ?? '' + // Append the new description (after a newline) async function saveDescription(newText: string) { - const newDescription = `${contract.description}\n\n${newText}`.trim() + const editor = new Editor({ content: desc, extensions: exhibitExts }) + editor + .chain() + .focus('end') + .insertContent('<br /><br />') + .insertContent(newText.trim()) + .run() + const tags = parseTags( - `${newDescription} ${contract.tags.map((tag) => `#${tag}`).join(' ')}` + `${editor.getText()} ${contract.tags.map((tag) => `#${tag}`).join(' ')}` ) const lowercaseTags = tags.map((tag) => tag.toLowerCase()) await updateContract(contract.id, { - description: newDescription, + description: editor.getJSON(), tags, lowercaseTags, }) } - if (!isCreator && !contract.description.trim()) return null + if (!isCreator) return null const { tags } = contract const categories = tags.filter((tag) => @@ -50,7 +60,7 @@ export function ContractDescription(props: { className )} > - <Linkify text={contract.description} /> + <Content content={desc} /> {categories.length > 0 && ( <div className="mt-4"> diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index f908918e..b4d67520 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -31,6 +31,8 @@ import { DAY_MS } from 'common/util/time' import { useGroupsWithContract } from 'web/hooks/use-group' import { ShareIconButton } from 'web/components/share-icon-button' import { useUser } from 'web/hooks/use-user' +import { Editor } from '@tiptap/react' +import { exhibitExts } from 'common/util/parse' export type ShowTime = 'resolve-date' | 'close-date' @@ -268,13 +270,20 @@ function EditableCloseDate(props: { const newCloseTime = dayjs(closeDate).valueOf() if (newCloseTime === closeTime) setIsEditingCloseTime(false) else if (newCloseTime > Date.now()) { - const { description } = contract + const content = contract.description const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a') - const newDescription = `${description}\n\nClose date updated to ${formattedCloseDate}` + + const editor = new Editor({ content, extensions: exhibitExts }) + editor + .chain() + .focus('end') + .insertContent('<br /><br />') + .insertContent(`Close date updated to ${formattedCloseDate}`) + .run() updateContract(contract.id, { closeTime: newCloseTime, - description: newDescription, + description: editor.getJSON(), }) setIsEditingCloseTime(false) diff --git a/web/components/editor.tsx b/web/components/editor.tsx new file mode 100644 index 00000000..bd4d97c0 --- /dev/null +++ b/web/components/editor.tsx @@ -0,0 +1,152 @@ +import CharacterCount from '@tiptap/extension-character-count' +import Placeholder from '@tiptap/extension-placeholder' +import { + useEditor, + EditorContent, + FloatingMenu, + JSONContent, + Content, + Editor, +} from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { Image } from '@tiptap/extension-image' +import clsx from 'clsx' +import { useEffect } from 'react' +import { Linkify } from './linkify' +import { uploadImage } from 'web/lib/firebase/storage' +import { useMutation } from 'react-query' +import { exhibitExts } from 'common/util/parse' +import { FileUploadButton } from './file-upload-button' + +const proseClass = + 'prose prose-sm prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none' + +export function useTextEditor(props: { + placeholder?: string + max?: number + defaultValue?: Content + disabled?: boolean +}) { + const { placeholder, max, defaultValue = '', disabled } = props + + const editorClass = clsx( + proseClass, + 'box-content min-h-[6em] textarea textarea-bordered' + ) + + const editor = useEditor({ + editorProps: { attributes: { class: editorClass } }, + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + }), + Placeholder.configure({ + placeholder, + emptyEditorClass: + 'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0', + }), + CharacterCount.configure({ limit: max }), + Image, + ], + content: defaultValue, + }) + + const upload = useUploadMutation(editor) + + editor?.setOptions({ + editorProps: { + handlePaste(view, event) { + const imageFiles = Array.from(event.clipboardData?.files ?? []).filter( + (file) => file.type.startsWith('image') + ) + + if (!imageFiles.length) { + return // if no files pasted, use default paste handler + } + + event.preventDefault() + upload.mutate(imageFiles) + }, + }, + }) + + useEffect(() => { + editor?.setEditable(!disabled) + }, [editor, disabled]) + + return { editor, upload } +} + +export function TextEditor(props: { + editor: Editor | null + upload: ReturnType<typeof useUploadMutation> +}) { + const { editor, upload } = props + + return ( + <> + {/* hide placeholder when focused */} + <div className="w-full [&:focus-within_p.is-empty]:before:content-none"> + {editor && ( + <FloatingMenu + editor={editor} + className="w-full text-sm text-slate-300" + > + Type <em>*anything*</em> or even paste or{' '} + <FileUploadButton + className="link text-blue-300" + onFiles={upload.mutate} + > + upload an image + </FileUploadButton> + </FloatingMenu> + )} + <EditorContent editor={editor} /> + </div> + {upload.isLoading && <span className="text-xs">Uploading image...</span>} + {upload.isError && ( + <span className="text-error text-xs">Error uploading image :(</span> + )} + </> + ) +} + +const useUploadMutation = (editor: Editor | null) => + useMutation( + (files: File[]) => + Promise.all(files.map((file) => uploadImage('default', file))), + { + onSuccess(urls) { + if (!editor) return + let trans = editor.view.state.tr + urls.forEach((src: any) => { + const node = editor.view.state.schema.nodes.image.create({ src }) + trans = trans.insert(editor.view.state.selection.to, node) + }) + editor.view.dispatch(trans) + }, + } + ) + +function RichContent(props: { content: JSONContent }) { + const { content } = props + const editor = useEditor({ + editorProps: { attributes: { class: proseClass } }, + extensions: exhibitExts, + content, + editable: false, + }) + useEffect(() => void editor?.commands?.setContent(content), [editor, content]) + + return <EditorContent editor={editor} /> +} + +// backwards compatibility: we used to store content as strings +export function Content(props: { content: JSONContent | string }) { + const { content } = props + return typeof content === 'string' ? ( + <Linkify text={content} /> + ) : ( + <RichContent content={content} /> + ) +} diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index 68dfcb2d..511767c6 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -37,7 +37,6 @@ export type DescriptionItem = BaseActivityItem & { export type QuestionItem = BaseActivityItem & { type: 'question' - showDescription: boolean contractPath?: string } diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index a9618f8c..ff5f5440 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -31,7 +31,6 @@ import { FeedAnswerCommentGroup } from 'web/components/feed/feed-answer-comment- import { FeedCommentThread, CommentInput, - TruncatedComment, } from 'web/components/feed/feed-comments' import { FeedBet } from 'web/components/feed/feed-bets' import { CPMMBinaryContract, NumericContract } from 'common/contract' @@ -104,10 +103,9 @@ export function FeedItem(props: { item: ActivityItem }) { export function FeedQuestion(props: { contract: Contract - showDescription: boolean contractPath?: string }) { - const { contract, showDescription } = props + const { contract } = props const { creatorName, creatorUsername, @@ -163,13 +161,6 @@ export function FeedQuestion(props: { /> )} </Col> - {showDescription && ( - <TruncatedComment - comment={contract.description} - moreHref={contractPath(contract)} - shouldTruncate - /> - )} </div> </div> ) diff --git a/web/components/file-upload-button.tsx b/web/components/file-upload-button.tsx new file mode 100644 index 00000000..3ff15d91 --- /dev/null +++ b/web/components/file-upload-button.tsx @@ -0,0 +1,26 @@ +import { useRef } from 'react' + +/** button that opens file upload window */ +export function FileUploadButton(props: { + onFiles: (files: File[]) => void + className?: string + children?: React.ReactNode +}) { + const { onFiles, className, children } = props + const ref = useRef<HTMLInputElement>(null) + return ( + <> + <button className={className} onClick={() => ref.current?.click()}> + {children} + </button> + <input + ref={ref} + type="file" + accept=".gif,.jpg,.jpeg,.png,.webp, image/*" + multiple + className="hidden" + onChange={(e) => onFiles(Array.from(e.target.files || []))} + /> + </> + ) +} diff --git a/web/lib/firebase/storage.ts b/web/lib/firebase/storage.ts index e7794580..5293a6bc 100644 --- a/web/lib/firebase/storage.ts +++ b/web/lib/firebase/storage.ts @@ -7,6 +7,7 @@ import { const storage = getStorage() +// TODO: compress large images export const uploadImage = async ( username: string, file: File, diff --git a/web/package.json b/web/package.json index 454db57c..f81950bf 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,11 @@ "@nivo/core": "0.74.0", "@nivo/line": "0.74.0", "@react-query-firebase/firestore": "0.4.2", + "@tiptap/extension-character-count": "2.0.0-beta.31", + "@tiptap/extension-image": "2.0.0-beta.30", + "@tiptap/extension-placeholder": "2.0.0-beta.53", + "@tiptap/react": "2.0.0-beta.114", + "@tiptap/starter-kit": "2.0.0-beta.190", "algoliasearch": "4.13.0", "clsx": "1.1.1", "cors": "2.8.5", @@ -61,7 +66,7 @@ "next-sitemap": "^2.5.14", "postcss": "8.3.5", "prettier-plugin-tailwindcss": "^0.1.5", - "tailwindcss": "3.0.1", + "tailwindcss": "3.1.6", "tsc-files": "1.1.3" }, "lint-staged": { diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index e8b290f3..bfe13837 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -45,6 +45,7 @@ import { useTracking } from 'web/hooks/use-tracking' import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { useRouter } from 'next/router' import { useLiquidity } from 'web/hooks/use-liquidity' +import { richTextToString } from 'common/util/parse' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -396,15 +397,18 @@ const getOpenGraphProps = (contract: Contract) => { creatorUsername, outcomeType, creatorAvatarUrl, + description: desc, } = contract const probPercent = outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined + const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) + const description = resolution - ? `Resolved ${resolution}. ${contract.description}` + ? `Resolved ${resolution}. ${stringDesc}` : probPercent - ? `${probPercent} chance. ${contract.description}` - : contract.description + ? `${probPercent} chance. ${stringDesc}` + : stringDesc return { question, diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index 7f52077d..5b9a7dab 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -6,6 +6,7 @@ import { Contract } from 'common/contract' import { User } from 'common/user' import { removeUndefinedProps } from 'common/util/object' import { ENV_CONFIG } from 'common/envs/constants' +import { JSONContent } from '@tiptap/core' export type LiteMarket = { // Unique identifer for this market @@ -20,7 +21,7 @@ export type LiteMarket = { // Market attributes. All times are in milliseconds since epoch closeTime?: number question: string - description: string + description: string | JSONContent tags: string[] url: string outcomeType: string diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 8cd80f7a..2fa4844e 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -36,7 +36,6 @@ export default function ContractSearchFirestore(props: { let matches = (contracts ?? []).filter( (c) => check(c.question) || - check(c.description) || check(c.creatorName) || check(c.creatorUsername) || check(c.lowercaseTags.map((tag) => `#${tag}`).join(' ')) || diff --git a/web/pages/create.tsx b/web/pages/create.tsx index fd071310..705ef0eb 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -26,6 +26,7 @@ import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' import { track } from 'web/lib/service/analytics' import { GroupSelector } from 'web/components/groups/group-selector' import { User } from 'common/user' +import { TextEditor, useTextEditor } from 'web/components/editor' type NewQuestionParams = { groupId?: string @@ -101,13 +102,11 @@ export function NewContract(props: { (params?.outcomeType as outcomeType) ?? 'BINARY' ) const [initialProb] = useState(50) - const [bottomRef, setBottomRef] = useState<HTMLDivElement | null>(null) const [minString, setMinString] = useState(params?.min ?? '') const [maxString, setMaxString] = useState(params?.max ?? '') const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale) const [initialValueString, setInitialValueString] = useState(initValue) - const [description, setDescription] = useState(params?.description ?? '') useEffect(() => { if (groupId && creator) getGroup(groupId).then((group) => { @@ -152,9 +151,6 @@ export function NewContract(props: { // get days from today until the end of this year: const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') - const hasUnsavedChanges = !isSubmitting && Boolean(question || description) - useWarnUnsavedChanges(hasUnsavedChanges) - const isValid = (outcomeType === 'BINARY' ? initialProb >= 5 && initialProb <= 95 : true) && question.length > 0 && @@ -175,6 +171,20 @@ export function NewContract(props: { min < initialValue && initialValue < max)) + const descriptionPlaceholder = + outcomeType === 'BINARY' + ? `e.g. This question resolves to "YES" if they receive the majority of votes...` + : `e.g. I will choose the answer according to...` + + const { editor, upload } = useTextEditor({ + max: MAX_DESCRIPTION_LENGTH, + placeholder: descriptionPlaceholder, + disabled: isSubmitting, + }) + + const isEditorFilled = editor != null && !editor.isEmpty + useWarnUnsavedChanges(!isSubmitting && (Boolean(question) || isEditorFilled)) + function setCloseDateInDays(days: number) { const newCloseDate = dayjs().add(days, 'day').format('YYYY-MM-DD') setCloseDate(newCloseDate) @@ -183,14 +193,13 @@ export function NewContract(props: { async function submit() { // TODO: Tell users why their contract is invalid if (!creator || !isValid) return - setIsSubmitting(true) try { const result = await createMarket( removeUndefinedProps({ question, outcomeType, - description, + description: editor?.getJSON(), initialProb, ante, closeTime, @@ -213,15 +222,11 @@ export function NewContract(props: { await router.push(contractPath(result as Contract)) } catch (e) { - console.log('error creating contract', e) + console.error('error creating contract', e, (e as any).details) + setIsSubmitting(false) } } - const descriptionPlaceholder = - outcomeType === 'BINARY' - ? `e.g. This question resolves to "YES" if they receive the majority of votes...` - : `e.g. I will choose the answer according to...` - if (!creator) return <></> return ( @@ -396,25 +401,12 @@ export function NewContract(props: { <Spacer h={6} /> - <div className="form-control mb-1 items-start"> - <label className="label mb-1 gap-2"> + <div className="form-control mb-1 items-start gap-1"> + <label className="label gap-2"> <span className="mb-1">Description</span> <InfoTooltip text="Optional. Describe how you will resolve this question." /> </label> - <Textarea - className="textarea textarea-bordered w-full resize-none" - rows={3} - maxLength={MAX_DESCRIPTION_LENGTH} - placeholder={descriptionPlaceholder} - value={description} - disabled={isSubmitting} - onClick={(e) => e.stopPropagation()} - onChange={(e) => { - setDescription(e.target.value || '') - bottomRef?.scrollIntoView() - }} - /> - <div ref={setBottomRef} /> + <TextEditor editor={editor} upload={upload} /> </div> <Spacer h={6} /> @@ -451,7 +443,7 @@ export function NewContract(props: { 'btn btn-primary normal-case', isSubmitting && 'loading disabled' )} - disabled={isSubmitting || !isValid} + disabled={isSubmitting || !isValid || upload.isLoading} onClick={(e) => { e.preventDefault() submit() diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 5882159d..4266b808 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -159,7 +159,6 @@ export default function GroupPage(props: { ? contracts.filter( (c) => checkAgainstQuery(query, c.question) || - checkAgainstQuery(query, c.description || '') || checkAgainstQuery(query, c.creatorName) || checkAgainstQuery(query, c.creatorUsername) ) diff --git a/web/pages/make-predictions.tsx b/web/pages/make-predictions.tsx deleted file mode 100644 index b22fe371..00000000 --- a/web/pages/make-predictions.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import clsx from 'clsx' -import dayjs from 'dayjs' -import Link from 'next/link' -import { useState } from 'react' -import Textarea from 'react-expanding-textarea' - -import { getProbability } from 'common/calculate' -import { BinaryContract } from 'common/contract' -import { parseWordsAsTags } from 'common/util/parse' -import { BuyAmountInput } from 'web/components/amount-input' -import { InfoTooltip } from 'web/components/info-tooltip' -import { Col } from 'web/components/layout/col' -import { Row } from 'web/components/layout/row' -import { Spacer } from 'web/components/layout/spacer' -import { Linkify } from 'web/components/linkify' -import { Page } from 'web/components/page' -import { Title } from 'web/components/title' -import { useUser } from 'web/hooks/use-user' -import { createMarket } from 'web/lib/firebase/api' -import { contractPath } from 'web/lib/firebase/contracts' - -type Prediction = { - question: string - description: string - initialProb: number - createdUrl?: string -} - -function toPrediction(contract: BinaryContract): Prediction { - const startProb = getProbability(contract) - return { - question: contract.question, - description: contract.description, - initialProb: startProb * 100, - createdUrl: contractPath(contract), - } -} - -function PredictionRow(props: { prediction: Prediction }) { - const { prediction } = props - return ( - <Row className="justify-between gap-4 p-4 hover:bg-gray-300"> - <Col className="justify-between"> - <div className="mb-2 font-medium text-indigo-700"> - <Linkify text={prediction.question} /> - </div> - <div className="text-sm text-gray-500">{prediction.description}</div> - </Col> - {/* Initial probability */} - <div className="ml-auto"> - <div className="text-3xl"> - <div className="text-primary"> - {prediction.initialProb.toFixed(0)}% - <div className="text-lg">chance</div> - </div> - </div> - </div> - {/* Current probability; hidden for now */} - {/* <div> - <div className="text-3xl"> - <div className="text-primary"> - {prediction.initialProb}%<div className="text-lg">chance</div> - </div> - </div> - </div> */} - </Row> - ) -} - -function PredictionList(props: { predictions: Prediction[] }) { - const { predictions } = props - return ( - <Col className="divide-y divide-gray-300 rounded-md border border-gray-300"> - {predictions.map((prediction) => - prediction.createdUrl ? ( - <Link href={prediction.createdUrl}> - <a> - <PredictionRow - key={prediction.question} - prediction={prediction} - /> - </a> - </Link> - ) : ( - <PredictionRow key={prediction.question} prediction={prediction} /> - ) - )} - </Col> - ) -} - -const TEST_VALUE = `1. Biden approval rating (as per 538) is greater than 50%: 80% -2. Court packing is clearly going to happen (new justices don't have to be appointed by end of year): 5% -3. Yang is New York mayor: 80% -4. Newsom recalled as CA governor: 5% -5. At least $250 million in damage from BLM protests this year: 30% -6. Significant capital gains tax hike (above 30% for highest bracket): 20%` - -export default function MakePredictions() { - const user = useUser() - const [predictionsString, setPredictionsString] = useState('') - const [description, setDescription] = useState('') - const [tags, setTags] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - const [createdContracts, setCreatedContracts] = useState<BinaryContract[]>([]) - - const [ante, setAnte] = useState<number | undefined>(100) - const [anteError, setAnteError] = useState<string | undefined>() - // By default, close the market a week from today - const weekFromToday = dayjs().add(7, 'day').format('YYYY-MM-DDT23:59') - const [closeDate, setCloseDate] = useState<undefined | string>(weekFromToday) - - const closeTime = closeDate ? dayjs(closeDate).valueOf() : undefined - - const bulkPlaceholder = `e.g. -${TEST_VALUE} -... -` - - const predictions: Prediction[] = [] - - // Parse bulkContracts, then run createContract for each - const lines = predictionsString ? predictionsString.split('\n') : [] - for (const line of lines) { - // Parse line with regex - const matches = line.match(/^(.*):\s*(\d+)%\s*$/) || ['', '', ''] - const [_, question, prob] = matches - - if (!question || !prob) { - console.error('Invalid prediction: ', line) - continue - } - - predictions.push({ - question, - description, - initialProb: parseInt(prob), - }) - } - - async function createMarkets() { - if (!user) { - // TODO: Convey error with snackbar/toast - console.error('You need to be signed in!') - return - } - setIsSubmitting(true) - for (const prediction of predictions) { - const contract = (await createMarket({ - question: prediction.question, - description: prediction.description, - initialProb: prediction.initialProb, - ante, - closeTime, - tags: parseWordsAsTags(tags), - })) as BinaryContract - - setCreatedContracts((prev) => [...prev, contract]) - } - setPredictionsString('') - setIsSubmitting(false) - } - - return ( - <Page> - <Title text="Make Predictions" /> - <div className="w-full rounded-lg bg-gray-100 px-6 py-4 shadow-xl"> - <form> - <div className="form-control"> - <label className="label"> - <span className="label-text">Prediction</span> - <div className="ml-1 text-sm text-gray-500"> - One prediction per line, each formatted like "The sun will rise - tomorrow: 99%" - </div> - </label> - - <textarea - className="textarea textarea-bordered h-60" - placeholder={bulkPlaceholder} - value={predictionsString} - onChange={(e) => setPredictionsString(e.target.value || '')} - ></textarea> - </div> - </form> - - <Spacer h={4} /> - - <div className="form-control w-full"> - <label className="label"> - <span className="label-text">Description</span> - </label> - - <Textarea - placeholder="e.g. This market is part of the ACX predictions for 2022..." - className="input resize-none" - value={description} - onChange={(e) => setDescription(e.target.value || '')} - /> - </div> - - <div className="form-control w-full"> - <label className="label"> - <span className="label-text">Tags</span> - </label> - - <input - type="text" - placeholder="e.g. ACX2021 World" - className="input" - value={tags} - onChange={(e) => setTags(e.target.value || '')} - /> - </div> - - <div className="form-control mb-1 items-start"> - <label className="label mb-1 gap-2"> - <span>Market close</span> - <InfoTooltip text="Trading will be halted after this time (local timezone)." /> - </label> - <input - type="datetime-local" - className="input input-bordered" - onClick={(e) => e.stopPropagation()} - onChange={(e) => setCloseDate(e.target.value || '')} - min={Date.now()} - disabled={isSubmitting} - value={closeDate} - /> - </div> - - <Spacer h={4} /> - - <div className="form-control mb-1 items-start"> - <label className="label mb-1 gap-2"> - <span>Market ante</span> - <InfoTooltip - text={`Subsidize your market to encourage trading. Ante bets are set to match your initial probability. - You earn ${0.01 * 100}% of trading volume.`} - /> - </label> - <BuyAmountInput - amount={ante} - minimumAmount={10} - onChange={setAnte} - error={anteError} - setError={setAnteError} - disabled={isSubmitting} - /> - </div> - - {predictions.length > 0 && ( - <div> - <Spacer h={4} /> - <label className="label"> - <span className="label-text">Preview</span> - </label> - <PredictionList predictions={predictions} /> - </div> - )} - - <Spacer h={4} /> - - <div className="my-4 flex justify-end"> - <button - type="submit" - className={clsx('btn btn-primary', { - loading: isSubmitting, - })} - disabled={predictions.length === 0 || isSubmitting} - onClick={(e) => { - e.preventDefault() - createMarkets() - }} - > - Create all - </button> - </div> - </div> - - {createdContracts.length > 0 && ( - <> - <Spacer h={16} /> - <Title text="Created Predictions" /> - <div className="w-full rounded-lg bg-gray-100 px-6 py-4 shadow-xl"> - <PredictionList predictions={createdContracts.map(toPrediction)} /> - </div> - </> - )} - </Page> - ) -} diff --git a/yarn.lock b/yarn.lock index 0ee2aa0f..e84f3616 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2588,6 +2588,11 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== +"@popperjs/core@^2.9.0": + version "2.11.5" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.5.tgz#db5a11bf66bdab39569719555b0f76e138d7bd64" + integrity sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw== + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -2860,6 +2865,193 @@ lodash.isplainobject "^4.0.6" lodash.merge "^4.6.2" +"@tiptap/core@2.0.0-beta.181", "@tiptap/core@^2.0.0-beta.181": + version "2.0.0-beta.181" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.181.tgz#07aeea26336814ab82eb7f4199b17538187c6fbb" + integrity sha512-tbwRqjTVvY9v31TNAH6W0Njhr/OVwI28zWXmH55/USrwyU2CB1iCVfXktZKOhB+8WyvOaBv1JA5YplMIhstYTw== + dependencies: + prosemirror-commands "1.3.0" + prosemirror-keymap "1.2.0" + prosemirror-model "1.18.1" + prosemirror-schema-list "1.2.0" + prosemirror-state "1.4.1" + prosemirror-transform "1.6.0" + prosemirror-view "1.26.2" + +"@tiptap/extension-blockquote@^2.0.0-beta.29": + version "2.0.0-beta.29" + resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.0.0-beta.29.tgz#6f1c4b17efa6457c7776f32d0807e96d848d4389" + integrity sha512-zMYT5TtpKWav9VhTn4JLyMvXmhEdbD6on0MdhcTjRm0I5ugyR4ZbJwh2aelM7G9DZVYzB8jZU18OSDJmo7Af7w== + +"@tiptap/extension-bold@^2.0.0-beta.28": + version "2.0.0-beta.28" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-2.0.0-beta.28.tgz#cf67c264a80434ffb2368f3dd37cf357ae0c2064" + integrity sha512-DY8GOzw9xjmTFrnvTbgHUNxTnDfKrkDgrhe0SUvdkT2udntWp8umPdhPiD3vczLgHOJw6tX68qMRjbsR1ZPcHQ== + +"@tiptap/extension-bubble-menu@^2.0.0-beta.61": + version "2.0.0-beta.61" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.0.0-beta.61.tgz#cc61ce8b094fdbcec58f44f0fa39172a726c024c" + integrity sha512-T3Yx+y1sUnXAJjK1CUfsQewSxOpDca9KzKqN2H9c9RZ9UlorR9XmZg6YYW7m9a7adeihj+o3cCO9jRd8dV+nnA== + dependencies: + prosemirror-state "1.4.1" + prosemirror-view "1.26.2" + tippy.js "^6.3.7" + +"@tiptap/extension-bullet-list@^2.0.0-beta.29": + version "2.0.0-beta.29" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.0.0-beta.29.tgz#640883e4fffc1a86c7cbd78792688e7edee5ee41" + integrity sha512-R8VB2l1ZB6VeGWx/t/04nBS5Wg3qjIDEZCpPihj2fccJOw99Lu0Ub2UJg/SfdGmeNNpBh4ZYYFv1g/XjyzlXKg== + +"@tiptap/extension-character-count@2.0.0-beta.31": + version "2.0.0-beta.31" + resolved "https://registry.yarnpkg.com/@tiptap/extension-character-count/-/extension-character-count-2.0.0-beta.31.tgz#fac9ba809ddc38cf67c8a05a42d94e062a1967d2" + integrity sha512-NNA9MN1IjZe+yYQLuYVAg9RNG/3RonYrHiM5mL6vsegd+PF4uMqyZLgsM0/9dMhxh9K/pDPaCRxhuDoZC8V1wA== + dependencies: + prosemirror-model "1.18.1" + prosemirror-state "1.4.1" + +"@tiptap/extension-code-block@^2.0.0-beta.42": + version "2.0.0-beta.42" + resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.0.0-beta.42.tgz#2abfd92eb22399fa542aafb3b76dddfb41d87ab5" + integrity sha512-4wzLup4mI8w9ypIceekUV/8g41cQIPn31qs1iC9u1/JuTkjMj/tA+TFUyp6IMugLxoI/P2DlTztU6/6m7n9DyQ== + dependencies: + prosemirror-state "1.4.1" + +"@tiptap/extension-code@^2.0.0-beta.28": + version "2.0.0-beta.28" + resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.0.0-beta.28.tgz#a22c0e873497ac0bbcd77e4a855322f8591f954e" + integrity sha512-QPJ2Gwb1+3NgcC1ZIhvVcb+FsnWWDu5VZXTKXM4mz892i9V2x48uHg5anPiUV6pcolXsW1F5VNbXIHGTUUO6CQ== + +"@tiptap/extension-document@^2.0.0-beta.17": + version "2.0.0-beta.17" + resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.0.0-beta.17.tgz#ded4182dd860762bcf41c588f712d83908c472a3" + integrity sha512-L6sg0FNchbtIpQkCSjMmItVGs3/vep8Fq56WRtDc1wBSGUSmtHaxQG7F2FZLnNIUMuvzVMRD81m2vYG73WkY6A== + +"@tiptap/extension-dropcursor@^2.0.0-beta.29": + version "2.0.0-beta.29" + resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.0.0-beta.29.tgz#9ccc9d82cb9f8fa28a59ffc061c4c83ee059a12c" + integrity sha512-I+joyoFB8pfdXUPLMqdNO08nlB5m2lbu0VQ5dpqdi/HzgVThMZPZA1cW0X8vAUvrALs5/JFRiFoR9hrLN5R5ng== + dependencies: + prosemirror-dropcursor "1.5.0" + +"@tiptap/extension-floating-menu@^2.0.0-beta.56": + version "2.0.0-beta.56" + resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-2.0.0-beta.56.tgz#c7428d9109d215bdbd9033f69782c4aadb2aabec" + integrity sha512-j/evHE/6UPGkIgXny9IGcAh0IrcnQmg0b2NBYebs2mqx9xYKYoe+0jVgNdLp/0M3MRgQCzyWTyatBDBFOUR2mw== + dependencies: + prosemirror-state "1.4.1" + prosemirror-view "1.26.2" + tippy.js "^6.3.7" + +"@tiptap/extension-gapcursor@^2.0.0-beta.39": + version "2.0.0-beta.39" + resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.0.0-beta.39.tgz#b8585d2936df7ca90446758c3af90b46d552a1fb" + integrity sha512-oCyz5WEeQXrEIoa1WXaD52yf1EwMFCXaK1cVzFgUj8lkXJ+nJj+O/Zp0Mg+9/MVR0LYu/kifqVorKNXM4AFA/g== + dependencies: + prosemirror-gapcursor "1.3.0" + +"@tiptap/extension-hard-break@^2.0.0-beta.33": + version "2.0.0-beta.33" + resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.0.0-beta.33.tgz#e2f355a22aaaec6e831cf2880c52aa5b0b860573" + integrity sha512-41xf0vSV9hcyTFd01ItLq/CjhjgmOFLCrO3UWN/P2E/cIxuDTyXcvjTE/KXeqRCOV3OYd9fVr0wO91hc8Ij1Yg== + +"@tiptap/extension-heading@^2.0.0-beta.29": + version "2.0.0-beta.29" + resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.0.0-beta.29.tgz#d017d216c0fd1962c266f6f61a335093f9749862" + integrity sha512-q92jYcsT5bPhvuQaB0h44Z9r+Ii22tDYo082KMVnR4+tknHT/3xx+p4JC8KHjh+/5W8Quyafqy6mS8L8VX0zsQ== + +"@tiptap/extension-history@^2.0.0-beta.26": + version "2.0.0-beta.26" + resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.0.0-beta.26.tgz#ae4c0ee8d19b3530e72d99cb5d0f69aefcf96d04" + integrity sha512-ly19uwvdmXG8Fw1KcavXIHi3Qx6JBASOR7394zghOEpW3atpY8nd/8I373rZ8eDUcGOClfaF7bCx2xvIotAAnw== + dependencies: + prosemirror-history "1.3.0" + +"@tiptap/extension-horizontal-rule@^2.0.0-beta.36": + version "2.0.0-beta.36" + resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.0.0-beta.36.tgz#daf8e2d0f30b210a90fdb8f015646653661cfa04" + integrity sha512-o+Zp7dcn3zAQhtlhZiFB69mTHuH3ZRbGEF7Cbf1D3uX1izotni5zIZbPaFFUT4r6OmVe/vDDt/nopfcGc10ktQ== + dependencies: + prosemirror-state "1.4.1" + +"@tiptap/extension-image@2.0.0-beta.30": + version "2.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@tiptap/extension-image/-/extension-image-2.0.0-beta.30.tgz#60c6cfd09bfd017a3d8b1feaf0931462ffd71a60" + integrity sha512-VhEmgiKkZMiKR7hbpJgIlIUS/QNjSGI5ER7mKDAbuV1IB5yb6nGjZ6o3Exrr2/CaTaW5hQarBC1z2Xgdu05EGg== + +"@tiptap/extension-italic@^2.0.0-beta.28": + version "2.0.0-beta.28" + resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.0.0-beta.28.tgz#bf88ecae64c8f2f69f1f508b802c1efd7454a84e" + integrity sha512-/pKRiCfewh7nqiXRD3N4hQHfGrGNOiWPFYZfY35bSpvTms7PDb/MF7xT1CWW23hSpY31BBS+R/a66vlR/gqu7Q== + +"@tiptap/extension-list-item@^2.0.0-beta.23": + version "2.0.0-beta.23" + resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.0.0-beta.23.tgz#6d1ac7235462b0bcee196f42bb1871669480b843" + integrity sha512-AkzvdELz3ZnrlZM0r9+ritBDOnAjXHR/8zCZhW0ZlWx4zyKPMsNG5ygivY+xr4QT65NEGRT8P8b2zOhXrMjjMQ== + +"@tiptap/extension-ordered-list@^2.0.0-beta.30": + version "2.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.0.0-beta.30.tgz#1f656b664302d90272c244b2e478d7056203f2a8" + integrity sha512-GRxGQdq1u0Rp5N8TjthCqoZ//460m343A0HCN7UwfQOnX7Ipv0UJemwNkSHWrl7Pexym9vy3yPWgrn7oRRmgEw== + +"@tiptap/extension-paragraph@^2.0.0-beta.26": + version "2.0.0-beta.26" + resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.0.0-beta.26.tgz#5199c8cedb9c076347a2e15cc67442ef7c3c3fbb" + integrity sha512-WcYsuUa7LLfk0vi7I1dVjdMRu53B52FMMqd+UL1qPdDKVkU3DBsZVwPj+yyfQyqN8Mc/xyg9VacGaiKFLmWNDg== + +"@tiptap/extension-placeholder@2.0.0-beta.53": + version "2.0.0-beta.53" + resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.0.0-beta.53.tgz#df29d813044da9a0e30bf8409335e77f6857c2b2" + integrity sha512-NGU/a+GvcJVBjFqb2vI45+rNa3Cjsq/M+R/2xg9olb1w/HBr17NKf/5WSoqcc1S2cdnmMH6rB0/mVhG7Ciur+Q== + dependencies: + prosemirror-model "1.18.1" + prosemirror-state "1.4.1" + prosemirror-view "1.26.2" + +"@tiptap/extension-strike@^2.0.0-beta.29": + version "2.0.0-beta.29" + resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.0.0-beta.29.tgz#7004d0c5d126b0517fa78efc5a333a4b8e3334bf" + integrity sha512-zqFuY7GfNmZ/KClt6kxQ+msGo3syqucP/Xnlihxi+/h/G+oTvEwyOIXCtDOltvxcsWH/TUsdr5vzLp0j+Mdc6Q== + +"@tiptap/extension-text@^2.0.0-beta.17": + version "2.0.0-beta.17" + resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.0.0-beta.17.tgz#4fdd1bdf62c82c1af6feef91c689906a8f5b171e" + integrity sha512-OyKL+pqWJEtjyd9/mrsuY1kZh2b3LWpOQDWKtd4aWR4EA0efmQG+7FPwcIeAVEh7ZoqM+/ABCnPjN6IjzIrSfg== + +"@tiptap/react@2.0.0-beta.114": + version "2.0.0-beta.114" + resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-2.0.0-beta.114.tgz#fa2b3fcdf379bf7ee25388c0eddbda49249977d5" + integrity sha512-9JbRE+16WM6RxbBxzY74SrJtLodvjeRBnEbWxuhxVgGKxMunRj6r8oED87ODJgqLmkpofwE0KFHTPGdEXfdcKA== + dependencies: + "@tiptap/extension-bubble-menu" "^2.0.0-beta.61" + "@tiptap/extension-floating-menu" "^2.0.0-beta.56" + prosemirror-view "1.26.2" + +"@tiptap/starter-kit@2.0.0-beta.190": + version "2.0.0-beta.190" + resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.190.tgz#fe0021e29d070fc5707722513a398c8884e15f71" + integrity sha512-jaFMkE6mjCHmCJsXUyLiXGYRVDcHF+PbH/5hEu1riUIAT0Hmm7uak5TYsPeuoCVN7P/tmDEBbBRASZ5CzEQpvw== + dependencies: + "@tiptap/core" "^2.0.0-beta.181" + "@tiptap/extension-blockquote" "^2.0.0-beta.29" + "@tiptap/extension-bold" "^2.0.0-beta.28" + "@tiptap/extension-bullet-list" "^2.0.0-beta.29" + "@tiptap/extension-code" "^2.0.0-beta.28" + "@tiptap/extension-code-block" "^2.0.0-beta.42" + "@tiptap/extension-document" "^2.0.0-beta.17" + "@tiptap/extension-dropcursor" "^2.0.0-beta.29" + "@tiptap/extension-gapcursor" "^2.0.0-beta.39" + "@tiptap/extension-hard-break" "^2.0.0-beta.33" + "@tiptap/extension-heading" "^2.0.0-beta.29" + "@tiptap/extension-history" "^2.0.0-beta.26" + "@tiptap/extension-horizontal-rule" "^2.0.0-beta.36" + "@tiptap/extension-italic" "^2.0.0-beta.28" + "@tiptap/extension-list-item" "^2.0.0-beta.23" + "@tiptap/extension-ordered-list" "^2.0.0-beta.30" + "@tiptap/extension-paragraph" "^2.0.0-beta.26" + "@tiptap/extension-strike" "^2.0.0-beta.29" + "@tiptap/extension-text" "^2.0.0-beta.17" + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -3425,7 +3617,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-node@^1.6.1: +acorn-node@^1.8.2: version "1.8.2" resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8" integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A== @@ -3623,11 +3815,16 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -arg@^5.0.0, arg@^5.0.1: +arg@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.1.tgz#eb0c9a8f77786cad2af8ff2b862899842d7b6adb" integrity sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA== +arg@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" + integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -4174,7 +4371,7 @@ cheerio@^1.0.0-rc.10: parse5-htmlparser2-tree-adapter "^7.0.0" tslib "^2.4.0" -chokidar@^3.4.2, chokidar@^3.5.2, chokidar@^3.5.3: +chokidar@^3.4.2, chokidar@^3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== @@ -5017,14 +5214,14 @@ detect-port@^1.3.0: address "^1.0.1" debug "^2.6.0" -detective@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.0.tgz#feb2a77e85b904ecdea459ad897cc90a99bd2a7b" - integrity sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg== +detective@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.1.tgz#6af01eeda11015acb0e73f933242b70f24f91034" + integrity sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw== dependencies: - acorn-node "^1.6.1" + acorn-node "^1.8.2" defined "^1.0.0" - minimist "^1.1.1" + minimist "^1.2.6" dicer@^0.3.0: version "0.3.1" @@ -5745,7 +5942,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@^3.2.7, fast-glob@^3.2.9: +fast-glob@^3.2.11, fast-glob@^3.2.7, fast-glob@^3.2.9: version "3.2.11" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9" integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew== @@ -8026,7 +8223,7 @@ minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: +minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: version "1.2.6" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== @@ -8259,11 +8456,6 @@ object-assign@^4, object-assign@^4.1.0, object-assign@^4.1.1: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= -object-hash@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" - integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== - object-hash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" @@ -8398,6 +8590,11 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" +orderedmap@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.0.0.tgz#12ff5ef6ea9d12d6430b80c701b35475e1c9ff34" + integrity sha512-buf4PoAMlh45b8a8gsGy/X6w279TSqkyAS0C0wdTSJwFSU+ljQFJON5I8NfjLHoCXwpSROIo2wr0g33T+kQshQ== + p-cancelable@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" @@ -8683,6 +8880,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== + pkg-dir@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" @@ -8750,15 +8952,23 @@ postcss-discard-unused@^5.1.0: dependencies: postcss-selector-parser "^6.0.5" -postcss-js@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-3.0.3.tgz#2f0bd370a2e8599d45439f6970403b5873abda33" - integrity sha512-gWnoWQXKFw65Hk/mi2+WTQTHdPD5UJdDXZmX073EY/B3BWnYjO4F4t0VneTCnCGQ5E5GsCdMkzPaTXwl3r5dJw== +postcss-import@^14.1.0: + version "14.1.0" + resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-14.1.0.tgz#a7333ffe32f0b8795303ee9e40215dac922781f0" + integrity sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw== + dependencies: + postcss-value-parser "^4.0.0" + read-cache "^1.0.0" + resolve "^1.1.7" + +postcss-js@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.0.tgz#31db79889531b80dc7bc9b0ad283e418dce0ac00" + integrity sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ== dependencies: camelcase-css "^2.0.1" - postcss "^8.1.6" -postcss-load-config@^3.1.0: +postcss-load-config@^3.1.4: version "3.1.4" resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz#1ab2571faf84bb078877e1d07905eabe9ebda855" integrity sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg== @@ -8961,7 +9171,7 @@ postcss-reduce-transforms@^5.1.0: dependencies: postcss-value-parser "^4.2.0" -postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.6, postcss-selector-parser@^6.0.9: +postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.6, postcss-selector-parser@^6.0.9: version "6.0.10" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d" integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== @@ -8991,7 +9201,7 @@ postcss-unique-selectors@^5.1.1: dependencies: postcss-selector-parser "^6.0.5" -postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: +postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== @@ -9019,7 +9229,7 @@ postcss@8.4.5: picocolors "^1.0.0" source-map-js "^1.0.1" -postcss@^8.1.6, postcss@^8.3.11, postcss@^8.3.5, postcss@^8.3.7, postcss@^8.4.7: +postcss@^8.3.11, postcss@^8.3.5, postcss@^8.3.7, postcss@^8.4.14, postcss@^8.4.7: version "8.4.14" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf" integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== @@ -9134,6 +9344,91 @@ property-information@^5.0.0, property-information@^5.3.0: dependencies: xtend "^4.0.0" +prosemirror-commands@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.3.0.tgz#361b2e2b2a347ce7453386459f97c3f549a1113b" + integrity sha512-BwBbZ5OAScPcm0x7H8SPbqjuEJnCU2RJT9LDyOiiIl/3NbL1nJZI4SFNHwU2e/tRr2Xe7JsptpzseqvZvToLBQ== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.0.0" + +prosemirror-dropcursor@1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.5.0.tgz#edbc61d6f71f9f924130eec8e85b0861357957c9" + integrity sha512-vy7i77ddKyXlu8kKBB3nlxLBnsWyKUmQIPB5x8RkYNh01QNp/qqGmdd5yZefJs0s3rtv5r7Izfu2qbtr+tYAMQ== + dependencies: + prosemirror-state "^1.0.0" + prosemirror-transform "^1.1.0" + prosemirror-view "^1.1.0" + +prosemirror-gapcursor@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.0.tgz#e07c22ad959b86ec0c4cfc590cc5f484dd984d56" + integrity sha512-9Tdx83xB2W4Oqchm12FtCkSizbqvi64cjs1I9TRPblqdA5TUWoVZ4ZI+t71Jh6HSEh4cDMPzx3UwfryJtKlb/w== + dependencies: + prosemirror-keymap "^1.0.0" + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-view "^1.0.0" + +prosemirror-history@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.3.0.tgz#bf5a1ff7759aca759ddf0c722c2fa5b14fb0ddc1" + integrity sha512-qo/9Wn4B/Bq89/YD+eNWFbAytu6dmIM85EhID+fz9Jcl9+DfGEo8TTSrRhP15+fFEoaPqpHSxlvSzSEbmlxlUA== + dependencies: + prosemirror-state "^1.2.2" + prosemirror-transform "^1.0.0" + rope-sequence "^1.3.0" + +prosemirror-keymap@1.2.0, prosemirror-keymap@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.2.0.tgz#d5cc9da9b712020690a994b50b92a0e448a60bf5" + integrity sha512-TdSfu+YyLDd54ufN/ZeD1VtBRYpgZnTPnnbY+4R08DDgs84KrIPEPbJL8t1Lm2dkljFx6xeBE26YWH3aIzkPKg== + dependencies: + prosemirror-state "^1.0.0" + w3c-keyname "^2.2.0" + +prosemirror-model@1.18.1, prosemirror-model@^1.0.0, prosemirror-model@^1.16.0: + version "1.18.1" + resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.18.1.tgz#1d5d6b6de7b983ee67a479dc607165fdef3935bd" + integrity sha512-IxSVBKAEMjD7s3n8cgtwMlxAXZrC7Mlag7zYsAKDndAqnDScvSmp/UdnRTV/B33lTCVU3CCm7dyAn/rVVD0mcw== + dependencies: + orderedmap "^2.0.0" + +prosemirror-schema-list@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.2.0.tgz#1932268593a7396c0ac168cbe31f28187406ce24" + integrity sha512-8PT/9xOx1HHdC7fDNNfhQ50Z8Mzu7nKyA1KCDltSpcZVZIbB0k7KtsHrnXyuIhbLlScoymBiLZ00c5MH6wdFsA== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.0.0" + +prosemirror-state@1.4.1, prosemirror-state@^1.0.0, prosemirror-state@^1.2.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.1.tgz#f6e26c7b6a7e11206176689eb6ebbf91870953e1" + integrity sha512-U/LBDW2gNmVa07sz/D229XigSdDQ5CLFwVB1Vb32MJbAHHhWe/6pOc721faI17tqw4pZ49i1xfY/jEZ9tbIhPg== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-transform "^1.0.0" + +prosemirror-transform@1.6.0, prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.6.0.tgz#8162dbfaf124f9253a7ab28605a9460411a96a53" + integrity sha512-MAp7AjsjEGEqQY0sSMufNIUuEyB1ZR9Fqlm8dTwwWwpEJRv/plsKjWXBbx52q3Ml8MtaMcd7ic14zAHVB3WaMw== + dependencies: + prosemirror-model "^1.0.0" + +prosemirror-view@1.26.2, prosemirror-view@^1.0.0, prosemirror-view@^1.1.0: + version "1.26.2" + resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.26.2.tgz#e673894ecf26aea330b727622d561c51b41d31eb" + integrity sha512-CGKw+GadkfSBEwRAJTHCEKJ4DlV6/3IhAdjpwGyZHUHtbP7jX4Ol4zmi7xa2c6GOabDlIJLYXJydoNYLX7lNeQ== + dependencies: + prosemirror-model "^1.16.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.1.0" + proto3-json-serializer@^0.1.8: version "0.1.9" resolved "https://registry.yarnpkg.com/proto3-json-serializer/-/proto3-json-serializer-0.1.9.tgz#705ddb41b009dd3e6fcd8123edd72926abf65a34" @@ -9545,6 +9840,13 @@ react@17.0.2, react@^17.0.1: loose-envify "^1.1.0" object-assign "^4.1.1" +read-cache@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" + integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA== + dependencies: + pify "^2.3.0" + read-pkg-up@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" @@ -9868,7 +10170,7 @@ resolve@^1.1.6, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.3. path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -resolve@^1.10.0: +resolve@^1.1.7, resolve@^1.10.0, resolve@^1.22.1: version "1.22.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== @@ -9917,6 +10219,11 @@ rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" +rope-sequence@^1.3.0: + version "1.3.3" + resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.3.tgz#3f67fc106288b84b71532b4a5fd9d4881e4457f0" + integrity sha512-85aZYCxweiD5J8yTEbw+E6A27zSnLPNDL0WfPdw3YYodq7WjnTKo0q4dtyQ2gz23iPT8Q9CUyJtAaUNcTxRf5Q== + rtl-detect@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/rtl-detect/-/rtl-detect-1.0.4.tgz#40ae0ea7302a150b96bc75af7d749607392ecac6" @@ -10628,32 +10935,33 @@ svgo@^2.5.0, svgo@^2.7.0: picocolors "^1.0.0" stable "^0.1.8" -tailwindcss@3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.0.1.tgz#bef72ff45d5cfed79bb648d30da952e521e98da4" - integrity sha512-EVDXVZkcueZ77/zfOJw7XkzCuxe5TCiT/S9pw9P183oRzSuwMZ7WO+W/L76jbJQA5qxGeUBJOVOLVBuAUfeZ3g== +tailwindcss@3.1.6: + version "3.1.6" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.1.6.tgz#bcb719357776c39e6376a8d84e9834b2b19a49f1" + integrity sha512-7skAOY56erZAFQssT1xkpk+kWt2NrO45kORlxFPXUt3CiGsVPhH1smuH5XoDH6sGPXLyBv+zgCKA2HWBsgCytg== dependencies: - arg "^5.0.1" - chalk "^4.1.2" - chokidar "^3.5.2" + arg "^5.0.2" + chokidar "^3.5.3" color-name "^1.1.4" - cosmiconfig "^7.0.1" - detective "^5.2.0" + detective "^5.2.1" didyoumean "^1.2.2" dlv "^1.1.3" - fast-glob "^3.2.7" + fast-glob "^3.2.11" glob-parent "^6.0.2" is-glob "^4.0.3" + lilconfig "^2.0.5" normalize-path "^3.0.0" - object-hash "^2.2.0" - postcss-js "^3.0.3" - postcss-load-config "^3.1.0" + object-hash "^3.0.0" + picocolors "^1.0.0" + postcss "^8.4.14" + postcss-import "^14.1.0" + postcss-js "^4.0.0" + postcss-load-config "^3.1.4" postcss-nested "5.0.6" - postcss-selector-parser "^6.0.6" + postcss-selector-parser "^6.0.10" postcss-value-parser "^4.2.0" quick-lru "^5.1.1" - resolve "^1.20.0" - tmp "^0.2.1" + resolve "^1.22.1" tapable@^1.0.0: version "1.1.3" @@ -10722,6 +11030,13 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.3: resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== +tippy.js@^6.3.7: + version "6.3.7" + resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c" + integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ== + dependencies: + "@popperjs/core" "^2.9.0" + tmp@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" @@ -11198,6 +11513,11 @@ vfile@^4.0.0: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" +w3c-keyname@^2.2.0: + version "2.2.4" + resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.4.tgz#4ade6916f6290224cdbd1db8ac49eab03d0eef6b" + integrity sha512-tOhfEwEzFLJzf6d1ZPkYfGj+FWhIpBux9ppoP3rlclw3Z0BZv3N7b7030Z1kYth+6rDuAsXUFr+d0VE6Ed1ikw== + wait-on@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/wait-on/-/wait-on-6.0.1.tgz#16bbc4d1e4ebdd41c5b4e63a2e16dbd1f4e5601e" From a92eda3af243e3e0183e4b262203cb94b57bc46c Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 13 Jul 2022 12:36:01 -0700 Subject: [PATCH 186/220] fix bug where descriptions not showing --- web/components/contract/contract-description.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index a427afe1..b2f839e9 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -46,8 +46,6 @@ export function ContractDescription(props: { }) } - if (!isCreator) return null - const { tags } = contract const categories = tags.filter((tag) => CATEGORY_LIST.includes(tag.toLowerCase()) From 87b669e3580f05037a3f047aa8178183c910d83c Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 13 Jul 2022 12:44:22 -0700 Subject: [PATCH 187/220] Add FYXX Foundation (h/t Holly Elmore) --- common/charity.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/common/charity.ts b/common/charity.ts index 8c33cb17..f1223b04 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -316,6 +316,14 @@ Without sufficient public interest or research activity, solutions to the proble Wild Animal Initiative currently focuses on helping scientists, grantors, and decision-makers investigate important and understudied questions about wild animal welfare. Our work catalyzes research and applied projects that will open the door to a clearer picture of wild animals’ needs and how to enhance their well-being. Ultimately, we envision a world in which people actively choose to help wild animals — and have the knowledge they need to do so responsibly.`, }, + { + name: 'FYXX Foundation', + website: 'https://www.fyxxfoundation.org/', + photo: 'https://i.imgur.com/ROmWO7m.png', + preview: + 'FYXX Foundation: wildlife population management, without killing.', + description: `The future of our planet depends on the innovations of today, and the health of our wildlife are the first indication of our successful stewardship, which we believe can be improved by safe population management utilizing fertility control instead of poison and culling.`, + }, { name: 'New Incentives', website: 'https://www.newincentives.org/', From 9075a6f33afa636985272ae50468fc17221c708f Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 13 Jul 2022 14:59:49 -0500 Subject: [PATCH 188/220] Add headers to limit orders table --- web/components/limit-bets.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx index 4f1f1893..f81d4294 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -76,6 +76,13 @@ function LimitOrderTable(props: { return ( <table className="table-compact table w-full rounded text-gray-500"> + <thead> + {!isYou && <th>User</th>} + <th>Outcome</th> + <th>Amount</th> + <th>Prob</th> + {isYou && <th></th>} + </thead> <tbody> {limitBets.map((bet) => ( <LimitBet key={bet.id} bet={bet} contract={contract} isYou={isYou} /> From e868f0a15ab5e265c33b253a7606f304695154b0 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 13 Jul 2022 15:15:03 -0500 Subject: [PATCH 189/220] Fix pagination component going one page too far + tweaks --- web/components/pagination.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/web/components/pagination.tsx b/web/components/pagination.tsx index 968e49a8..a585985d 100644 --- a/web/components/pagination.tsx +++ b/web/components/pagination.tsx @@ -7,6 +7,8 @@ export function Pagination(props: { }) { const { page, itemsPerPage, totalItems, setPage, scrollToTop } = props + const maxPage = Math.ceil(totalItems / itemsPerPage) - 1 + return ( <nav className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" @@ -14,26 +16,26 @@ export function Pagination(props: { > <div className="hidden sm:block"> <p className="text-sm text-gray-700"> - Showing{' '} + Showing <span className="font-medium">{page * itemsPerPage + 1}</span>{' '} + to{' '} <span className="font-medium"> - {page === 0 ? page + 1 : page * itemsPerPage} + {Math.min(totalItems, (page + 1) * itemsPerPage)} </span>{' '} - to <span className="font-medium">{(page + 1) * itemsPerPage}</span> of{' '} - <span className="font-medium">{totalItems}</span> results + of <span className="font-medium">{totalItems}</span> results </p> </div> <div className="flex flex-1 justify-between sm:justify-end"> <a href={scrollToTop ? '#' : undefined} - className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" onClick={() => page > 0 && setPage(page - 1)} > Previous </a> <a href={scrollToTop ? '#' : undefined} - className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" - onClick={() => page < totalItems / itemsPerPage && setPage(page + 1)} + className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => page < maxPage && setPage(page + 1)} > Next </a> From 55c91dfcdd4a5d497d1afea38b5f3db4eaa89ccc Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 13 Jul 2022 15:11:22 -0600 Subject: [PATCH 190/220] Categories to groups (#641) * start on script * Revert "Remove category filters" This reverts commit d6e808e1a39e20193c97f713b1710ed687f7a5a4. * Convert categories to official default groups * Add new users to default groups * Rework group cards * Cleanup * Add unique bettors to contract and sort by them * Most bettors to most popular * Unused vars * Track unique bettor ids on contracts * Add followed users' bets to personal markets * Add new users to welcome, bugs, and updates groups * Add users to fewer default cats --- common/calculate-dpm.ts | 2 +- common/categories.ts | 17 +- common/contract.ts | 15 +- common/group.ts | 3 + firestore.rules | 2 +- functions/src/create-user.ts | 62 +++++- functions/src/get-daily-bonuses.ts | 142 ------------- functions/src/index.ts | 1 - functions/src/on-create-bet.ts | 120 ++++++++++- functions/src/scripts/convert-categories.ts | 110 ++++++++++ web/components/contract-search.tsx | 59 +++++- web/components/leaderboard.tsx | 5 +- web/components/nav/sidebar.tsx | 4 +- web/components/notifications-icon.tsx | 10 - web/hooks/use-group.ts | 19 +- web/hooks/use-sort-and-query-params.tsx | 3 +- web/lib/firebase/api.ts | 4 - web/lib/firebase/groups.ts | 20 +- web/pages/contract-search-firestore.tsx | 5 + web/pages/create.tsx | 4 +- web/pages/group/[...slugs]/index.tsx | 210 +++++++++++--------- web/pages/groups.tsx | 180 +++++++++-------- web/pages/home.tsx | 2 +- 23 files changed, 617 insertions(+), 382 deletions(-) delete mode 100644 functions/src/get-daily-bonuses.ts create mode 100644 functions/src/scripts/convert-categories.ts diff --git a/common/calculate-dpm.ts b/common/calculate-dpm.ts index 497f1155..d38a7b67 100644 --- a/common/calculate-dpm.ts +++ b/common/calculate-dpm.ts @@ -2,7 +2,7 @@ import { cloneDeep, range, sum, sumBy, sortBy, mapValues } from 'lodash' import { Bet, NumericBet } from './bet' import { DPMContract, DPMBinaryContract, NumericContract } from './contract' import { DPM_FEES } from './fees' -import { normpdf } from '../common/util/math' +import { normpdf } from './util/math' import { addObjects } from './util/object' export function getDpmProbability(totalShares: { [outcome: string]: number }) { diff --git a/common/categories.ts b/common/categories.ts index 2bd6d25a..232aa526 100644 --- a/common/categories.ts +++ b/common/categories.ts @@ -1,5 +1,6 @@ import { difference } from 'lodash' +export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default' export const CATEGORIES = { politics: 'Politics', technology: 'Technology', @@ -24,9 +25,15 @@ export const TO_CATEGORY = Object.fromEntries( export const CATEGORY_LIST = Object.keys(CATEGORIES) -export const EXCLUDED_CATEGORIES: category[] = ['fun', 'manifold', 'personal'] +export const EXCLUDED_CATEGORIES: category[] = [ + 'fun', + 'manifold', + 'personal', + 'covid', + 'culture', + 'gaming', + 'crypto', + 'world', +] -export const DEFAULT_CATEGORIES = difference( - CATEGORY_LIST, - EXCLUDED_CATEGORIES -) +export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES) diff --git a/common/contract.ts b/common/contract.ts index 52ca91d6..5ddcf0b8 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -44,10 +44,14 @@ export type Contract<T extends AnyContractType = AnyContractType> = { volume7Days: number collectedFees: Fees + + groupSlugs?: string[] + uniqueBettorIds?: string[] + uniqueBettorCount?: number } & T -export type BinaryContract = Contract & Binary -export type PseudoNumericContract = Contract & PseudoNumeric +export type BinaryContract = Contract & Binary +export type PseudoNumericContract = Contract & PseudoNumeric export type NumericContract = Contract & Numeric export type FreeResponseContract = Contract & FreeResponse export type DPMContract = Contract & DPM @@ -109,7 +113,12 @@ export type Numeric = { export type outcomeType = AnyOutcomeType['outcomeType'] export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const -export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'PSEUDO_NUMERIC', 'NUMERIC'] as const +export const OUTCOME_TYPES = [ + 'BINARY', + 'FREE_RESPONSE', + 'PSEUDO_NUMERIC', + 'NUMERIC', +] as const export const MAX_QUESTION_LENGTH = 480 export const MAX_DESCRIPTION_LENGTH = 10000 diff --git a/common/group.ts b/common/group.ts index f06fdd15..15348d5a 100644 --- a/common/group.ts +++ b/common/group.ts @@ -9,7 +9,10 @@ export type Group = { memberIds: string[] // User ids anyoneCanJoin: boolean contractIds: string[] + + chatDisabled?: boolean } export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 +export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome'] diff --git a/firestore.rules b/firestore.rules index 28ff4485..918448d6 100644 --- a/firestore.rules +++ b/firestore.rules @@ -71,7 +71,7 @@ service cloud.firestore { match /contracts/{contractId} { allow read; allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['tags', 'lowercaseTags']); + .hasOnly(['tags', 'lowercaseTags', 'groupSlugs']); allow update: if request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['description', 'closeTime']) && resource.data.creatorId == request.auth.uid; diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index e70371ca..332c1872 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -6,7 +6,7 @@ import { SUS_STARTING_BALANCE, User, } from '../../common/user' -import { getUser, getUserByUsername } from './utils' +import { getUser, getUserByUsername, getValues, isProd } from './utils' import { randomString } from '../../common/util/random' import { cleanDisplayName, @@ -14,10 +14,19 @@ import { } from '../../common/util/clean-username' import { sendWelcomeEmail } from './emails' import { isWhitelisted } from '../../common/envs/constants' -import { DEFAULT_CATEGORIES } from '../../common/categories' +import { + CATEGORIES_GROUP_SLUG_POSTFIX, + DEFAULT_CATEGORIES, +} from '../../common/categories' import { track } from './analytics' import { APIError, newEndpoint, validate } from './api' +import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' +import { uniq } from 'lodash' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' const bodySchema = z.object({ deviceToken: z.string().optional(), @@ -85,7 +94,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { await firestore.collection('private-users').doc(auth.uid).create(privateUser) await sendWelcomeEmail(user, privateUser) - + await addUserToDefaultGroups(user) await track(auth.uid, 'create user', { username }, { ip: req.ip }) return user @@ -110,3 +119,50 @@ const numberUsersWithIp = async (ipAddress: string) => { return snap.docs.length } + +const addUserToDefaultGroups = async (user: User) => { + for (const category of Object.values(DEFAULT_CATEGORIES)) { + const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX + const groups = await getValues<Group>( + firestore.collection('groups').where('slug', '==', slug) + ) + await firestore + .collection('groups') + .doc(groups[0].id) + .update({ + memberIds: uniq(groups[0].memberIds.concat(user.id)), + }) + } + + for (const slug of NEW_USER_GROUP_SLUGS) { + const groups = await getValues<Group>( + firestore.collection('groups').where('slug', '==', slug) + ) + const group = groups[0] + await firestore + .collection('groups') + .doc(group.id) + .update({ + memberIds: uniq(group.memberIds.concat(user.id)), + }) + const manifoldAccount = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + + if (slug === 'welcome') { + const welcomeCommentDoc = firestore + .collection(`groups/${group.id}/comments`) + .doc() + await welcomeCommentDoc.create({ + id: welcomeCommentDoc.id, + groupId: group.id, + userId: manifoldAccount, + text: `Welcome, ${user.name} (@${user.username})!`, + createdTime: Date.now(), + userName: 'Manifold Markets', + userUsername: 'ManifoldMarkets', + userAvatarUrl: 'https://manifold.markets/logo-bg-white.png', + }) + } + } +} diff --git a/functions/src/get-daily-bonuses.ts b/functions/src/get-daily-bonuses.ts deleted file mode 100644 index 017c32fc..00000000 --- a/functions/src/get-daily-bonuses.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { APIError, newEndpoint } from './api' -import { isProd, log } from './utils' -import * as admin from 'firebase-admin' -import { PrivateUser } from '../../common/lib/user' -import { uniq } from 'lodash' -import { Bet } from '../../common/lib/bet' -const firestore = admin.firestore() -import { - DEV_HOUSE_LIQUIDITY_PROVIDER_ID, - HOUSE_LIQUIDITY_PROVIDER_ID, -} from '../../common/antes' -import { runTxn, TxnData } from './transact' -import { createNotification } from './create-notification' -import { User } from '../../common/lib/user' -import { Contract } from '../../common/lib/contract' -import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants' - -const BONUS_START_DATE = new Date('2022-07-01T00:00:00.000Z').getTime() -const QUERY_LIMIT_SECONDS = 60 - -export const getdailybonuses = newEndpoint({}, async (req, auth) => { - const { user, lastTimeCheckedBonuses } = await firestore.runTransaction( - async (trans) => { - const userSnap = await trans.get( - firestore.doc(`private-users/${auth.uid}`) - ) - if (!userSnap.exists) throw new APIError(400, 'User not found.') - const user = userSnap.data() as PrivateUser - const lastTimeCheckedBonuses = user.lastTimeCheckedBonuses ?? 0 - if (Date.now() - lastTimeCheckedBonuses < QUERY_LIMIT_SECONDS * 1000) - throw new APIError( - 400, - `Limited to one query per user per ${QUERY_LIMIT_SECONDS} seconds.` - ) - await trans.update(userSnap.ref, { - lastTimeCheckedBonuses: Date.now(), - }) - return { - user, - lastTimeCheckedBonuses, - } - } - ) - const fromUserId = isProd() - ? HOUSE_LIQUIDITY_PROVIDER_ID - : DEV_HOUSE_LIQUIDITY_PROVIDER_ID - const fromSnap = await firestore.doc(`users/${fromUserId}`).get() - if (!fromSnap.exists) throw new APIError(400, 'From user not found.') - const fromUser = fromSnap.data() as User - // Get all users contracts made since implementation time - const userContractsSnap = await firestore - .collection(`contracts`) - .where('creatorId', '==', user.id) - .where('createdTime', '>=', BONUS_START_DATE) - .get() - const userContracts = userContractsSnap.docs.map( - (doc) => doc.data() as Contract - ) - const nullReturn = { status: 'no bets', txn: null } - for (const contract of userContracts) { - const result = await firestore.runTransaction(async (trans) => { - const contractId = contract.id - // Get all bets made on user's contracts - const bets = ( - await firestore - .collection(`contracts/${contractId}/bets`) - .where('userId', '!=', user.id) - .get() - ).docs.map((bet) => bet.ref) - if (bets.length === 0) { - return nullReturn - } - const contractBetsSnap = await trans.getAll(...bets) - const contractBets = contractBetsSnap.map((doc) => doc.data() as Bet) - - const uniqueBettorIdsBeforeLastResetTime = uniq( - contractBets - .filter((bet) => bet.createdTime < lastTimeCheckedBonuses) - .map((bet) => bet.userId) - ) - - // Filter users for ONLY those that have made bets since the last daily bonus received time - const uniqueBettorIdsWithBetsAfterLastResetTime = uniq( - contractBets - .filter((bet) => bet.createdTime > lastTimeCheckedBonuses) - .map((bet) => bet.userId) - ) - - // Filter for users only present in the above list - const newUniqueBettorIds = - uniqueBettorIdsWithBetsAfterLastResetTime.filter( - (userId) => !uniqueBettorIdsBeforeLastResetTime.includes(userId) - ) - newUniqueBettorIds.length > 0 && - log( - `Got ${newUniqueBettorIds.length} new unique bettors since last bonus` - ) - if (newUniqueBettorIds.length === 0) { - return nullReturn - } - // Create combined txn for all unique bettors - const bonusTxnDetails = { - contractId: contractId, - uniqueBettors: newUniqueBettorIds.length, - } - const bonusTxn: TxnData = { - fromId: fromUser.id, - fromType: 'BANK', - toId: user.id, - toType: 'USER', - amount: UNIQUE_BETTOR_BONUS_AMOUNT * newUniqueBettorIds.length, - token: 'M$', - category: 'UNIQUE_BETTOR_BONUS', - description: JSON.stringify(bonusTxnDetails), - } - return await runTxn(trans, bonusTxn) - }) - - if (result.status != 'success' || !result.txn) { - result.status != nullReturn.status && - log(`No bonus for user: ${user.id} - reason:`, result.status) - } else { - log(`Bonus txn for user: ${user.id} completed:`, result.txn?.id) - await createNotification( - result.txn.id, - 'bonus', - 'created', - fromUser, - result.txn.id, - result.txn.amount + '', - contract, - undefined, - // No need to set the user id, we'll use the contract creator id - undefined, - contract.slug, - contract.question - ) - } - } - - return { userId: user.id, message: 'success' } -}) diff --git a/functions/src/index.ts b/functions/src/index.ts index e5ae78ec..cf75802e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -38,6 +38,5 @@ export * from './add-liquidity' export * from './withdraw-liquidity' export * from './create-group' export * from './resolve-market' -export * from './get-daily-bonuses' export * from './unsubscribe' export * from './stripe' diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 5789ed0b..adf22d56 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -1,13 +1,26 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { keyBy } from 'lodash' +import { keyBy, uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' -import { getContract, getUser, getValues } from './utils' -import { createBetFillNotification } from './create-notification' +import { getContract, getUser, getValues, isProd, log } from './utils' +import { + createBetFillNotification, + createNotification, +} from './create-notification' import { filterDefined } from '../../common/util/array' +import { Contract } from '../../common/contract' +import { runTxn, TxnData } from './transact' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' +import { APIError } from '../../common/api' +import { User } from '../../common/user' const firestore = admin.firestore() +const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() export const onCreateBet = functions.firestore .document('contracts/{contractId}/bets/{betId}') @@ -26,8 +39,109 @@ export const onCreateBet = functions.firestore .update({ lastBetTime, lastUpdatedTime: Date.now() }) await notifyFills(bet, contractId, eventId) + await updateUniqueBettorsAndGiveCreatorBonus( + contractId, + eventId, + bet.userId + ) }) +const updateUniqueBettorsAndGiveCreatorBonus = async ( + contractId: string, + eventId: string, + bettorId: string +) => { + const userContractSnap = await firestore + .collection(`contracts`) + .doc(contractId) + .get() + const contract = userContractSnap.data() as Contract + if (!contract) { + log(`Could not find contract ${contractId}`) + return + } + let previousUniqueBettorIds = contract.uniqueBettorIds + + if (!previousUniqueBettorIds) { + const contractBets = ( + await firestore + .collection(`contracts/${contractId}/bets`) + .where('userId', '!=', contract.creatorId) + .get() + ).docs.map((doc) => doc.data() as Bet) + + if (contractBets.length === 0) { + log(`No bets for contract ${contractId}`) + return + } + + previousUniqueBettorIds = uniq( + contractBets + .filter((bet) => bet.createdTime < BONUS_START_DATE) + .map((bet) => bet.userId) + ) + } + + const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId) + + const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId]) + // Update contract unique bettors + if (!contract.uniqueBettorIds || isNewUniqueBettor) { + log(`Got ${previousUniqueBettorIds} unique bettors`) + isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) + await firestore.collection(`contracts`).doc(contractId).update({ + uniqueBettorIds: newUniqueBettorIds, + uniqueBettorCount: newUniqueBettorIds.length, + }) + } + if (!isNewUniqueBettor) return + + // Create combined txn for all new unique bettors + const bonusTxnDetails = { + contractId: contractId, + uniqueBettorIds: newUniqueBettorIds, + } + const fromUserId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + const fromSnap = await firestore.doc(`users/${fromUserId}`).get() + if (!fromSnap.exists) throw new APIError(400, 'From user not found.') + const fromUser = fromSnap.data() as User + const result = await firestore.runTransaction(async (trans) => { + const bonusTxn: TxnData = { + fromId: fromUser.id, + fromType: 'BANK', + toId: contract.creatorId, + toType: 'USER', + amount: UNIQUE_BETTOR_BONUS_AMOUNT, + token: 'M$', + category: 'UNIQUE_BETTOR_BONUS', + description: JSON.stringify(bonusTxnDetails), + } + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + log(`No bonus for user: ${contract.creatorId} - reason:`, result.status) + } else { + log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) + await createNotification( + result.txn.id, + 'bonus', + 'created', + fromUser, + eventId + '-bonus', + result.txn.amount + '', + contract, + undefined, + // No need to set the user id, we'll use the contract creator id + undefined, + contract.slug, + contract.question + ) + } +} + const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { if (!bet.fills) return diff --git a/functions/src/scripts/convert-categories.ts b/functions/src/scripts/convert-categories.ts new file mode 100644 index 00000000..8fe90807 --- /dev/null +++ b/functions/src/scripts/convert-categories.ts @@ -0,0 +1,110 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +initAdmin() + +import { getValues, isProd } from '../utils' +import { + CATEGORIES_GROUP_SLUG_POSTFIX, + DEFAULT_CATEGORIES, +} from 'common/categories' +import { Group } from 'common/group' +import { uniq } from 'lodash' +import { Contract } from 'common/contract' +import { User } from 'common/user' +import { filterDefined } from 'common/util/array' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from 'common/antes' + +const adminFirestore = admin.firestore() + +async function convertCategoriesToGroups() { + const groups = await getValues<Group>(adminFirestore.collection('groups')) + const contracts = await getValues<Contract>( + adminFirestore.collection('contracts') + ) + for (const group of groups) { + const groupContracts = contracts.filter((contract) => + group.contractIds.includes(contract.id) + ) + for (const contract of groupContracts) { + await adminFirestore + .collection('contracts') + .doc(contract.id) + .update({ + groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), + }) + } + } + + for (const category of Object.values(DEFAULT_CATEGORIES)) { + const markets = await getValues<Contract>( + adminFirestore + .collection('contracts') + .where('lowercaseTags', 'array-contains', category.toLowerCase()) + ) + const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX + const oldGroup = await getValues<Group>( + adminFirestore.collection('groups').where('slug', '==', slug) + ) + if (oldGroup.length > 0) { + console.log(`Found old group for ${category}`) + await adminFirestore.collection('groups').doc(oldGroup[0].id).delete() + } + + const allUsers = await getValues<User>(adminFirestore.collection('users')) + const groupUsers = filterDefined( + allUsers.map((user: User) => { + if (!user.followedCategories || user.followedCategories.length === 0) + return user.id + if (!user.followedCategories.includes(category.toLowerCase())) + return null + return user.id + }) + ) + + const manifoldAccount = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + const newGroupRef = await adminFirestore.collection('groups').doc() + const newGroup: Group = { + id: newGroupRef.id, + name: category, + slug, + creatorId: manifoldAccount, + createdTime: Date.now(), + anyoneCanJoin: true, + memberIds: [manifoldAccount], + about: 'Official group for all things related to ' + category, + mostRecentActivityTime: Date.now(), + contractIds: markets.map((market) => market.id), + chatDisabled: true, + } + + await adminFirestore.collection('groups').doc(newGroupRef.id).set(newGroup) + // Update group with new memberIds to avoid notifying everyone + await adminFirestore + .collection('groups') + .doc(newGroupRef.id) + .update({ + memberIds: uniq(groupUsers), + }) + + for (const market of markets) { + await adminFirestore + .collection('contracts') + .doc(market.id) + .update({ + groupSlugs: uniq([...(market?.groupSlugs ?? []), newGroup.slug]), + }) + } + } +} + +if (require.main === module) { + convertCategoriesToGroups() + .then(() => process.exit()) + .catch(console.log) +} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 220a95ab..834a4db1 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -20,8 +20,12 @@ import { Row } from './layout/row' import { useEffect, useMemo, useRef, useState } from 'react' import { Spacer } from './layout/spacer' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' +import { useUser } from 'web/hooks/use-user' +import { useFollows } from 'web/hooks/use-follows' import { trackCallback } from 'web/lib/service/analytics' import ContractSearchFirestore from 'web/pages/contract-search-firestore' +import { useMemberGroups } from 'web/hooks/use-group' +import { NEW_USER_GROUP_SLUGS } from 'common/group' const searchClient = algoliasearch( 'GJQPAYENIF', @@ -33,6 +37,7 @@ const indexPrefix = ENV === 'DEV' ? 'dev-' : '' const sortIndexes = [ { label: 'Newest', value: indexPrefix + 'contracts-newest' }, { label: 'Oldest', value: indexPrefix + 'contracts-oldest' }, + { label: 'Most popular', value: indexPrefix + 'contracts-most-popular' }, { label: 'Most traded', value: indexPrefix + 'contracts-most-traded' }, { label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' }, { label: 'Last updated', value: indexPrefix + 'contracts-last-updated' }, @@ -40,7 +45,7 @@ const sortIndexes = [ { label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' }, ] -type filter = 'open' | 'closed' | 'resolved' | 'all' +type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' export function ContractSearch(props: { querySortOptions?: { @@ -69,13 +74,19 @@ export function ContractSearch(props: { hideQuickBet, } = props + const user = useUser() + const memberGroupSlugs = useMemberGroups(user?.id) + ?.map((g) => g.slug) + .filter((s) => !NEW_USER_GROUP_SLUGS.includes(s)) + const follows = useFollows(user?.id) + console.log(memberGroupSlugs, follows) const { initialSort } = useInitialQueryAndSort(querySortOptions) const sort = sortIndexes .map(({ value }) => value) .includes(`${indexPrefix}contracts-${initialSort ?? ''}`) ? initialSort - : querySortOptions?.defaultSort ?? '24-hour-vol' + : querySortOptions?.defaultSort ?? 'most-popular' const [filter, setFilter] = useState<filter>( querySortOptions?.defaultFilter ?? 'open' @@ -86,10 +97,21 @@ export function ContractSearch(props: { filter === 'open' ? 'isResolved:false' : '', filter === 'closed' ? 'isResolved:false' : '', filter === 'resolved' ? 'isResolved:true' : '', + filter === 'personal' + ? // Show contracts in groups that the user is a member of + (memberGroupSlugs?.map((slug) => `groupSlugs:${slug}`) ?? []) + // Show contracts created by users the user follows + .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) + // Show contracts bet on by users the user follows + .concat( + follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] + // Show contracts bet on by the user + ) + .concat(user ? `uniqueBettorIds:${user.id}` : []) + : '', additionalFilter?.creatorId ? `creatorId:${additionalFilter.creatorId}` : '', - additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', ].filter((f) => f) // Hack to make Algolia work. filters = ['', ...filters] @@ -100,7 +122,12 @@ export function ContractSearch(props: { ].filter((f) => f) return { filters, numericFilters } - }, [filter, Object.values(additionalFilter ?? {}).join(',')]) + }, [ + filter, + Object.values(additionalFilter ?? {}).join(','), + (memberGroupSlugs ?? []).join(','), + (follows ?? []).join(','), + ]) const indexName = `${indexPrefix}contracts-${sort}` @@ -125,6 +152,7 @@ export function ContractSearch(props: { resetIcon: 'mt-2 hidden sm:flex', }} /> + {/*// TODO track WHICH filter users are using*/} <select className="!select !select-bordered" value={filter} @@ -134,6 +162,7 @@ export function ContractSearch(props: { <option value="open">Open</option> <option value="closed">Closed</option> <option value="resolved">Resolved</option> + <option value="personal">For you</option> <option value="all">All</option> </select> {!hideOrderSelector && ( @@ -155,13 +184,21 @@ export function ContractSearch(props: { <Spacer h={3} /> - <ContractSearchInner - querySortOptions={querySortOptions} - onContractClick={onContractClick} - overrideGridClassName={overrideGridClassName} - hideQuickBet={hideQuickBet} - excludeContractIds={additionalFilter?.excludeContractIds} - /> + {/*<Spacer h={4} />*/} + + {filter === 'personal' && + (follows ?? []).length === 0 && + (memberGroupSlugs ?? []).length === 0 ? ( + <>You're not following anyone, nor in any of your own groups yet.</> + ) : ( + <ContractSearchInner + querySortOptions={querySortOptions} + onContractClick={onContractClick} + overrideGridClassName={overrideGridClassName} + hideQuickBet={hideQuickBet} + excludeContractIds={additionalFilter?.excludeContractIds} + /> + )} </InstantSearch> ) } diff --git a/web/components/leaderboard.tsx b/web/components/leaderboard.tsx index fb104060..b8c725e0 100644 --- a/web/components/leaderboard.tsx +++ b/web/components/leaderboard.tsx @@ -13,9 +13,12 @@ export function Leaderboard(props: { renderCell: (user: User) => any }[] className?: string + maxToShow?: number }) { // TODO: Ideally, highlight your own entry on the leaderboard - const { title, users, columns, className } = props + const { title, columns, className } = props + const maxToShow = props.maxToShow ?? props.users.length + const users = props.users.slice(0, maxToShow) return ( <div className={clsx('w-full px-1', className)}> <Title text={title} className="!mt-0" /> diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 430e98d2..784eb63a 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -193,7 +193,9 @@ export default function Sidebar(props: { className?: string }) { const mobileNavigationOptions = !user ? signedOutMobileNavigation : signedInMobileNavigation - const memberItems = (useMemberGroups(user?.id) ?? []).map((group: Group) => ({ + const memberItems = ( + useMemberGroups(user?.id, { withChatEnabled: true }) ?? [] + ).map((group: Group) => ({ name: group.name, href: groupPath(group.slug), })) diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index 478b4ad4..0dfc5054 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -6,22 +6,12 @@ import { usePrivateUser, useUser } from 'web/hooks/use-user' import { useRouter } from 'next/router' import { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' -import { requestBonuses } from 'web/lib/firebase/api' import { PrivateUser } from 'common/user' export default function NotificationsIcon(props: { className?: string }) { const user = useUser() const privateUser = usePrivateUser(user?.id) - useEffect(() => { - if ( - privateUser && - privateUser.lastTimeCheckedBonuses && - Date.now() - privateUser.lastTimeCheckedBonuses > 1000 * 70 - ) - requestBonuses({}).catch(() => console.log('no bonuses for you (yet)')) - }, [privateUser]) - return ( <Row className={clsx('justify-center')}> <div className={'relative'}> diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 41f84707..1fde9f4e 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -2,12 +2,15 @@ import { useEffect, useState } from 'react' import { Group } from 'common/group' import { User } from 'common/user' import { + getGroupBySlug, getGroupsWithContractId, listenForGroup, listenForGroups, listenForMemberGroups, } from 'web/lib/firebase/groups' import { getUser } from 'web/lib/firebase/users' +import { CATEGORIES, CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories' +import { filterDefined } from 'common/util/array' export const useGroup = (groupId: string | undefined) => { const [group, setGroup] = useState<Group | null | undefined>() @@ -29,11 +32,21 @@ export const useGroups = () => { return groups } -export const useMemberGroups = (userId: string | null | undefined) => { +export const useMemberGroups = ( + userId: string | null | undefined, + options?: { withChatEnabled: boolean } +) => { const [memberGroups, setMemberGroups] = useState<Group[] | undefined>() useEffect(() => { - if (userId) return listenForMemberGroups(userId, setMemberGroups) - }, [userId]) + if (userId) + return listenForMemberGroups(userId, (groups) => { + if (options?.withChatEnabled) + return setMemberGroups( + filterDefined(groups.filter((group) => group.chatDisabled !== true)) + ) + return setMemberGroups(groups) + }) + }, [options?.withChatEnabled, userId]) return memberGroups } diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index b7bfb288..a2590249 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -10,6 +10,7 @@ export type Sort = | 'newest' | 'oldest' | 'most-traded' + | 'most-popular' | '24-hour-vol' | 'close-date' | 'resolve-date' @@ -35,7 +36,7 @@ export function useInitialQueryAndSort(options?: { shouldLoadFromStorage?: boolean }) { const { defaultSort, shouldLoadFromStorage } = defaults(options, { - defaultSort: '24-hour-vol', + defaultSort: 'most-popular', shouldLoadFromStorage: true, }) const router = useRouter() diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index a6bd4359..27d6caa3 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -80,7 +80,3 @@ export function claimManalink(params: any) { export function createGroup(params: any) { return call(getFunctionUrl('creategroup'), 'POST', params) } - -export function requestBonuses(params: any) { - return call(getFunctionUrl('getdailybonuses'), 'POST', params) -} diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index fbb11520..708096b3 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -8,7 +8,7 @@ import { } from 'firebase/firestore' import { sortBy, uniq } from 'lodash' import { Group } from 'common/group' -import { getContractFromId } from './contracts' +import { getContractFromId, updateContract } from './contracts' import { coll, getValue, @@ -17,6 +17,7 @@ import { listenForValues, } from './utils' import { filterDefined } from 'common/util/array' +import { Contract } from 'common/contract' export const groups = coll<Group>('groups') @@ -129,7 +130,22 @@ export async function leaveGroup(group: Group, userId: string): Promise<Group> { return newGroup } -export async function addContractToGroup(group: Group, contractId: string) { +export async function addContractToGroup(group: Group, contract: Contract) { + await updateContract(contract.id, { + groupSlugs: [...(contract.groupSlugs ?? []), group.slug], + }) + return await updateGroup(group, { + contractIds: uniq([...group.contractIds, contract.id]), + }) + .then(() => group) + .catch((err) => { + console.error('error adding contract to group', err) + return err + }) +} + +export async function setContractGroupSlugs(group: Group, contractId: string) { + await updateContract(contractId, { groupSlugs: [group.slug] }) return await updateGroup(group, { contractIds: uniq([...group.contractIds, contractId]), }) diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 2fa4844e..3ac11993 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -61,6 +61,10 @@ export default function ContractSearchFirestore(props: { ) } else if (sort === 'most-traded') { matches.sort((a, b) => b.volume - a.volume) + } else if (sort === 'most-popular') { + matches.sort( + (a, b) => (b.uniqueBettorCount ?? 0) - (a.uniqueBettorCount ?? 0) + ) } else if (sort === '24-hour-vol') { // Use lodash for stable sort, so previous sort breaks all ties. matches = sortBy(matches, ({ volume7Days }) => -1 * volume7Days) @@ -107,6 +111,7 @@ export default function ContractSearchFirestore(props: { > <option value="newest">Newest</option> <option value="oldest">Oldest</option> + <option value="most-popular">Most popular</option> <option value="most-traded">Most traded</option> <option value="24-hour-vol">24h volume</option> <option value="close-date">Closing soon</option> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 705ef0eb..7040dff0 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -19,7 +19,7 @@ import { import { formatMoney } from 'common/util/format' import { removeUndefinedProps } from 'common/util/object' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { addContractToGroup, getGroup } from 'web/lib/firebase/groups' +import { setContractGroupSlugs, getGroup } from 'web/lib/firebase/groups' import { Group } from 'common/group' import { useTracking } from 'web/hooks/use-tracking' import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' @@ -217,7 +217,7 @@ export function NewContract(props: { isFree: false, }) if (result && selectedGroup) { - await addContractToGroup(selectedGroup, result.id) + await setContractGroupSlugs(selectedGroup, result.id) } await router.push(contractPath(result as Contract)) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 4266b808..fc76df48 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -9,8 +9,8 @@ import { getGroupBySlug, getGroupContracts, updateGroup, - addContractToGroup, addUserToGroup, + addContractToGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' @@ -54,6 +54,7 @@ import clsx from 'clsx' import { FollowList } from 'web/components/follow-list' import { SearchIcon } from '@heroicons/react/outline' import { useTipTxns } from 'web/hooks/use-tip-txns' +import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -145,7 +146,7 @@ export default function GroupPage(props: { const router = useRouter() const { slugs } = router.query as { slugs: string[] } - const page = (slugs?.[1] ?? 'chat') as typeof groupSubpages[number] + const page = slugs?.[1] as typeof groupSubpages[number] const group = useGroup(props.group?.id) ?? props.group const [contracts, setContracts] = useState<Contract[] | undefined>(undefined) @@ -213,6 +214,75 @@ export default function GroupPage(props: { /> </Col> ) + + const tabs = [ + ...(group.chatDisabled + ? [] + : [ + { + title: 'Chat', + content: messages ? ( + <GroupChat + messages={messages} + user={user} + group={group} + tips={tips} + /> + ) : ( + <LoadingIndicator /> + ), + href: groupPath(group.slug, 'chat'), + }, + ]), + { + title: 'Questions', + content: ( + <div className={'mt-2 px-1'}> + {contracts ? ( + contracts.length > 0 ? ( + <> + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Search the group's questions" + className="input input-bordered mb-4 w-full" + /> + <ContractsGrid + contracts={query != '' ? filteredContracts : contracts} + hasMore={false} + loadMore={() => {}} + /> + </> + ) : ( + <div className="p-2 text-gray-500"> + No questions yet. Why not{' '} + <SiteLink + href={`/create/?groupId=${group.id}`} + className={'font-bold text-gray-700'} + > + add one? + </SiteLink> + </div> + ) + ) : ( + <LoadingIndicator /> + )} + </div> + ), + href: groupPath(group.slug, 'questions'), + }, + { + title: 'Rankings', + content: leaderboard, + href: groupPath(group.slug, 'rankings'), + }, + { + title: 'About', + content: aboutTab, + href: groupPath(group.slug, 'about'), + }, + ] + const tabIndex = tabs.map((t) => t.title).indexOf(page ?? 'chat') return ( <Page rightSidebar={rightSidebar} className="!pb-0"> <SEO @@ -250,79 +320,9 @@ export default function GroupPage(props: { <Tabs currentPageForAnalytics={groupPath(group.slug)} - className={'mx-3 mb-0'} - defaultIndex={ - page === 'rankings' - ? 2 - : page === 'about' - ? 3 - : page === 'questions' - ? 1 - : 0 - } - tabs={[ - { - title: 'Chat', - content: messages ? ( - <GroupChat - messages={messages} - user={user} - group={group} - tips={tips} - /> - ) : ( - <LoadingIndicator /> - ), - href: groupPath(group.slug, 'chat'), - }, - { - title: 'Questions', - content: ( - <div className={'mt-2 px-1'}> - {contracts ? ( - contracts.length > 0 ? ( - <> - <input - type="text" - onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Search the group's questions" - className="input input-bordered mb-4 w-full" - /> - <ContractsGrid - contracts={query != '' ? filteredContracts : contracts} - hasMore={false} - loadMore={() => {}} - /> - </> - ) : ( - <div className="p-2 text-gray-500"> - No questions yet. Why not{' '} - <SiteLink - href={`/create/?groupId=${group.id}`} - className={'font-bold text-gray-700'} - > - add one? - </SiteLink> - </div> - ) - ) : ( - <LoadingIndicator /> - )} - </div> - ), - href: groupPath(group.slug, 'questions'), - }, - { - title: 'Rankings', - content: leaderboard, - href: groupPath(group.slug, 'rankings'), - }, - { - title: 'About', - content: aboutTab, - href: groupPath(group.slug, 'about'), - }, - ]} + className={'mb-0 sm:mb-2'} + defaultIndex={tabIndex > 0 ? tabIndex : 0} + tabs={tabs} /> </Page> ) @@ -391,7 +391,16 @@ function GroupOverview(props: { username={creator.username} /> </div> - {isCreator && <EditGroupButton className={'ml-1'} group={group} />} + {isCreator ? ( + <EditGroupButton className={'ml-1'} group={group} /> + ) : ( + user && + group.memberIds.includes(user?.id) && ( + <Row> + <JoinOrLeaveGroupButton group={group} /> + </Row> + ) + )} </Row> <div className={'block sm:hidden'}> <Linkify text={group.about} /> @@ -461,12 +470,19 @@ function GroupMemberSearch(props: { group: Group }) { (m) => checkAgainstQuery(query, m.name) || checkAgainstQuery(query, m.username) ) + const matchLimit = 25 + return ( <div> <SearchBar setQuery={setQuery} /> <Col className={'gap-2'}> {matches.length > 0 && ( - <FollowList userIds={matches.map((m) => m.id)} /> + <FollowList userIds={matches.slice(0, matchLimit).map((m) => m.id)} /> + )} + {matches.length > 25 && ( + <div className={'text-center'}> + And {matches.length - matchLimit} more... + </div> )} </Col> </div> @@ -475,25 +491,21 @@ function GroupMemberSearch(props: { group: Group }) { export function GroupMembersList(props: { group: Group }) { const { group } = props - const members = useMembers(group) - const maxMambersToShow = 5 + const members = useMembers(group).filter((m) => m.id !== group.creatorId) + const maxMembersToShow = 3 if (group.memberIds.length === 1) return <div /> return ( - <div> - <div> - <div className="text-neutral flex flex-wrap gap-1"> - <span className={'text-gray-500'}>Other members</span> - {members.slice(0, maxMambersToShow).map((member, i) => ( - <div key={member.id} className={'flex-shrink'}> - <UserLink name={member.name} username={member.username} /> - {members.length > 1 && i !== members.length - 1 && <span>,</span>} - </div> - ))} - {members.length > maxMambersToShow && ( - <span> & {members.length - maxMambersToShow} more</span> - )} + <div className="text-neutral flex flex-wrap gap-1"> + <span className={'text-gray-500'}>Other members</span> + {members.slice(0, maxMembersToShow).map((member, i) => ( + <div key={member.id} className={'flex-shrink'}> + <UserLink name={member.name} username={member.username} /> + {members.length > 1 && i !== members.length - 1 && <span>,</span>} </div> - </div> + ))} + {members.length > maxMembersToShow && ( + <span> & {members.length - maxMembersToShow} more</span> + )} </div> ) } @@ -503,8 +515,9 @@ function SortedLeaderboard(props: { scoreFunction: (user: User) => number title: string header: string + maxToShow?: number }) { - const { users, scoreFunction, title, header } = props + const { users, scoreFunction, title, header, maxToShow } = props const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a)) return ( <Leaderboard @@ -514,6 +527,7 @@ function SortedLeaderboard(props: { columns={[ { header, renderCell: (user) => formatMoney(scoreFunction(user)) }, ]} + maxToShow={maxToShow} /> ) } @@ -528,7 +542,7 @@ function GroupLeaderboards(props: { }) { const { traderScores, creatorScores, members, topTraders, topCreators } = props - + const maxToShow = 50 // Consider hiding M$0 // If it's just one member (curator), show all bettors, otherwise just show members return ( @@ -541,12 +555,14 @@ function GroupLeaderboards(props: { scoreFunction={(user) => traderScores[user.id] ?? 0} title="🏅 Bettor rankings" header="Profit" + maxToShow={maxToShow} /> <SortedLeaderboard users={members} scoreFunction={(user) => creatorScores[user.id] ?? 0} title="🏅 Creator rankings" header="Market volume" + maxToShow={maxToShow} /> </> ) : ( @@ -561,6 +577,7 @@ function GroupLeaderboards(props: { renderCell: (user) => formatMoney(traderScores[user.id] ?? 0), }, ]} + maxToShow={maxToShow} /> <Leaderboard className="max-w-xl" @@ -573,6 +590,7 @@ function GroupLeaderboards(props: { formatMoney(creatorScores[user.id] ?? 0), }, ]} + maxToShow={maxToShow} /> </> )} @@ -586,7 +604,7 @@ function AddContractButton(props: { group: Group; user: User }) { const [open, setOpen] = useState(false) async function addContractToCurrentGroup(contract: Contract) { - await addContractToGroup(group, contract.id) + await addContractToGroup(group, contract) setOpen(false) } diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index ae64cc76..8f2fe424 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -7,7 +7,6 @@ import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { Title } from 'web/components/title' -import { UserLink } from 'web/components/user-page' import { useGroups, useMemberGroupIds } from 'web/hooks/use-group' import { useUser } from 'web/hooks/use-user' import { groupPath, listAllGroups } from 'web/lib/firebase/groups' @@ -17,6 +16,8 @@ import { GroupMembersList } from 'web/pages/group/[...slugs]' import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' import { SiteLink } from 'web/components/site-link' import clsx from 'clsx' +import { Avatar } from 'web/components/avatar' +import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' export async function getStaticProps() { const groups = await listAllGroups().catch((_) => []) @@ -92,79 +93,75 @@ export default function Groups(props: { return ( <Page> <Col className="items-center"> - <Col className="w-full max-w-xl"> - <Col className="px-4 sm:px-0"> - <Row className="items-center justify-between"> - <Title text="Explore groups" /> - {user && ( - <CreateGroupButton user={user} goToGroupOnSubmit={true} /> - )} - </Row> + <Col className="w-full max-w-2xl px-4 sm:px-2"> + <Row className="items-center justify-between"> + <Title text="Explore groups" /> + {user && <CreateGroupButton user={user} goToGroupOnSubmit={true} />} + </Row> - <div className="mb-6 text-gray-500"> - Discuss and compete on questions with a group of friends. - </div> + <div className="mb-6 text-gray-500"> + Discuss and compete on questions with a group of friends. + </div> - <Tabs - currentPageForAnalytics={'groups'} - tabs={[ - ...(user && memberGroupIds.length > 0 - ? [ - { - title: 'My Groups', - content: ( - <Col> - <input - type="text" - onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Search your groups" - className="input input-bordered mb-4 w-full" - /> - - <Col className="gap-4"> - {matchesOrderedByRecentActivity - .filter((match) => - memberGroupIds.includes(match.id) - ) - .map((group) => ( - <GroupCard - key={group.id} - group={group} - creator={creatorsDict[group.creatorId]} - /> - ))} - </Col> - </Col> - ), - }, - ] - : []), - { - title: 'All', - content: ( - <Col> - <input - type="text" - onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Search groups" - className="input input-bordered mb-4 w-full" - /> - - <Col className="gap-4"> - {matches.map((group) => ( - <GroupCard - key={group.id} - group={group} - creator={creatorsDict[group.creatorId]} + <Tabs + currentPageForAnalytics={'groups'} + tabs={[ + ...(user && memberGroupIds.length > 0 + ? [ + { + title: 'My Groups', + content: ( + <Col> + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Search your groups" + className="input input-bordered mb-4 w-full" /> - ))} - </Col> - </Col> - ), - }, - ]} - /> - </Col> + + <div className="flex flex-wrap justify-center gap-4"> + {matchesOrderedByRecentActivity + .filter((match) => + memberGroupIds.includes(match.id) + ) + .map((group) => ( + <GroupCard + key={group.id} + group={group} + creator={creatorsDict[group.creatorId]} + /> + ))} + </div> + </Col> + ), + }, + ] + : []), + { + title: 'All', + content: ( + <Col> + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Search groups" + className="input input-bordered mb-4 w-full" + /> + + <div className="flex flex-wrap justify-center gap-4"> + {matches.map((group) => ( + <GroupCard + key={group.id} + group={group} + creator={creatorsDict[group.creatorId]} + /> + ))} + </div> + </Col> + ), + }, + ]} + /> </Col> </Col> </Page> @@ -176,32 +173,33 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { return ( <Col key={group.id} - className="relative gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100" + className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100" > <Link href={groupPath(group.slug)}> - <a className="absolute left-0 right-0 top-0 bottom-0" /> + <a className="absolute left-0 right-0 top-0 bottom-0 z-0" /> </Link> + <div> + <Avatar + className={'absolute top-2 right-2'} + username={creator?.username} + avatarUrl={creator?.avatarUrl} + noLink={false} + size={12} + /> + </div> <Row className="items-center justify-between gap-2"> <span className="text-xl">{group.name}</span> </Row> - <div className="flex flex-col items-start justify-start gap-2 text-sm text-gray-500 "> - <Row> - {group.contractIds.length} questions - <div className={'mx-2'}>•</div> - <div className="mr-1">Created by</div> - <UserLink - className="text-neutral" - name={creator?.name ?? ''} - username={creator?.username ?? ''} - /> - </Row> - {group.memberIds.length > 1 && ( - <Row> - <GroupMembersList group={group} /> - </Row> - )} - </div> - <div className="text-sm text-gray-500">{group.about}</div> + <Row>{group.contractIds.length} questions</Row> + <Row className="text-sm text-gray-500"> + <GroupMembersList group={group} /> + </Row> + <Row> + <div className="text-sm text-gray-500">{group.about}</div> + </Row> + <Col className={'mt-2 h-full items-start justify-end'}> + <JoinOrLeaveGroupButton group={group} className={'z-10 w-24'} /> + </Col> </Col> ) } diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 12bd46a2..98d5036e 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -32,7 +32,7 @@ const Home = () => { <ContractSearch querySortOptions={{ shouldLoadFromStorage: true, - defaultSort: getSavedSort() ?? '24-hour-vol', + defaultSort: getSavedSort() ?? 'most-popular', }} onContractClick={(c) => { // Show contract without navigating to contract page. From a4e2cce4aab5232db21788175ed338ee7163a97c Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Wed, 13 Jul 2022 14:57:34 -0700 Subject: [PATCH 191/220] initial commit for manalinks UI improvements (#642) * manalinks UI improvements * made manalink look more like card * changed new link to pulsing indigo instead of green --- web/components/button.tsx | 37 +++ web/components/manalink-card.tsx | 41 +++ .../manalinks/create-links-button.tsx | 197 +++++++++++++++ web/components/subtitle.tsx | 15 ++ web/get-manalink-url.ts | 3 + web/pages/links.tsx | 236 ++++-------------- 6 files changed, 340 insertions(+), 189 deletions(-) create mode 100644 web/components/button.tsx create mode 100644 web/components/manalinks/create-links-button.tsx create mode 100644 web/components/subtitle.tsx create mode 100644 web/get-manalink-url.ts diff --git a/web/components/button.tsx b/web/components/button.tsx new file mode 100644 index 00000000..3b59581b --- /dev/null +++ b/web/components/button.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from 'react' +import clsx from 'clsx' + +export default function Button(props: { + className?: string + onClick?: () => void + color: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray' + children?: ReactNode + type?: 'button' | 'reset' | 'submit' +}) { + const { + className, + onClick, + children, + color = 'indigo', + type = 'button', + } = props + + return ( + <button + type={type} + className={clsx( + 'font-md items-center justify-center rounded-md border border-transparent px-4 py-2 shadow-sm hover:transition-colors', + color === 'green' && 'btn-primary text-white', + color === 'red' && 'bg-red-400 text-white hover:bg-red-500', + color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', + color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', + color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', + color === 'gray' && 'bg-gray-200 text-gray-700 hover:bg-gray-300', + className + )} + onClick={onClick} + > + {children} + </button> + ) +} diff --git a/web/components/manalink-card.tsx b/web/components/manalink-card.tsx index 97f5951c..fec05919 100644 --- a/web/components/manalink-card.tsx +++ b/web/components/manalink-card.tsx @@ -67,3 +67,44 @@ export function ManalinkCard(props: { </div> ) } + +export function ManalinkCardPreview(props: { + className?: string + info: ManalinkInfo + defaultMessage: string +}) { + const { className, defaultMessage, info } = props + const { expiresTime, maxUses, uses, amount, message } = info + return ( + <div + className={clsx( + className, + ' group flex flex-col rounded-lg bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all' + )} + > + <Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100"> + <div> + {maxUses != null + ? `${maxUses - uses}/${maxUses} uses left` + : `Unlimited use`} + </div> + <div> + {expiresTime != null + ? `Expires ${fromNow(expiresTime)}` + : 'Never expires'} + </div> + </Col> + + <img + className="my-2 block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12" + src="/logo-white.svg" + /> + <Row className="rounded-b-lg bg-white p-2"> + <Col className="text-md"> + <div className="mb-1 text-indigo-500">{formatMoney(amount)}</div> + <div className="text-xs">{message || defaultMessage}</div> + </Col> + </Row> + </div> + ) +} diff --git a/web/components/manalinks/create-links-button.tsx b/web/components/manalinks/create-links-button.tsx new file mode 100644 index 00000000..d74980cf --- /dev/null +++ b/web/components/manalinks/create-links-button.tsx @@ -0,0 +1,197 @@ +import clsx from 'clsx' +import { useState } from 'react' +import { Col } from '../layout/col' +import { Row } from '../layout/row' +import { Title } from '../title' +import { User } from 'common/user' +import { ManalinkCardPreview, 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' + +export function CreateLinksButton(props: { + user: User + highlightedSlug: string + setHighlightedSlug: (slug: string) => void +}) { + const { user, highlightedSlug, setHighlightedSlug } = props + const [open, setOpen] = useState(false) + + return ( + <> + <Modal open={open} setOpen={(newOpen) => setOpen(newOpen)}> + <Col className="gap-4 rounded-md bg-white px-8 py-6"> + <CreateManalinkForm + highlightedSlug={highlightedSlug} + user={user} + onCreate={async (newManalink) => { + const slug = await createManalink({ + fromId: user.id, + amount: newManalink.amount, + expiresTime: newManalink.expiresTime, + maxUses: newManalink.maxUses, + message: newManalink.message, + }) + setHighlightedSlug(slug || '') + }} + /> + </Col> + </Modal> + + <Button + color={'indigo'} + onClick={() => setOpen(true)} + className={clsx('whitespace-nowrap')} + > + Create a Manalink + </Button> + </> + ) +} + +function CreateManalinkForm(props: { + highlightedSlug: string + user: User + onCreate: (m: ManalinkInfo) => Promise<void> +}) { + const { user, onCreate, highlightedSlug } = props + const [isCreating, setIsCreating] = useState(false) + const [finishedCreating, setFinishedCreating] = useState(false) + const [copyPressed, setCopyPressed] = useState(false) + setTimeout(() => setCopyPressed(false), 300) + + const [newManalink, setNewManalink] = useState<ManalinkInfo>({ + expiresTime: null, + amount: 100, + maxUses: 1, + uses: 0, + message: '', + }) + + return ( + <> + {!finishedCreating && ( + <form + onSubmit={(e) => { + e.preventDefault() + setIsCreating(true) + onCreate(newManalink).finally(() => setIsCreating(false)) + setFinishedCreating(true) + }} + > + <Title className="!my-0" text="Create a Manalink" /> + <div className="flex flex-col flex-wrap gap-x-5 gap-y-2"> + <div className="form-control flex-auto"> + <label className="label">Amount</label> + <input + className="input input-bordered" + type="number" + value={newManalink.amount} + onChange={(e) => + setNewManalink((m) => { + return { ...m, amount: parseInt(e.target.value) } + }) + } + ></input> + </div> + <div className="flex flex-col gap-2 md:flex-row"> + <div className="form-control"> + <label className="label">Uses</label> + <input + className="input input-bordered w-full" + type="number" + value={newManalink.maxUses ?? ''} + onChange={(e) => + setNewManalink((m) => { + return { ...m, maxUses: parseInt(e.target.value) } + }) + } + ></input> + </div> + <div className="form-control"> + <label className="label">Expires in</label> + <input + value={ + newManalink.expiresTime != null + ? dayjs(newManalink.expiresTime).format( + 'YYYY-MM-DDTHH:mm' + ) + : '' + } + className="input input-bordered" + type="datetime-local" + onChange={(e) => { + setNewManalink((m) => { + return { + ...m, + expiresTime: e.target.value + ? dayjs(e.target.value, 'YYYY-MM-DDTHH:mm').valueOf() + : null, + } + }) + }} + ></input> + </div> + </div> + <div className="form-control w-full"> + <label className="label">Message</label> + <Textarea + placeholder={`From ${user.name}`} + className="input input-bordered resize-none" + autoFocus + value={newManalink.message} + rows="3" + onChange={(e) => + setNewManalink((m) => { + return { ...m, message: e.target.value } + }) + } + /> + </div> + </div> + <Button + type="submit" + color={'indigo'} + className={clsx( + 'mt-8 whitespace-nowrap drop-shadow-md', + isCreating ? 'disabled' : '' + )} + > + Create + </Button> + </form> + )} + {finishedCreating && ( + <> + <Title className="!my-0" text="Manalink Created!" /> + <ManalinkCardPreview + className="my-4" + defaultMessage={`From ${user.name}`} + info={newManalink} + /> + <Row + className={clsx( + 'rounded border bg-gray-50 py-2 px-3 text-sm text-gray-500 transition-colors duration-700', + copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : '' + )} + > + <div className="w-full select-text truncate"> + {getManalinkUrl(highlightedSlug)} + </div> + <DuplicateIcon + onClick={() => { + navigator.clipboard.writeText(getManalinkUrl(highlightedSlug)) + setCopyPressed(true) + }} + className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50" + /> + </Row> + </> + )} + </> + ) +} diff --git a/web/components/subtitle.tsx b/web/components/subtitle.tsx new file mode 100644 index 00000000..85d19778 --- /dev/null +++ b/web/components/subtitle.tsx @@ -0,0 +1,15 @@ +import clsx from 'clsx' + +export function Subtitle(props: { text: string; className?: string }) { + const { text, className } = props + return ( + <h1 + className={clsx( + 'mt-6 mb-2 inline-block text-lg text-indigo-500 sm:mt-6 sm:mb-2 sm:text-xl', + className + )} + > + {text} + </h1> + ) +} diff --git a/web/get-manalink-url.ts b/web/get-manalink-url.ts new file mode 100644 index 00000000..89a0c8a6 --- /dev/null +++ b/web/get-manalink-url.ts @@ -0,0 +1,3 @@ +export default function getManalinkUrl(slug: string) { + return `${location.protocol}//${location.host}/link/${slug}` +} diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 12cde274..ede997df 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -4,41 +4,29 @@ import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' import { Claim, Manalink } from 'common/manalink' import { formatMoney } from 'common/util/format' import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' +import { Subtitle } from 'web/components/subtitle' import { useUser } from 'web/hooks/use-user' -import { createManalink, useUserManalinks } from 'web/lib/firebase/manalinks' +import { useUserManalinks } from 'web/lib/firebase/manalinks' import { fromNow } from 'web/lib/util/time' import { useUserById } from 'web/hooks/use-users' import { ManalinkTxn } from 'common/txn' -import { User } from 'common/user' -import { Tabs } from 'web/components/layout/tabs' import { Avatar } from 'web/components/avatar' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { UserLink } from 'web/components/user-page' -import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card' - -import Textarea from 'react-expanding-textarea' +import { CreateLinksButton } from 'web/components/manalinks/create-links-button' import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' dayjs.extend(customParseFormat) -function getLinkUrl(slug: string) { +export function getManalinkUrl(slug: string) { return `${location.protocol}//${location.host}/link/${slug}` } -// TODO: incredibly gross, but the tab component is wrongly designed and -// keeps the tab state inside of itself, so this seems like the only -// way we can tell it to switch tabs from outside after initial render. -function setTabIndex(tabIndex: number) { - const tabHref = document.getElementById(`tab-${tabIndex}`) - if (tabHref) { - tabHref.click() - } -} - export default function LinkPage() { const user = useUser() const links = useUserManalinks(user?.id ?? '') @@ -58,166 +46,31 @@ export default function LinkPage() { <Page> <SEO title="Manalinks" - description="Send mana to anyone via link!" + description="Send M$ to others with a link, even if they don't have a Manifold account yet!" url="/send" /> <Col className="w-full px-8"> - <Title text="Manalinks" /> - <Tabs - labelClassName={'pb-2 pt-1 '} - defaultIndex={0} - tabs={[ - { - title: 'Create a link', - content: ( - <CreateManalinkForm - user={user} - onCreate={async (newManalink) => { - const slug = await createManalink({ - fromId: user.id, - amount: newManalink.amount, - expiresTime: newManalink.expiresTime, - maxUses: newManalink.maxUses, - message: newManalink.message, - }) - setTabIndex(1) - setHighlightedSlug(slug || '') - }} - /> - ), - }, - { - title: 'Unclaimed links', - content: ( - <LinksTable - links={unclaimedLinks} - highlightedSlug={highlightedSlug} - /> - ), - }, - // TODO: we have no use case for this atm and it's also really inefficient - // { - // title: 'Claimed', - // content: <ClaimsList txns={manalinkTxns} />, - // }, - ]} - /> + <Row className="items-center justify-between"> + <Title text="Manalinks" /> + {user && ( + <CreateLinksButton + user={user} + highlightedSlug={highlightedSlug} + setHighlightedSlug={setHighlightedSlug} + /> + )} + </Row> + <p> + You can use manalinks to send mana to other people, even if they + don't yet have a Manifold account. + </p> + <Subtitle text="Your Manalinks" /> + <LinksTable links={unclaimedLinks} highlightedSlug={highlightedSlug} /> </Col> </Page> ) } -function CreateManalinkForm(props: { - user: User - onCreate: (m: ManalinkInfo) => Promise<void> -}) { - const { user, onCreate } = props - const [isCreating, setIsCreating] = useState(false) - const [newManalink, setNewManalink] = useState<ManalinkInfo>({ - expiresTime: null, - amount: 100, - maxUses: 1, - uses: 0, - message: '', - }) - return ( - <> - <p> - You can use manalinks to send mana to other people, even if they - don't yet have a Manifold account. - </p> - <form - className="my-5" - onSubmit={(e) => { - e.preventDefault() - setIsCreating(true) - onCreate(newManalink).finally(() => setIsCreating(false)) - }} - > - <div className="flex flex-row flex-wrap gap-x-5 gap-y-2"> - <div className="form-control flex-auto"> - <label className="label">Amount</label> - <input - className="input" - type="number" - value={newManalink.amount} - onChange={(e) => - setNewManalink((m) => { - return { ...m, amount: parseInt(e.target.value) } - }) - } - ></input> - </div> - <div className="form-control flex-auto"> - <label className="label">Uses</label> - <input - className="input" - type="number" - value={newManalink.maxUses ?? ''} - onChange={(e) => - setNewManalink((m) => { - return { ...m, maxUses: parseInt(e.target.value) } - }) - } - ></input> - </div> - <div className="form-control flex-auto"> - <label className="label">Expires at</label> - <input - value={ - newManalink.expiresTime != null - ? dayjs(newManalink.expiresTime).format('YYYY-MM-DDTHH:mm') - : '' - } - className="input" - type="datetime-local" - onChange={(e) => { - setNewManalink((m) => { - return { - ...m, - expiresTime: e.target.value - ? dayjs(e.target.value, 'YYYY-MM-DDTHH:mm').valueOf() - : null, - } - }) - }} - ></input> - </div> - </div> - <div className="form-control w-full"> - <label className="label">Message</label> - <Textarea - placeholder={`From ${user.name}`} - className="input input-bordered resize-none" - autoFocus - value={newManalink.message} - onChange={(e) => - setNewManalink((m) => { - return { ...m, message: e.target.value } - }) - } - /> - </div> - <button - type="submit" - className={clsx('btn mt-5', isCreating ? 'loading disabled' : '')} - > - {isCreating ? '' : 'Create'} - </button> - </form> - - <Title text="Preview" /> - <p>This is what the person you send the link to will see:</p> - <ManalinkCard - className="my-5" - defaultMessage={`From ${user.name}`} - info={newManalink} - isClaiming={false} - /> - </> - ) -} - export function ClaimsList(props: { txns: ManalinkTxn[] }) { const { txns } = props return ( @@ -334,8 +187,8 @@ function LinkSummaryRow(props: { }) { const { link, highlight, expanded, onToggle } = props const className = clsx( - 'whitespace-nowrap text-sm hover:cursor-pointer', - highlight ? 'bg-primary' : 'text-gray-500 hover:bg-sky-50 bg-white' + 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white', + highlight ? 'bg-indigo-100 rounded-lg animate-pulse' : '' ) return ( <tr id={link.slug} key={link.slug} className={className}> @@ -350,7 +203,7 @@ function LinkSummaryRow(props: { <td className="px-5 py-4 font-medium text-gray-900"> {formatMoney(link.amount)} </td> - <td className="px-5 py-4">{getLinkUrl(link.slug)}</td> + <td className="px-5 py-4">{getManalinkUrl(link.slug)}</td> <td className="px-5 py-4">{link.claimedUserIds.length}</td> <td className="px-5 py-4">{link.maxUses == null ? '∞' : link.maxUses}</td> <td className="px-5 py-4"> @@ -365,22 +218,27 @@ function LinksTable(props: { links: Manalink[]; highlightedSlug?: string }) { return links.length == 0 ? ( <p>You don't currently have any outstanding manalinks.</p> ) : ( - <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> - <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> - <tr> - <th></th> - <th className="px-5 py-3.5">Amount</th> - <th className="px-5 py-3.5">Link</th> - <th className="px-5 py-3.5">Uses</th> - <th className="px-5 py-3.5">Max Uses</th> - <th className="px-5 py-3.5">Expires</th> - </tr> - </thead> - <tbody className="divide-y divide-gray-200 bg-white"> - {links.map((link) => ( - <LinkTableRow link={link} highlight={link.slug === highlightedSlug} /> - ))} - </tbody> - </table> + <div className="overflow-scroll"> + <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> + <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> + <tr> + <th></th> + <th className="px-5 py-3.5">Amount</th> + <th className="px-5 py-3.5">Link</th> + <th className="px-5 py-3.5">Uses</th> + <th className="px-5 py-3.5">Max Uses</th> + <th className="px-5 py-3.5">Expires</th> + </tr> + </thead> + <tbody className="divide-y divide-gray-200 bg-white"> + {links.map((link) => ( + <LinkTableRow + link={link} + highlight={link.slug === highlightedSlug} + /> + ))} + </tbody> + </table> + </div> ) } From f08d6bda930bae2f7071edec4d9d57a8a723f64a Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 13 Jul 2022 15:14:06 -0700 Subject: [PATCH 192/220] when adding package, don't put ^ before version (#645) --- common/.yarnrc | 1 + functions/.yarnrc | 1 + web/.yarnrc | 1 + 3 files changed, 3 insertions(+) create mode 100644 common/.yarnrc create mode 100644 functions/.yarnrc create mode 100644 web/.yarnrc diff --git a/common/.yarnrc b/common/.yarnrc new file mode 100644 index 00000000..fdd705c6 --- /dev/null +++ b/common/.yarnrc @@ -0,0 +1 @@ +save-prefix "" diff --git a/functions/.yarnrc b/functions/.yarnrc new file mode 100644 index 00000000..fdd705c6 --- /dev/null +++ b/functions/.yarnrc @@ -0,0 +1 @@ +save-prefix "" diff --git a/web/.yarnrc b/web/.yarnrc new file mode 100644 index 00000000..fdd705c6 --- /dev/null +++ b/web/.yarnrc @@ -0,0 +1 @@ +save-prefix "" From 7a49549389ed36b19dc81a8fde3495009efeb36d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 13 Jul 2022 16:20:56 -0600 Subject: [PATCH 193/220] Ignore rankings/members for huge groups for now --- web/components/contract-search.tsx | 5 +- web/hooks/use-group.ts | 12 +++-- web/pages/group/[...slugs]/index.tsx | 81 +++++++++------------------- 3 files changed, 35 insertions(+), 63 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 834a4db1..952d4034 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -57,6 +57,7 @@ export function ContractSearch(props: { creatorId?: string tag?: string excludeContractIds?: string[] + groupSlug?: string } onContractClick?: (contract: Contract) => void showPlaceHolder?: boolean @@ -79,7 +80,6 @@ export function ContractSearch(props: { ?.map((g) => g.slug) .filter((s) => !NEW_USER_GROUP_SLUGS.includes(s)) const follows = useFollows(user?.id) - console.log(memberGroupSlugs, follows) const { initialSort } = useInitialQueryAndSort(querySortOptions) const sort = sortIndexes @@ -112,6 +112,9 @@ export function ContractSearch(props: { additionalFilter?.creatorId ? `creatorId:${additionalFilter.creatorId}` : '', + additionalFilter?.groupSlug + ? `groupSlugs:${additionalFilter.groupSlug}` + : '', ].filter((f) => f) // Hack to make Algolia work. filters = ['', ...filters] diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 1fde9f4e..8af2183e 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -75,19 +75,21 @@ export const useMemberGroupIds = (user: User | null | undefined) => { return memberGroupIds } -export function useMembers(group: Group) { +export function useMembers(group: Group, max?: number) { const [members, setMembers] = useState<User[]>([]) useEffect(() => { const { memberIds } = group if (memberIds.length > 0) { - listMembers(group).then((members) => setMembers(members)) + listMembers(group, max).then((members) => setMembers(members)) } - }, [group]) + }, [group, max]) return members } -export async function listMembers(group: Group) { - return await Promise.all(group.memberIds.map(getUser)) +export async function listMembers(group: Group, max?: number) { + return await Promise.all( + group.memberIds.slice(0, max ? max : group.memberIds.length).map(getUser) + ) } export const useGroupsWithContract = (contractId: string | undefined) => { diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index fc76df48..91e6f998 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -7,10 +7,10 @@ import { Contract } from 'web/lib/firebase/contracts' import { groupPath, getGroupBySlug, - getGroupContracts, updateGroup, addUserToGroup, addContractToGroup, + getGroupContracts, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' @@ -33,7 +33,6 @@ import { SEO } from 'web/components/SEO' import { Linkify } from 'web/components/linkify' import { fromPropz, usePropz } from 'web/hooks/use-propz' import { Tabs } from 'web/components/layout/tabs' -import { ContractsGrid } from 'web/components/contract/contracts-list' import { createButtonStyle, CreateQuestionButton, @@ -42,13 +41,15 @@ import React, { useEffect, useState } from 'react' import { GroupChat } from 'web/components/groups/group-chat' import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' -import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' +import { + checkAgainstQuery, + getSavedSort, +} from 'web/hooks/use-sort-and-query-params' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' import { useCommentsOnGroup } from 'web/hooks/use-comments' import { ShareIconButton } from 'web/components/share-icon-button' import { REFERRAL_AMOUNT } from 'common/user' -import { SiteLink } from 'web/components/site-link' import { ContractSearch } from 'web/components/contract-search' import clsx from 'clsx' import { FollowList } from 'web/components/follow-list' @@ -61,10 +62,14 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const { slugs } = props.params const group = await getGroupBySlug(slugs[0]) - const members = group ? await listMembers(group) : [] + const members = + group && group.memberIds.length < 100 ? await listMembers(group) : [] const creatorPromise = group ? getUser(group.creatorId) : null - const contracts = group ? await getGroupContracts(group).catch((_) => []) : [] + const contracts = + group && group.contractIds.length < 100 + ? await getGroupContracts(group).catch((_) => []) + : [] const bets = await Promise.all( contracts.map((contract: Contract) => listAllBets(contract.id)) @@ -149,26 +154,9 @@ export default function GroupPage(props: { const page = slugs?.[1] as typeof groupSubpages[number] const group = useGroup(props.group?.id) ?? props.group - const [contracts, setContracts] = useState<Contract[] | undefined>(undefined) - const [query, setQuery] = useState('') const tips = useTipTxns({ groupId: group?.id }) const messages = useCommentsOnGroup(group?.id) - const debouncedQuery = debounce(setQuery, 50) - const filteredContracts = - query != '' && contracts - ? contracts.filter( - (c) => - checkAgainstQuery(query, c.question) || - checkAgainstQuery(query, c.creatorName) || - checkAgainstQuery(query, c.creatorUsername) - ) - : [] - - useEffect(() => { - if (group) - getGroupContracts(group).then((contracts) => setContracts(contracts)) - }, [group]) const user = useUser() useEffect(() => { @@ -237,37 +225,14 @@ export default function GroupPage(props: { { title: 'Questions', content: ( - <div className={'mt-2 px-1'}> - {contracts ? ( - contracts.length > 0 ? ( - <> - <input - type="text" - onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Search the group's questions" - className="input input-bordered mb-4 w-full" - /> - <ContractsGrid - contracts={query != '' ? filteredContracts : contracts} - hasMore={false} - loadMore={() => {}} - /> - </> - ) : ( - <div className="p-2 text-gray-500"> - No questions yet. Why not{' '} - <SiteLink - href={`/create/?groupId=${group.id}`} - className={'font-bold text-gray-700'} - > - add one? - </SiteLink> - </div> - ) - ) : ( - <LoadingIndicator /> - )} - </div> + <ContractSearch + querySortOptions={{ + shouldLoadFromStorage: true, + defaultSort: getSavedSort() ?? 'newest', + defaultFilter: 'open', + }} + additionalFilter={{ groupSlug: group.slug }} + /> ), href: groupPath(group.slug, 'questions'), }, @@ -491,8 +456,10 @@ function GroupMemberSearch(props: { group: Group }) { export function GroupMembersList(props: { group: Group }) { const { group } = props - const members = useMembers(group).filter((m) => m.id !== group.creatorId) const maxMembersToShow = 3 + const members = useMembers(group, maxMembersToShow).filter( + (m) => m.id !== group.creatorId + ) if (group.memberIds.length === 1) return <div /> return ( <div className="text-neutral flex flex-wrap gap-1"> @@ -503,8 +470,8 @@ export function GroupMembersList(props: { group: Group }) { {members.length > 1 && i !== members.length - 1 && <span>,</span>} </div> ))} - {members.length > maxMembersToShow && ( - <span> & {members.length - maxMembersToShow} more</span> + {group.memberIds.length > maxMembersToShow && ( + <span> & {group.memberIds.length - maxMembersToShow} more</span> )} </div> ) From e1b6619e9c0a593db8ec0f06d6027b172fb4c9cc Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 13 Jul 2022 17:22:22 -0500 Subject: [PATCH 194/220] embeds: don't show bet button after resolution --- web/pages/embed/[username]/[contractSlug].tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index dc8cb51d..57189c0c 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -22,6 +22,7 @@ import { fromPropz, usePropz } from 'web/hooks/use-propz' import { useWindowSize } from 'web/hooks/use-window-size' import { listAllBets } from 'web/lib/firebase/bets' import { contractPath, getContractFromSlug } from 'web/lib/firebase/contracts' +import { tradingAllowed } from 'web/lib/firebase/contracts' import Custom404 from '../../404' export const getStaticProps = fromPropz(getStaticPropz) @@ -112,17 +113,21 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { {isBinary && ( <Row className="items-center gap-4"> - <BetRow - contract={contract as CPMMBinaryContract} - betPanelClassName="scale-75" - /> + {tradingAllowed(contract) && ( + <BetRow + contract={contract as CPMMBinaryContract} + betPanelClassName="scale-75" + /> + )} <BinaryResolutionOrChance contract={contract} /> </Row> )} {isPseudoNumeric && ( <Row className="items-center gap-4"> - <BetRow contract={contract} betPanelClassName="scale-75" /> + {tradingAllowed(contract) && ( + <BetRow contract={contract} betPanelClassName="scale-75" /> + )} <PseudoNumericResolutionOrExpectation contract={contract} /> </Row> )} From 45fb3803c1c31ed45724fb52d14e4b67111deede Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 13 Jul 2022 16:24:35 -0600 Subject: [PATCH 195/220] Limit member search to 100 --- web/pages/group/[...slugs]/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 91e6f998..43647cdb 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -428,7 +428,8 @@ function SearchBar(props: { setQuery: (query: string) => void }) { function GroupMemberSearch(props: { group: Group }) { const [query, setQuery] = useState('') - const members = useMembers(props.group) + const { group } = props + const members = useMembers(group, 100) // TODO use find-active-contracts to sort by? const matches = sortBy(members, [(member) => member.name]).filter( From 664e55a40bc3369445ff2753ce82228e219c980a Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 13 Jul 2022 15:56:15 -0700 Subject: [PATCH 196/220] Add typing, pasting links (#646) --- common/package.json | 1 + common/util/parse.ts | 2 ++ functions/package.json | 1 + web/components/editor.tsx | 10 ++++++++-- web/package.json | 1 + yarn.lock | 14 ++++++++++++++ 6 files changed, 27 insertions(+), 2 deletions(-) diff --git a/common/package.json b/common/package.json index 25992cb6..6f0f5b29 100644 --- a/common/package.json +++ b/common/package.json @@ -9,6 +9,7 @@ "sideEffects": false, "dependencies": { "@tiptap/extension-image": "2.0.0-beta.30", + "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/starter-kit": "2.0.0-beta.190", "lodash": "4.17.21" }, diff --git a/common/util/parse.ts b/common/util/parse.ts index 48b68fdd..94b5ab7f 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -19,6 +19,7 @@ import { Strike } from '@tiptap/extension-strike' import { Text } from '@tiptap/extension-text' // other tiptap extensions import { Image } from '@tiptap/extension-image' +import { Link } from '@tiptap/extension-link' export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi @@ -68,6 +69,7 @@ export const exhibitExts = [ Text, Image, + Link, ] // export const exhibitExts = [StarterKit as unknown as Extension, Image] diff --git a/functions/package.json b/functions/package.json index d7ebb663..f8657516 100644 --- a/functions/package.json +++ b/functions/package.json @@ -26,6 +26,7 @@ "@google-cloud/functions-framework": "3.1.2", "@tiptap/core": "2.0.0-beta.181", "@tiptap/extension-image": "2.0.0-beta.30", + "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/starter-kit": "2.0.0-beta.190", "firebase-admin": "10.0.0", "firebase-functions": "3.21.2", diff --git a/web/components/editor.tsx b/web/components/editor.tsx index bd4d97c0..3ec0663b 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -10,6 +10,7 @@ import { } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import { Image } from '@tiptap/extension-image' +import { Link } from '@tiptap/extension-link' import clsx from 'clsx' import { useEffect } from 'react' import { Linkify } from './linkify' @@ -18,8 +19,12 @@ import { useMutation } from 'react-query' import { exhibitExts } from 'common/util/parse' import { FileUploadButton } from './file-upload-button' -const proseClass = - 'prose prose-sm prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none' +const proseClass = clsx( + 'prose prose-sm prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none', + // link styles mostly copied from site-link.ts + 'prose-a:no-underline prose-a:!text-indigo-700', + 'prose-a:z-10 prose-a:break-words hover:prose-a:underline hover:prose-a:decoration-indigo-400 prose-a:hover:decoration-2' +) export function useTextEditor(props: { placeholder?: string @@ -47,6 +52,7 @@ export function useTextEditor(props: { }), CharacterCount.configure({ limit: max }), Image, + Link, ], content: defaultValue, }) diff --git a/web/package.json b/web/package.json index f81950bf..f8e1881b 100644 --- a/web/package.json +++ b/web/package.json @@ -26,6 +26,7 @@ "@react-query-firebase/firestore": "0.4.2", "@tiptap/extension-character-count": "2.0.0-beta.31", "@tiptap/extension-image": "2.0.0-beta.30", + "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-placeholder": "2.0.0-beta.53", "@tiptap/react": "2.0.0-beta.114", "@tiptap/starter-kit": "2.0.0-beta.190", diff --git a/yarn.lock b/yarn.lock index e84f3616..6fcdf53a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2984,6 +2984,15 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.0.0-beta.28.tgz#bf88ecae64c8f2f69f1f508b802c1efd7454a84e" integrity sha512-/pKRiCfewh7nqiXRD3N4hQHfGrGNOiWPFYZfY35bSpvTms7PDb/MF7xT1CWW23hSpY31BBS+R/a66vlR/gqu7Q== +"@tiptap/extension-link@2.0.0-beta.43": + version "2.0.0-beta.43" + resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-2.0.0-beta.43.tgz#c123a2170dd50d075b9fe7fb91d86d23f778ffb0" + integrity sha512-AYueqfTW713KGVfWSWhVbj4ObeWudgawikm3m0uYcKSdsAz/CfEvOD2/NA0uyQzlxmYLA6Pf8HMxoKGN+O4Cmg== + dependencies: + linkifyjs "^3.0.5" + prosemirror-model "1.18.1" + prosemirror-state "1.4.1" + "@tiptap/extension-list-item@^2.0.0-beta.23": version "2.0.0-beta.23" resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.0.0-beta.23.tgz#6d1ac7235462b0bcee196f42bb1871669480b843" @@ -7769,6 +7778,11 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +linkifyjs@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-3.0.5.tgz#99e51a3a0c0e232fcb63ebb89eea3ff923378f34" + integrity sha512-1Y9XQH65eQKA9p2xtk+zxvnTeQBG7rdAXSkUG97DmuI/Xhji9uaUzaWxRj6rf9YC0v8KKHkxav7tnLX82Sz5Fg== + loader-runner@^4.2.0: version "4.3.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" From 98192ee58069ed0bbd823753e4c503da82910fef Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 13 Jul 2022 16:12:19 -0700 Subject: [PATCH 197/220] simplify rich text link styles --- web/components/editor.tsx | 6 ++---- web/components/site-link.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 3ec0663b..41c2b80a 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -18,12 +18,10 @@ import { uploadImage } from 'web/lib/firebase/storage' import { useMutation } from 'react-query' import { exhibitExts } from 'common/util/parse' import { FileUploadButton } from './file-upload-button' +import { linkClass } from './site-link' const proseClass = clsx( 'prose prose-sm prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none', - // link styles mostly copied from site-link.ts - 'prose-a:no-underline prose-a:!text-indigo-700', - 'prose-a:z-10 prose-a:break-words hover:prose-a:underline hover:prose-a:decoration-indigo-400 prose-a:hover:decoration-2' ) export function useTextEditor(props: { @@ -52,7 +50,7 @@ export function useTextEditor(props: { }), CharacterCount.configure({ limit: max }), Image, - Link, + Link.configure({ HTMLAttributes: { class: clsx('no-underline !text-indigo-700', linkClass)}}), ], content: defaultValue, }) diff --git a/web/components/site-link.tsx b/web/components/site-link.tsx index 8137eb08..ee12d519 100644 --- a/web/components/site-link.tsx +++ b/web/components/site-link.tsx @@ -2,6 +2,9 @@ import clsx from 'clsx' import { ReactNode } from 'react' import Link from 'next/link' +export const linkClass = + 'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2' + export const SiteLink = (props: { href: string children?: ReactNode @@ -13,10 +16,7 @@ export const SiteLink = (props: { return ( <MaybeLink href={href}> <a - className={clsx( - 'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2', - className - )} + className={clsx(linkClass, className)} href={href} target={href.startsWith('http') ? '_blank' : undefined} style={{ /* For iOS safari */ wordBreak: 'break-word' }} From 9240cd3d1c533402f0431d7ca5fb78bc8781c6d0 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 13 Jul 2022 18:23:03 -0500 Subject: [PATCH 198/220] Bet panel: Quick vs Limit pill buttons. Also, pill buttons for Yes vs No. --- web/components/bet-panel.tsx | 67 +++++++++++++++++--------- web/components/buttons/pill-button.tsx | 27 +++++++++++ web/components/yes-no-selector.tsx | 62 ------------------------ 3 files changed, 71 insertions(+), 85 deletions(-) create mode 100644 web/components/buttons/pill-button.tsx diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index c8356a06..bd2bfebb 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -8,7 +8,6 @@ import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' -import { YesNoSelector } from './yes-no-selector' import { formatMoney, formatMoneyWithDecimals, @@ -41,6 +40,7 @@ import { removeUndefinedProps } from 'common/util/object' import { useUnfilledBets } from 'web/hooks/use-bets' import { LimitBets } from './limit-bets' import { BucketInput } from './bucket-input' +import { PillButton } from './buttons/pill-button' export function BetPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract @@ -54,10 +54,6 @@ export function BetPanel(props: { const { sharesOutcome } = useSaveBinaryShares(contract, userBets) const [isLimitOrder, setIsLimitOrder] = useState(false) - const toggleLimitOrder = () => { - setIsLimitOrder(!isLimitOrder) - track('toggle limit order') - } const showLimitOrders = (isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0 @@ -71,21 +67,33 @@ export function BetPanel(props: { /> <Col className={clsx( - 'relative rounded-b-md bg-white px-8 py-6', + 'relative rounded-b-md bg-white px-6 py-6', !sharesOutcome && 'rounded-t-md', className )} > - <Row className="align-center justify-between"> - <div className="mb-6 text-2xl"> - {isLimitOrder ? <>Limit order</> : <>Place your bet</>} - </div> - <button - className="btn btn-ghost btn-sm text-sm normal-case" - onClick={toggleLimitOrder} - > - <SwitchHorizontalIcon className="inline h-6 w-6" /> - </button> + <Row className="align-center mb-4 justify-between"> + <div className="text-4xl">Bet</div> + <Row className="mt-2 items-center gap-2"> + <PillButton + selected={!isLimitOrder} + onSelect={() => { + setIsLimitOrder(false) + track('select quick order') + }} + > + Quick + </PillButton> + <PillButton + selected={isLimitOrder} + onSelect={() => { + setIsLimitOrder(true) + track('select limit order') + }} + > + Limit + </PillButton> + </Row> </Row> <BuyPanel @@ -287,13 +295,26 @@ function BuyPanel(props: { return ( <> - <YesNoSelector - className="mb-4" - btnClassName="flex-1" - selected={betChoice} - onSelect={(choice) => onBetChoice(choice)} - isPseudoNumeric={isPseudoNumeric} - /> + <div className="my-3 text-left text-sm text-gray-500">Direction</div> + <Row className="mb-4 items-center gap-2"> + <PillButton + selected={betChoice === 'YES'} + onSelect={() => onBetChoice('YES')} + big + color="bg-primary" + > + {isPseudoNumeric ? 'Higher' : 'Yes'} + </PillButton> + <PillButton + selected={betChoice === 'NO'} + onSelect={() => onBetChoice('NO')} + big + color="bg-red-400" + > + {isPseudoNumeric ? 'Lower' : 'No'} + </PillButton> + </Row> + <div className="my-3 text-left text-sm text-gray-500">Amount</div> <BuyAmountInput inputClassName="w-full max-w-none" diff --git a/web/components/buttons/pill-button.tsx b/web/components/buttons/pill-button.tsx new file mode 100644 index 00000000..796036d1 --- /dev/null +++ b/web/components/buttons/pill-button.tsx @@ -0,0 +1,27 @@ +import clsx from 'clsx' +import { ReactNode } from 'react' + +export function PillButton(props: { + selected: boolean + onSelect: () => void + color?: string + big?: boolean + children: ReactNode +}) { + const { children, selected, onSelect, color, big } = props + + return ( + <button + className={clsx( + 'cursor-pointer select-none rounded-full', + selected + ? ['text-white', color ?? 'bg-gray-700'] + : 'bg-gray-100 hover:bg-gray-200', + big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm' + )} + onClick={onSelect} + > + {children} + </button> + ) +} diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index 3b3cc21d..dda97c0c 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -5,68 +5,6 @@ import { Col } from './layout/col' import { Row } from './layout/row' import { resolution } from 'common/contract' -export function YesNoSelector(props: { - selected?: 'YES' | 'NO' - onSelect: (selected: 'YES' | 'NO') => void - className?: string - btnClassName?: string - replaceYesButton?: React.ReactNode - replaceNoButton?: React.ReactNode - isPseudoNumeric?: boolean -}) { - const { - selected, - onSelect, - className, - btnClassName, - replaceNoButton, - replaceYesButton, - isPseudoNumeric, - } = props - - const commonClassNames = - 'inline-flex items-center justify-center rounded-3xl border-2 p-2' - - return ( - <Row className={clsx('space-x-3', className)}> - {replaceYesButton ? ( - replaceYesButton - ) : ( - <button - className={clsx( - commonClassNames, - 'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white', - selected == 'YES' - ? 'bg-primary text-white' - : 'text-primary bg-transparent', - btnClassName - )} - onClick={() => onSelect('YES')} - > - {isPseudoNumeric ? 'HIGHER' : 'YES'} - </button> - )} - {replaceNoButton ? ( - replaceNoButton - ) : ( - <button - className={clsx( - commonClassNames, - 'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white', - selected == 'NO' - ? 'bg-red-400 text-white' - : 'bg-transparent text-red-400', - btnClassName - )} - onClick={() => onSelect('NO')} - > - {isPseudoNumeric ? 'LOWER' : 'NO'} - </button> - )} - </Row> - ) -} - export function YesNoCancelSelector(props: { selected: resolution | undefined onSelect: (selected: resolution) => void From 67b345092400005784180c5f43dd5a82c51621a2 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 13 Jul 2022 18:28:33 -0500 Subject: [PATCH 199/220] Use quick vs limit bet in mobile dialog --- web/components/bet-panel.tsx | 79 +++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index bd2bfebb..26d2d974 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,7 +1,6 @@ import clsx from 'clsx' import React, { useEffect, useState } from 'react' import { partition, sum, sumBy } from 'lodash' -import { SwitchHorizontalIcon } from '@heroicons/react/solid' import { useUser } from 'web/hooks/use-user' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' @@ -72,30 +71,10 @@ export function BetPanel(props: { className )} > - <Row className="align-center mb-4 justify-between"> - <div className="text-4xl">Bet</div> - <Row className="mt-2 items-center gap-2"> - <PillButton - selected={!isLimitOrder} - onSelect={() => { - setIsLimitOrder(false) - track('select quick order') - }} - > - Quick - </PillButton> - <PillButton - selected={isLimitOrder} - onSelect={() => { - setIsLimitOrder(true) - track('select limit order') - }} - > - Limit - </PillButton> - </Row> - </Row> - + <QuickOrLimitBet + isLimitOrder={isLimitOrder} + setIsLimitOrder={setIsLimitOrder} + /> <BuyPanel contract={contract} user={user} @@ -142,19 +121,10 @@ export function SimpleBetPanel(props: { 'rounded-b-md bg-white px-8 py-6' )} > - <Row className="justify-between"> - <div className="mb-6 text-2xl"> - {isLimitOrder ? <>Limit order</> : <>Place your bet</>} - </div> - - <button - className="btn btn-ghost btn-sm text-sm normal-case" - onClick={() => setIsLimitOrder(!isLimitOrder)} - > - <SwitchHorizontalIcon className="inline h-6 w-6" /> - </button> - </Row> - + <QuickOrLimitBet + isLimitOrder={isLimitOrder} + setIsLimitOrder={setIsLimitOrder} + /> <BuyPanel contract={contract} user={user} @@ -427,6 +397,39 @@ function BuyPanel(props: { ) } +function QuickOrLimitBet(props: { + isLimitOrder: boolean + setIsLimitOrder: (isLimitOrder: boolean) => void +}) { + const { isLimitOrder, setIsLimitOrder } = props + + return ( + <Row className="align-center mb-4 justify-between"> + <div className="text-4xl">Bet</div> + <Row className="mt-2 items-center gap-2"> + <PillButton + selected={!isLimitOrder} + onSelect={() => { + setIsLimitOrder(false) + track('select quick order') + }} + > + Quick + </PillButton> + <PillButton + selected={isLimitOrder} + onSelect={() => { + setIsLimitOrder(true) + track('select limit order') + }} + > + Limit + </PillButton> + </Row> + </Row> + ) +} + export function SellPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract userBets: Bet[] From f4b7b9efd0901ea6fe45e565855d43be0c0f7f61 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 13 Jul 2022 18:39:32 -0500 Subject: [PATCH 200/220] Only show probabilty update with arrow if probability changes --- web/components/bet-panel.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 26d2d974..8343d696 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -250,6 +250,9 @@ function BuyPanel(props: { ) const resultProb = getCpmmProbability(newPool, newP) + const probStayedSame = + formatPercent(resultProb) === formatPercent(initialProb) + const remainingMatched = isLimitOrder ? ((newBet.orderAmount ?? 0) - newBet.amount) / (betChoice === 'YES' ? limitProbFrac : 1 - limitProbFrac) @@ -339,11 +342,15 @@ function BuyPanel(props: { <div className="text-gray-500"> {isPseudoNumeric ? 'Estimated value' : 'Probability'} </div> - <div> - {format(initialProb)} - <span className="mx-2">→</span> - {format(resultProb)} - </div> + {probStayedSame ? ( + <div>{format(initialProb)}</div> + ) : ( + <div> + {format(initialProb)} + <span className="mx-2">→</span> + {format(resultProb)} + </div> + )} </Row> )} From 095af10d4fb4bc607a7a37e04cd3faf6dacdeeb7 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 13 Jul 2022 16:50:08 -0700 Subject: [PATCH 201/220] replace raw checkbox w/ Checkbox component also run prettier --- web/components/checkbox.tsx | 6 ++++-- web/components/editor.tsx | 8 ++++++-- web/pages/create.tsx | 17 ++++++++--------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/web/components/checkbox.tsx b/web/components/checkbox.tsx index 9cc7cae9..22a867b2 100644 --- a/web/components/checkbox.tsx +++ b/web/components/checkbox.tsx @@ -5,12 +5,13 @@ export function Checkbox(props: { checked: boolean toggle: (checked: boolean) => void className?: string + disabled?: boolean }) { - const { label, checked, toggle, className } = props + const { label, checked, toggle, className, disabled } = props return ( <div className={clsx(className, 'space-y-5')}> - <div className="relative flex items-start"> + <div className="relative flex items-center"> <div className="flex h-6 items-center"> <input id={label} @@ -18,6 +19,7 @@ export function Checkbox(props: { className="h-5 w-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" checked={checked} onChange={(e) => toggle(!e.target.checked)} + disabled={disabled} /> </div> <div className="ml-3"> diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 41c2b80a..4b3e2cce 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -21,7 +21,7 @@ import { FileUploadButton } from './file-upload-button' import { linkClass } from './site-link' const proseClass = clsx( - 'prose prose-sm prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none', + 'prose prose-sm prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none' ) export function useTextEditor(props: { @@ -50,7 +50,11 @@ export function useTextEditor(props: { }), CharacterCount.configure({ limit: max }), Image, - Link.configure({ HTMLAttributes: { class: clsx('no-underline !text-indigo-700', linkClass)}}), + Link.configure({ + HTMLAttributes: { + class: clsx('no-underline !text-indigo-700', linkClass), + }, + }), ], content: defaultValue, }) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 7040dff0..8d4c3662 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -27,6 +27,7 @@ import { track } from 'web/lib/service/analytics' import { GroupSelector } from 'web/components/groups/group-selector' import { User } from 'common/user' import { TextEditor, useTextEditor } from 'web/components/editor' +import { Checkbox } from 'web/components/checkbox' type NewQuestionParams = { groupId?: string @@ -294,15 +295,13 @@ export function NewContract(props: { </Row> {!(min !== undefined && min < 0) && ( - <Row className="mt-1 ml-2 mb-2 items-center"> - <span className="mr-2 text-sm">Log scale</span>{' '} - <input - type="checkbox" - checked={isLogScale} - onChange={() => setIsLogScale(!isLogScale)} - disabled={isSubmitting} - /> - </Row> + <Checkbox + className="my-2 text-sm" + label="Log scale" + checked={isLogScale} + toggle={() => setIsLogScale(!isLogScale)} + disabled={isSubmitting} + /> )} {min !== undefined && max !== undefined && min >= max && ( From 5ebd4498a0780f17a9d6f39ed174abf85c7e72c7 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 13 Jul 2022 17:43:20 -0700 Subject: [PATCH 202/220] Remove deprecated useUserById implementation (#571) * Remove duplicate useUserById implementation * fix bug: firebase doesn't accept empty paths --- web/components/charity/feed-items.tsx | 2 +- web/hooks/use-users.ts | 13 ------------- web/pages/[username]/[contractSlug].tsx | 2 +- web/pages/link/[slug].tsx | 4 ++-- web/pages/links.tsx | 2 +- 5 files changed, 5 insertions(+), 18 deletions(-) diff --git a/web/components/charity/feed-items.tsx b/web/components/charity/feed-items.tsx index 6e6def00..365aa606 100644 --- a/web/components/charity/feed-items.tsx +++ b/web/components/charity/feed-items.tsx @@ -1,6 +1,6 @@ import { DonationTxn } from 'common/txn' import { Avatar } from '../avatar' -import { useUserById } from 'web/hooks/use-users' +import { useUserById } from 'web/hooks/use-user' import { UserLink } from '../user-page' import { manaToUSD } from '../../../common/util/format' import { RelativeTimestamp } from '../relative-timestamp' diff --git a/web/hooks/use-users.ts b/web/hooks/use-users.ts index 1a527659..1312444e 100644 --- a/web/hooks/use-users.ts +++ b/web/hooks/use-users.ts @@ -1,7 +1,6 @@ import { useState, useEffect } from 'react' import { PrivateUser, User } from 'common/user' import { - getUser, listenForAllUsers, listenForPrivateUsers, } from 'web/lib/firebase/users' @@ -20,18 +19,6 @@ export const useUsers = () => { return users } -export const useUserById = (userId?: string) => { - const [user, setUser] = useState<User | undefined>(undefined) - - useEffect(() => { - if (userId) { - getUser(userId).then(setUser) - } - }, [userId]) - - return user -} - export const usePrivateUsers = () => { const [users, setUsers] = useState<PrivateUser[]>([]) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index bfe13837..0cfbc99f 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -27,7 +27,7 @@ import { fromPropz, usePropz } from 'web/hooks/use-propz' import { Leaderboard } from 'web/components/leaderboard' import { resolvedPayout } from 'common/calculate' import { formatMoney } from 'common/util/format' -import { useUserById } from 'web/hooks/use-users' +import { useUserById } from 'web/hooks/use-user' import { ContractTabs } from 'web/components/contract/contract-tabs' import { contractTextDetails } from 'web/components/contract/contract-details' import { useWindowSize } from 'web/hooks/use-window-size' diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index 01597a15..8093969b 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -6,7 +6,7 @@ import { claimManalink } from 'web/lib/firebase/api' import { useManalink } from 'web/lib/firebase/manalinks' import { ManalinkCard } from 'web/components/manalink-card' import { useUser } from 'web/hooks/use-user' -import { useUserById } from 'web/hooks/use-users' +import { useUserById } from 'web/hooks/use-user' import { firebaseLogin } from 'web/lib/firebase/users' export default function ClaimPage() { @@ -17,7 +17,7 @@ export default function ClaimPage() { const [claiming, setClaiming] = useState(false) const [error, setError] = useState<string | undefined>(undefined) - const fromUser = useUserById(manalink?.fromId) + const fromUser = useUserById(manalink?.fromId ?? '_loading') if (!manalink) { return <></> } diff --git a/web/pages/links.tsx b/web/pages/links.tsx index ede997df..76c62978 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -12,7 +12,7 @@ import { Subtitle } from 'web/components/subtitle' import { useUser } from 'web/hooks/use-user' import { useUserManalinks } from 'web/lib/firebase/manalinks' import { fromNow } from 'web/lib/util/time' -import { useUserById } from 'web/hooks/use-users' +import { useUserById } from 'web/hooks/use-user' import { ManalinkTxn } from 'common/txn' import { Avatar } from 'web/components/avatar' import { RelativeTimestamp } from 'web/components/relative-timestamp' From ee01328553414c889b62f7db98d4ddd9f0ce93cf Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 14 Jul 2022 07:53:41 -0600 Subject: [PATCH 203/220] Remove group slugs from contracts on delete group --- functions/src/index.ts | 1 + functions/src/on-group-delete.ts | 31 +++++++++++++++++++++++++++++++ web/hooks/use-group.ts | 2 -- web/lib/firebase/groups.ts | 1 + 4 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 functions/src/on-group-delete.ts diff --git a/functions/src/index.ts b/functions/src/index.ts index cf75802e..b3f65a4f 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -21,6 +21,7 @@ export * from './on-create-group' export * from './on-update-user' export * from './on-create-comment-on-group' export * from './on-create-txn' +export * from './on-group-delete' // v2 export * from './health' diff --git a/functions/src/on-group-delete.ts b/functions/src/on-group-delete.ts new file mode 100644 index 00000000..e078bbcd --- /dev/null +++ b/functions/src/on-group-delete.ts @@ -0,0 +1,31 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { Group } from 'common/group' +import { Contract } from 'common/contract' +const firestore = admin.firestore() + +exports.onGroupDelete = functions.firestore + .document('groups/{groupId}') + .onDelete(async (change) => { + const group = change.data() as Group + + // get all contracts with this group's slug + const contracts = await firestore + .collection('contracts') + .where('groupSlugs', 'array-contains', group.slug) + .get() + + for (const doc of contracts.docs) { + const contract = doc.data() as Contract + // remove the group from the contract + await firestore + .collection('contracts') + .doc(contract.id) + .update({ + groupSlugs: (contract.groupSlugs ?? []).filter( + (groupSlug) => groupSlug !== group.slug + ), + }) + } + }) diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 8af2183e..be0c92f6 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -2,14 +2,12 @@ import { useEffect, useState } from 'react' import { Group } from 'common/group' import { User } from 'common/user' import { - getGroupBySlug, getGroupsWithContractId, listenForGroup, listenForGroups, listenForMemberGroups, } from 'web/lib/firebase/groups' import { getUser } from 'web/lib/firebase/users' -import { CATEGORIES, CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories' import { filterDefined } from 'common/util/array' export const useGroup = (groupId: string | undefined) => { diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 708096b3..ec152792 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -119,6 +119,7 @@ export async function addUserToGroup( await updateGroup(newGroup, { memberIds: uniq(newMemberIds) }) return newGroup } + export async function leaveGroup(group: Group, userId: string): Promise<Group> { const { memberIds } = group if (!memberIds.includes(userId)) { From 709ce5377aa4017a7d352cf76e04ae83f5bd5287 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 14 Jul 2022 07:57:33 -0600 Subject: [PATCH 204/220] Remove extra key assignment --- web/pages/groups.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 8f2fe424..9192a094 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -171,10 +171,7 @@ export default function Groups(props: { export function GroupCard(props: { group: Group; creator: User | undefined }) { const { group, creator } = props return ( - <Col - key={group.id} - className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100" - > + <Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100"> <Link href={groupPath(group.slug)}> <a className="absolute left-0 right-0 top-0 bottom-0 z-0" /> </Link> From eb6b1b9f89e20ac0642bee354ec5070a5331f140 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 14 Jul 2022 08:02:54 -0600 Subject: [PATCH 205/220] Rename on-delete-group --- functions/src/index.ts | 2 +- functions/src/{on-group-delete.ts => on-delete-group.ts} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename functions/src/{on-group-delete.ts => on-delete-group.ts} (94%) diff --git a/functions/src/index.ts b/functions/src/index.ts index b3f65a4f..3055f8dc 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -21,7 +21,7 @@ export * from './on-create-group' export * from './on-update-user' export * from './on-create-comment-on-group' export * from './on-create-txn' -export * from './on-group-delete' +export * from './on-delete-group' // v2 export * from './health' diff --git a/functions/src/on-group-delete.ts b/functions/src/on-delete-group.ts similarity index 94% rename from functions/src/on-group-delete.ts rename to functions/src/on-delete-group.ts index e078bbcd..ca833254 100644 --- a/functions/src/on-group-delete.ts +++ b/functions/src/on-delete-group.ts @@ -5,7 +5,7 @@ import { Group } from 'common/group' import { Contract } from 'common/contract' const firestore = admin.firestore() -exports.onGroupDelete = functions.firestore +export const onDeleteGroup = functions.firestore .document('groups/{groupId}') .onDelete(async (change) => { const group = change.data() as Group From 4eba3c812404c72a537c4c09c17e0e82627e2aed Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 14 Jul 2022 09:09:12 -0600 Subject: [PATCH 206/220] Try new way of calculating rankings for large groups --- web/hooks/use-group.ts | 13 ++++-- web/lib/firebase/contracts.ts | 8 ++++ web/lib/firebase/groups.ts | 18 +------- web/pages/group/[...slugs]/index.tsx | 62 +++++++++------------------- web/pages/groups.tsx | 29 +++++++++++-- 5 files changed, 63 insertions(+), 67 deletions(-) diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index be0c92f6..c3098ba4 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -7,7 +7,7 @@ import { listenForGroups, listenForMemberGroups, } from 'web/lib/firebase/groups' -import { getUser } from 'web/lib/firebase/users' +import { getUser, getUsers } from 'web/lib/firebase/users' import { filterDefined } from 'common/util/array' export const useGroup = (groupId: string | undefined) => { @@ -85,9 +85,14 @@ export function useMembers(group: Group, max?: number) { } export async function listMembers(group: Group, max?: number) { - return await Promise.all( - group.memberIds.slice(0, max ? max : group.memberIds.length).map(getUser) - ) + const { memberIds } = group + const numToRetrieve = max ?? memberIds.length + if (memberIds.length === 0) return [] + if (numToRetrieve) + return (await getUsers()).filter((user) => + group.memberIds.includes(user.id) + ) + return await Promise.all(group.memberIds.slice(0, numToRetrieve).map(getUser)) } export const useGroupsWithContract = (contractId: string | undefined) => { diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index d5fb85cb..63efa53b 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -124,6 +124,14 @@ export async function listContracts(creatorId: string): Promise<Contract[]> { return snapshot.docs.map((doc) => doc.data()) } +export async function listContractsByGroupSlug( + slug: string +): Promise<Contract[]> { + const q = query(contracts, where('groupSlugs', 'array-contains', slug)) + const snapshot = await getDocs(q) + return snapshot.docs.map((doc) => doc.data()) +} + export async function listTaggedContractsCaseInsensitive( tag: string ): Promise<Contract[]> { diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index ec152792..762bfab1 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -8,7 +8,7 @@ import { } from 'firebase/firestore' import { sortBy, uniq } from 'lodash' import { Group } from 'common/group' -import { getContractFromId, updateContract } from './contracts' +import { updateContract } from './contracts' import { coll, getValue, @@ -16,7 +16,6 @@ import { listenForValue, listenForValues, } from './utils' -import { filterDefined } from 'common/util/array' import { Contract } from 'common/contract' export const groups = coll<Group>('groups') @@ -54,21 +53,6 @@ export async function getGroupBySlug(slug: string) { return docs.length === 0 ? null : docs[0].data() } -export async function getGroupContracts(group: Group) { - const { contractIds } = group - - const contracts = - filterDefined( - await Promise.all( - contractIds.map(async (contractId) => { - return await getContractFromId(contractId) - }) - ) - ) ?? [] - - return [...contracts] -} - export function listenForGroup( groupId: string, setGroup: (group: Group | null) => void diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 43647cdb..c00a8e29 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -3,14 +3,13 @@ import { take, sortBy, debounce } from 'lodash' import { Group } from 'common/group' import { Page } from 'web/components/page' import { listAllBets } from 'web/lib/firebase/bets' -import { Contract } from 'web/lib/firebase/contracts' +import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { groupPath, getGroupBySlug, updateGroup, addUserToGroup, addContractToGroup, - getGroupContracts, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' @@ -22,7 +21,7 @@ import { } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' +import { listMembers, useGroup } from 'web/hooks/use-group' import { useRouter } from 'next/router' import { scoreCreators, scoreTraders } from 'common/scoring' import { Leaderboard } from 'web/components/leaderboard' @@ -62,14 +61,11 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const { slugs } = props.params const group = await getGroupBySlug(slugs[0]) - const members = - group && group.memberIds.length < 100 ? await listMembers(group) : [] + const members = group && (await listMembers(group)) const creatorPromise = group ? getUser(group.creatorId) : null const contracts = - group && group.contractIds.length < 100 - ? await getGroupContracts(group).catch((_) => []) - : [] + (group && (await listContractsByGroupSlug(group.slug))) ?? [] const bets = await Promise.all( contracts.map((contract: Contract) => listAllBets(contract.id)) @@ -77,10 +73,12 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const creatorScores = scoreCreators(contracts) const traderScores = scoreTraders(contracts, bets) - const [topCreators, topTraders] = await Promise.all([ - toTopUsers(creatorScores), - toTopUsers(traderScores), - ]) + const [topCreators, topTraders] = + (members && [ + toTopUsers(creatorScores, members), + toTopUsers(traderScores, members), + ]) ?? + [] const creator = await creatorPromise @@ -99,14 +97,14 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { } } -async function toTopUsers(userScores: { [userId: string]: number }) { +function toTopUsers(userScores: { [userId: string]: number }, users: User[]) { const topUserPairs = take( sortBy(Object.entries(userScores), ([_, score]) => -1 * score), 10 ).filter(([_, score]) => score >= 0.5) - const topUsers = await Promise.all( - topUserPairs.map(([userId]) => getUser(userId)) + const topUsers = topUserPairs.map( + ([userId]) => users.filter((user) => user.id === userId)[0] ) return topUsers.filter((user) => user) } @@ -199,6 +197,7 @@ export default function GroupPage(props: { creator={creator} isCreator={!!isCreator} user={user} + members={members} /> </Col> ) @@ -327,8 +326,9 @@ function GroupOverview(props: { creator: User user: User | null | undefined isCreator: boolean + members: User[] }) { - const { group, creator, isCreator, user } = props + const { group, creator, isCreator, user, members } = props const anyoneCanJoinChoices: { [key: string]: string } = { Closed: 'false', Open: 'true', @@ -403,7 +403,7 @@ function GroupOverview(props: { </Row> )} <Col className={'mt-2'}> - <GroupMemberSearch group={group} /> + <GroupMemberSearch members={members} /> </Col> </Col> </> @@ -426,10 +426,9 @@ function SearchBar(props: { setQuery: (query: string) => void }) { ) } -function GroupMemberSearch(props: { group: Group }) { +function GroupMemberSearch(props: { members: User[] }) { const [query, setQuery] = useState('') - const { group } = props - const members = useMembers(group, 100) + const { members } = props // TODO use find-active-contracts to sort by? const matches = sortBy(members, [(member) => member.name]).filter( @@ -455,29 +454,6 @@ function GroupMemberSearch(props: { group: Group }) { ) } -export function GroupMembersList(props: { group: Group }) { - const { group } = props - const maxMembersToShow = 3 - const members = useMembers(group, maxMembersToShow).filter( - (m) => m.id !== group.creatorId - ) - if (group.memberIds.length === 1) return <div /> - return ( - <div className="text-neutral flex flex-wrap gap-1"> - <span className={'text-gray-500'}>Other members</span> - {members.slice(0, maxMembersToShow).map((member, i) => ( - <div key={member.id} className={'flex-shrink'}> - <UserLink name={member.name} username={member.username} /> - {members.length > 1 && i !== members.length - 1 && <span>,</span>} - </div> - ))} - {group.memberIds.length > maxMembersToShow && ( - <span> & {group.memberIds.length - maxMembersToShow} more</span> - )} - </div> - ) -} - function SortedLeaderboard(props: { users: User[] scoreFunction: (user: User) => number diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 9192a094..2523b789 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -1,23 +1,23 @@ import { sortBy, debounce } from 'lodash' import Link from 'next/link' -import { useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react' import { Group } from 'common/group' import { CreateGroupButton } from 'web/components/groups/create-group-button' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { Title } from 'web/components/title' -import { useGroups, useMemberGroupIds } from 'web/hooks/use-group' +import { useGroups, useMemberGroupIds, useMembers } from 'web/hooks/use-group' import { useUser } from 'web/hooks/use-user' import { groupPath, listAllGroups } from 'web/lib/firebase/groups' import { getUser, User } from 'web/lib/firebase/users' import { Tabs } from 'web/components/layout/tabs' -import { GroupMembersList } from 'web/pages/group/[...slugs]' import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' import { SiteLink } from 'web/components/site-link' import clsx from 'clsx' import { Avatar } from 'web/components/avatar' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' +import { UserLink } from 'web/components/user-page' export async function getStaticProps() { const groups = await listAllGroups().catch((_) => []) @@ -201,6 +201,29 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { ) } +function GroupMembersList(props: { group: Group }) { + const { group } = props + const maxMembersToShow = 3 + const members = useMembers(group, maxMembersToShow).filter( + (m) => m.id !== group.creatorId + ) + if (group.memberIds.length === 1) return <div /> + return ( + <div className="text-neutral flex flex-wrap gap-1"> + <span className={'text-gray-500'}>Other members</span> + {members.slice(0, maxMembersToShow).map((member, i) => ( + <div key={member.id} className={'flex-shrink'}> + <UserLink name={member.name} username={member.username} /> + {members.length > 1 && i !== members.length - 1 && <span>,</span>} + </div> + ))} + {group.memberIds.length > maxMembersToShow && ( + <span> & {group.memberIds.length - maxMembersToShow} more</span> + )} + </div> + ) +} + export function GroupLink(props: { group: Group; className?: string }) { const { group, className } = props From deaa595f0735c90554db3a8160c2bbc3bdeed33b Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 14 Jul 2022 09:32:50 -0600 Subject: [PATCH 207/220] Exclude contract creator in both places --- functions/src/on-create-bet.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index adf22d56..fc2e0053 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -82,7 +82,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( ) } - const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId) + const isNewUniqueBettor = + !previousUniqueBettorIds.includes(bettorId) && + bettorId !== contract.creatorId const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId]) // Update contract unique bettors From 0c328bc39809f1ab947066952f9611d283f54465 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 14 Jul 2022 11:44:52 -0500 Subject: [PATCH 208/220] Move getStorage() into init.ts after initializeApp() is called. --- web/lib/firebase/init.ts | 2 ++ web/lib/firebase/storage.ts | 10 ++-------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index 12f3d832..bf712a8f 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -1,5 +1,6 @@ import { getFirestore } from '@firebase/firestore' import { initializeApp, getApps, getApp } from 'firebase/app' +import { getStorage } from 'firebase/storage' import { FIREBASE_CONFIG } from 'common/envs/constants' import { connectFirestoreEmulator } from 'firebase/firestore' import { connectFunctionsEmulator, getFunctions } from 'firebase/functions' @@ -8,6 +9,7 @@ import { connectFunctionsEmulator, getFunctions } from 'firebase/functions' export const app = getApps().length ? getApp() : initializeApp(FIREBASE_CONFIG) export const db = getFirestore() export const functions = getFunctions() +export const storage = getStorage() declare global { /* eslint-disable-next-line no-var */ diff --git a/web/lib/firebase/storage.ts b/web/lib/firebase/storage.ts index 5293a6bc..2fc2ccc7 100644 --- a/web/lib/firebase/storage.ts +++ b/web/lib/firebase/storage.ts @@ -1,11 +1,5 @@ -import { - getStorage, - ref, - uploadBytesResumable, - getDownloadURL, -} from 'firebase/storage' - -const storage = getStorage() +import { ref, uploadBytesResumable, getDownloadURL } from 'firebase/storage' +import { storage } from './init' // TODO: compress large images export const uploadImage = async ( From a93e64c830e3d404936dcd25ed69b65e98e11297 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 14 Jul 2022 10:02:46 -0700 Subject: [PATCH 209/220] fix: let useUserById accept undefined userId (#648) --- web/hooks/use-user.ts | 2 +- web/pages/link/[slug].tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index 158235ca..df4dc8f7 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -46,7 +46,7 @@ export const usePrivateUser = (userId?: string) => { return privateUser } -export const useUserById = (userId: string) => { +export const useUserById = (userId = '_') => { const result = useFirestoreDocumentData<DocumentData, User>( ['users', userId], doc(users, userId), diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index 8093969b..67e7b695 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -17,7 +17,7 @@ export default function ClaimPage() { const [claiming, setClaiming] = useState(false) const [error, setError] = useState<string | undefined>(undefined) - const fromUser = useUserById(manalink?.fromId ?? '_loading') + const fromUser = useUserById(manalink?.fromId) if (!manalink) { return <></> } From 8daf1b2ba894212dec28e5f69b000e9d5e020f13 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 14 Jul 2022 12:03:29 -0500 Subject: [PATCH 210/220] Return undefined instead of null for useUserById(undefined) --- web/hooks/use-user.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index df4dc8f7..e04a69ca 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -53,6 +53,8 @@ export const useUserById = (userId = '_') => { { subscribe: true, includeMetadataChanges: true } ) + if (userId === '_') return undefined + return result.isLoading ? undefined : result.data } From 27a544205f3f03077c27ea70527a35085c852405 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 14 Jul 2022 11:09:28 -0600 Subject: [PATCH 211/220] Optimistically join groups --- web/components/groups/groups-button.tsx | 31 ++++++++++++++++++------- web/lib/firebase/groups.ts | 27 +++++++-------------- web/pages/group/[...slugs]/index.tsx | 10 ++++---- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index b81155d1..f3ae77a2 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx' import { User } from 'common/user' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useUser } from 'web/hooks/use-user' import { withTracking } from 'web/lib/service/analytics' import { Row } from 'web/components/layout/row' @@ -9,9 +9,10 @@ import { TextButton } from 'web/components/text-button' import { Group } from 'common/group' import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' -import { addUserToGroup, leaveGroup } from 'web/lib/firebase/groups' +import { joinGroup, leaveGroup } from 'web/lib/firebase/groups' import { firebaseLogin } from 'web/lib/firebase/users' import { GroupLink } from 'web/pages/groups' +import toast from 'react-hot-toast' export function GroupsButton(props: { user: User }) { const { user } = props @@ -88,22 +89,34 @@ export function JoinOrLeaveGroupButton(props: { }) { const { group, small, className } = props const currentUser = useUser() - const isFollowing = currentUser - ? group.memberIds.includes(currentUser.id) - : false + const [isMember, setIsMember] = useState<boolean>(false) + useEffect(() => { + if (currentUser && group.memberIds.includes(currentUser.id)) { + setIsMember(group.memberIds.includes(currentUser.id)) + } + }, [currentUser, group]) + const onJoinGroup = () => { if (!currentUser) return - addUserToGroup(group, currentUser.id) + setIsMember(true) + joinGroup(group, currentUser.id).catch(() => { + setIsMember(false) + toast.error('Failed to join group') + }) } const onLeaveGroup = () => { if (!currentUser) return - leaveGroup(group, currentUser.id) + setIsMember(false) + leaveGroup(group, currentUser.id).catch(() => { + setIsMember(true) + toast.error('Failed to leave group') + }) } const smallStyle = 'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500' - if (!currentUser || isFollowing === undefined) { + if (!currentUser || isMember === undefined) { if (!group.anyoneCanJoin) return <div className={clsx(className, 'text-gray-500')}>Closed</div> return ( @@ -116,7 +129,7 @@ export function JoinOrLeaveGroupButton(props: { ) } - if (isFollowing) { + if (isMember) { return ( <button className={clsx( diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 762bfab1..6d695b7f 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -87,32 +87,23 @@ export async function addUserToGroupViaSlug(groupSlug: string, userId: string) { console.error(`Group not found: ${groupSlug}`) return } - return await addUserToGroup(group, userId) + return await joinGroup(group, userId) } -export async function addUserToGroup( - group: Group, - userId: string -): Promise<Group> { +export async function joinGroup(group: Group, userId: string): Promise<void> { const { memberIds } = group - if (memberIds.includes(userId)) { - return group - } + if (memberIds.includes(userId)) return // already a member + const newMemberIds = [...memberIds, userId] - const newGroup = { ...group, memberIds: newMemberIds } - await updateGroup(newGroup, { memberIds: uniq(newMemberIds) }) - return newGroup + return await updateGroup(group, { memberIds: uniq(newMemberIds) }) } -export async function leaveGroup(group: Group, userId: string): Promise<Group> { +export async function leaveGroup(group: Group, userId: string): Promise<void> { const { memberIds } = group - if (!memberIds.includes(userId)) { - return group - } + if (!memberIds.includes(userId)) return // not a member + const newMemberIds = memberIds.filter((id) => id !== userId) - const newGroup = { ...group, memberIds: newMemberIds } - await updateGroup(newGroup, { memberIds: uniq(newMemberIds) }) - return newGroup + return await updateGroup(group, { memberIds: uniq(newMemberIds) }) } export async function addContractToGroup(group: Group, contract: Contract) { diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index c00a8e29..a364de43 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -8,7 +8,7 @@ import { groupPath, getGroupBySlug, updateGroup, - addUserToGroup, + joinGroup, addContractToGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' @@ -604,19 +604,19 @@ function JoinGroupButton(props: { user: User | null | undefined }) { const { group, user } = props - function joinGroup() { + function addUserToGroup() { if (user && !group.memberIds.includes(user.id)) { - toast.promise(addUserToGroup(group, user.id), { + toast.promise(joinGroup(group, user.id), { loading: 'Joining group...', success: 'Joined group!', - error: "Couldn't join group", + error: "Couldn't join group, try again?", }) } } return ( <div> <button - onClick={user ? joinGroup : firebaseLogin} + onClick={user ? addUserToGroup : firebaseLogin} className={'btn-md btn-outline btn whitespace-nowrap normal-case'} > {user ? 'Join group' : 'Login to join group'} From 6a286432153c1b48b69e7d19ceaef52380129e5c Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 14 Jul 2022 11:48:04 -0600 Subject: [PATCH 212/220] Notifications ux --- web/pages/notifications.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 2001c557..250fae37 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -36,6 +36,7 @@ import { groupBy, sum, uniq } from 'lodash' import Custom404 from 'web/pages/404' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' +import { useWindowSize } from 'web/hooks/use-window-size' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' @@ -183,7 +184,7 @@ function IncomeNotificationGroupItem(props: { const groupedNotificationsBySourceTitle = groupBy( groupedNotificationsBySourceType[sourceType], (notification) => { - return notification.sourceTitle + return notification.sourceTitle ?? notification.sourceContractTitle } ) for (const contractId in groupedNotificationsBySourceTitle) { @@ -314,7 +315,8 @@ function IncomeNotificationItem(props: { const { notification, justSummary } = props const { sourceType, sourceUserName, sourceUserUsername } = notification const [highlighted] = useState(!notification.isSeen) - + const { width } = useWindowSize() + const isMobile = (width && width < 768) || false useEffect(() => { setNotificationsAsSeen([notification]) }, [notification]) @@ -351,7 +353,7 @@ function IncomeNotificationItem(props: { {getReasonForShowingIncomeNotification(true)} <QuestionOrGroupLink notification={notification} - ignoreClick={true} + ignoreClick={isMobile} /> </span> </div> @@ -406,6 +408,8 @@ function NotificationGroupItem(props: { const { notificationGroup, className } = props const { notifications } = notificationGroup const { sourceContractTitle } = notifications[0] + const { width } = useWindowSize() + const isMobile = (width && width < 768) || false const numSummaryLines = 3 const [expanded, setExpanded] = useState(false) @@ -445,7 +449,10 @@ function NotificationGroupItem(props: { <div className={'flex w-full flex-row justify-between'}> <div className={'ml-2'}> Activity on - <QuestionOrGroupLink notification={notifications[0]} /> + <QuestionOrGroupLink + notification={notifications[0]} + ignoreClick={!expanded && isMobile} + /> </div> <div className={'hidden sm:inline-block'}> <RelativeTimestamp time={notifications[0].createdTime} /> From d9279e42ccf38d9839b5e293f94846d6dd31aa54 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 14 Jul 2022 11:56:40 -0600 Subject: [PATCH 213/220] Don't collapse/expand notifs with ctrl/cmd click --- web/pages/notifications.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 250fae37..f86c4fef 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -157,6 +157,11 @@ function IncomeNotificationGroupItem(props: { notifications.some((n) => !n.isSeen) ) + const onClickHandler = (event: React.MouseEvent<HTMLDivElement>) => { + if (event.ctrlKey || event.metaKey) return + setExpanded(!expanded) + } + useEffect(() => { setNotificationsAsSeen(notifications) }, [notifications]) @@ -231,7 +236,7 @@ function IncomeNotificationGroupItem(props: { !expanded ? 'hover:bg-gray-100' : '', highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : '' )} - onClick={() => setExpanded(!expanded)} + onClick={onClickHandler} > {expanded && ( <span @@ -245,7 +250,7 @@ function IncomeNotificationGroupItem(props: { /> <div className={'ml-2 flex w-full flex-row flex-wrap truncate'} - onClick={() => setExpanded(!expanded)} + onClick={onClickHandler} > <div className={'flex w-full flex-row justify-between'}> <div> @@ -416,6 +421,12 @@ function NotificationGroupItem(props: { const [highlighted, setHighlighted] = useState( notifications.some((n) => !n.isSeen) ) + + const onClickHandler = (event: React.MouseEvent<HTMLDivElement>) => { + if (event.ctrlKey || event.metaKey) return + setExpanded(!expanded) + } + useEffect(() => { setNotificationsAsSeen(notifications) }, [notifications]) @@ -432,7 +443,7 @@ function NotificationGroupItem(props: { !expanded ? 'hover:bg-gray-100' : '', highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : '' )} - onClick={() => setExpanded(!expanded)} + onClick={onClickHandler} > {expanded && ( <span From be64bf71a784e0588f60c647f808ac50dbfc174e Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 14 Jul 2022 14:57:17 -0500 Subject: [PATCH 214/220] Limit the amount of bets and comments sent to the client through getStaticProps --- web/pages/[username]/[contractSlug].tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 0cfbc99f..17453770 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -65,8 +65,9 @@ export async function getStaticPropz(props: { contract, username, slug: contractSlug, - bets, - comments, + // Limit the data sent to the client. Client will still load all bets and comments directly. + bets: bets.slice(0, 5000), + comments: comments.slice(0, 1000), }, revalidate: 60, // regenerate after a minute From 6e1aa4b0f458deac09e56eaaebcc30a5c80bce62 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 14 Jul 2022 16:46:45 -0600 Subject: [PATCH 215/220] Order groups by most recent chat activity (#650) * Order groups by most recent chat activity * Use group chat slug constant * Match source slug and isSeenOnHref * Listen for group member changes --- common/group.ts | 3 ++ functions/src/create-notification.ts | 49 ++++++++++++++------- functions/src/on-create-comment-on-group.ts | 18 +++----- functions/src/on-update-group.ts | 10 ++++- web/components/groups/groups-button.tsx | 4 +- web/components/nav/sidebar.tsx | 20 +++++++-- web/components/user-page.tsx | 5 ++- web/hooks/use-group.ts | 27 +++++++----- web/lib/firebase/groups.ts | 19 +++++--- web/pages/group/[...slugs]/index.tsx | 23 ++++++---- web/pages/groups.tsx | 6 ++- 11 files changed, 125 insertions(+), 59 deletions(-) diff --git a/common/group.ts b/common/group.ts index 15348d5a..e367ded7 100644 --- a/common/group.ts +++ b/common/group.ts @@ -11,8 +11,11 @@ export type Group = { contractIds: string[] chatDisabled?: boolean + mostRecentChatActivityTime?: number + mostRecentContractAddedTime?: number } export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome'] +export const GROUP_CHAT_SLUG = 'chat' diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 1fb6c3af..4c42b00e 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -15,11 +15,11 @@ import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' -import { Group } from '../../common/group' +import { Group, GROUP_CHAT_SLUG } from '../../common/group' const firestore = admin.firestore() type user_to_reason_texts = { - [userId: string]: { reason: notification_reason_types; isSeeOnHref?: string } + [userId: string]: { reason: notification_reason_types } } export const createNotification = async ( @@ -72,7 +72,6 @@ export const createNotification = async ( sourceContractSlug: sourceContract?.slug, sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, - isSeenOnHref: userToReasonTexts[userId].isSeeOnHref, } await notificationRef.set(removeUndefinedProps(notification)) }) @@ -277,17 +276,6 @@ export const createNotification = async ( } } - const notifyOtherGroupMembersOfComment = async ( - userToReasons: user_to_reason_texts, - userId: string - ) => { - if (shouldGetNotification(userId, userToReasons)) - userToReasons[userId] = { - reason: 'on_group_you_are_member_of', - isSeeOnHref: sourceSlug, - } - } - const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -298,8 +286,6 @@ export const createNotification = async ( await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) } else if (sourceType === 'user' && relatedUserId) { await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId) - } else if (sourceType === 'comment' && !sourceContract && relatedUserId) { - await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId) } // The following functions need sourceContract to be defined. @@ -417,3 +403,34 @@ export const createBetFillNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } + +export const createGroupCommentNotification = async ( + fromUser: User, + toUserId: string, + comment: Comment, + group: Group, + idempotencyKey: string +) => { + const notificationRef = firestore + .collection(`/users/${toUserId}/notifications`) + .doc(idempotencyKey) + const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}` + const notification: Notification = { + id: idempotencyKey, + userId: toUserId, + reason: 'on_group_you_are_member_of', + createdTime: Date.now(), + isSeen: false, + sourceId: comment.id, + sourceType: 'comment', + sourceUpdateType: 'created', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: comment.text, + sourceSlug, + sourceTitle: `${group.name}`, + isSeenOnHref: sourceSlug, + } + await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts index 7217e602..0064480f 100644 --- a/functions/src/on-create-comment-on-group.ts +++ b/functions/src/on-create-comment-on-group.ts @@ -3,7 +3,7 @@ import { Comment } from '../../common/comment' import * as admin from 'firebase-admin' import { Group } from '../../common/group' import { User } from '../../common/user' -import { createNotification } from './create-notification' +import { createGroupCommentNotification } from './create-notification' const firestore = admin.firestore() export const onCreateCommentOnGroup = functions.firestore @@ -29,23 +29,17 @@ export const onCreateCommentOnGroup = functions.firestore const group = groupSnapshot.data() as Group await firestore.collection('groups').doc(groupId).update({ - mostRecentActivityTime: comment.createdTime, + mostRecentChatActivityTime: comment.createdTime, }) await Promise.all( group.memberIds.map(async (memberId) => { - return await createNotification( - comment.id, - 'comment', - 'created', + return await createGroupCommentNotification( creatorSnapshot.data() as User, - eventId, - comment.text, - undefined, - undefined, memberId, - `/group/${group.slug}`, - `${group.name}` + comment, + group, + eventId ) }) ) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index feaa6443..3ab2a249 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -12,7 +12,15 @@ export const onUpdateGroup = functions.firestore // ignore the update we just made if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return - // TODO: create notification with isSeeOnHref set to the group's /group/questions url + + if (prevGroup.contractIds.length < group.contractIds.length) { + await firestore + .collection('groups') + .doc(group.id) + .update({ mostRecentContractAddedTime: Date.now() }) + //TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url + // but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two + } await firestore .collection('groups') diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index f3ae77a2..b510f44d 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -17,7 +17,9 @@ import toast from 'react-hot-toast' export function GroupsButton(props: { user: User }) { const { user } = props const [isOpen, setIsOpen] = useState(false) - const groups = useMemberGroups(user.id) + const groups = useMemberGroups(user.id, undefined, { + by: 'mostRecentChatActivityTime', + }) return ( <> diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 784eb63a..b260553b 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -24,7 +24,7 @@ import { CreateQuestionButton } from 'web/components/create-question-button' import { useMemberGroups } from 'web/hooks/use-group' import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' -import { Group } from 'common/group' +import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Spacer } from '../layout/spacer' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { setNotificationsAsSeen } from 'web/pages/notifications' @@ -194,10 +194,14 @@ export default function Sidebar(props: { className?: string }) { ? signedOutMobileNavigation : signedInMobileNavigation const memberItems = ( - useMemberGroups(user?.id, { withChatEnabled: true }) ?? [] + useMemberGroups( + user?.id, + { withChatEnabled: true }, + { by: 'mostRecentChatActivityTime' } + ) ?? [] ).map((group: Group) => ({ name: group.name, - href: groupPath(group.slug), + href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`, })) return ( @@ -278,8 +282,16 @@ function GroupsList(props: { // Set notification as seen if our current page is equal to the isSeenOnHref property useEffect(() => { + const currentPageGroupSlug = currentPage.split('/')[2] preferredNotifications.forEach((notification) => { - if (notification.isSeenOnHref === currentPage) { + if ( + notification.isSeenOnHref === currentPage || + // Old chat style group chat notif ended just with the group slug + notification.isSeenOnHref?.endsWith(currentPageGroupSlug) || + // They're on the home page, so if they've a chat notif, they're seeing the chat + (notification.isSeenOnHref?.endsWith(GROUP_CHAT_SLUG) && + currentPage.endsWith(currentPageGroupSlug)) + ) { setNotificationsAsSeen([notification]) } }) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index be3f3ac4..85d70e86 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -38,6 +38,7 @@ import { GroupsButton } from 'web/components/groups/groups-button' import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { filterDefined } from 'common/util/array' import { useUserBets } from 'web/hooks/use-user-bets' +import { ReferralsButton } from 'web/components/referrals-button' export function UserLink(props: { name: string @@ -202,7 +203,9 @@ export function UserPage(props: { <Row className="gap-4"> <FollowingButton user={user} /> <FollowersButton user={user} /> - {/* <ReferralsButton user={user} currentUser={currentUser} /> */} + {currentUser?.username === 'Ian' && ( + <ReferralsButton user={user} currentUser={currentUser} /> + )} <GroupsButton user={user} /> </Row> diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index c3098ba4..4f968005 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -32,19 +32,26 @@ export const useGroups = () => { export const useMemberGroups = ( userId: string | null | undefined, - options?: { withChatEnabled: boolean } + options?: { withChatEnabled: boolean }, + sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' } ) => { const [memberGroups, setMemberGroups] = useState<Group[] | undefined>() useEffect(() => { if (userId) - return listenForMemberGroups(userId, (groups) => { - if (options?.withChatEnabled) - return setMemberGroups( - filterDefined(groups.filter((group) => group.chatDisabled !== true)) - ) - return setMemberGroups(groups) - }) - }, [options?.withChatEnabled, userId]) + return listenForMemberGroups( + userId, + (groups) => { + if (options?.withChatEnabled) + return setMemberGroups( + filterDefined( + groups.filter((group) => group.chatDisabled !== true) + ) + ) + return setMemberGroups(groups) + }, + sort + ) + }, [options?.withChatEnabled, sort, userId]) return memberGroups } @@ -88,7 +95,7 @@ export async function listMembers(group: Group, max?: number) { const { memberIds } = group const numToRetrieve = max ?? memberIds.length if (memberIds.length === 0) return [] - if (numToRetrieve) + if (numToRetrieve > 100) return (await getUsers()).filter((user) => group.memberIds.includes(user.id) ) diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 6d695b7f..e49b012a 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -7,7 +7,7 @@ import { where, } from 'firebase/firestore' import { sortBy, uniq } from 'lodash' -import { Group } from 'common/group' +import { Group, GROUP_CHAT_SLUG } from 'common/group' import { updateContract } from './contracts' import { coll, @@ -22,7 +22,7 @@ export const groups = coll<Group>('groups') export function groupPath( groupSlug: string, - subpath?: 'edit' | 'questions' | 'about' | 'chat' | 'rankings' + subpath?: 'edit' | 'questions' | 'about' | typeof GROUP_CHAT_SLUG | 'rankings' ) { return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` } @@ -62,12 +62,21 @@ export function listenForGroup( export function listenForMemberGroups( userId: string, - setGroups: (groups: Group[]) => void + setGroups: (groups: Group[]) => void, + sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' } ) { const q = query(groups, where('memberIds', 'array-contains', userId)) - + const sorter = (group: Group) => { + if (sort?.by === 'mostRecentChatActivityTime') { + return group.mostRecentChatActivityTime ?? group.mostRecentActivityTime + } + if (sort?.by === 'mostRecentContractAddedTime') { + return group.mostRecentContractAddedTime ?? group.mostRecentActivityTime + } + return group.mostRecentActivityTime + } return listenForValues<Group>(q, (groups) => { - const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime]) + const sorted = sortBy(groups, [(group) => -sorter(group)]) setGroups(sorted) }) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index a364de43..3fa64964 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,6 +1,6 @@ import { take, sortBy, debounce } from 'lodash' -import { Group } from 'common/group' +import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Page } from 'web/components/page' import { listAllBets } from 'web/lib/firebase/bets' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' @@ -21,7 +21,7 @@ import { } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { listMembers, useGroup } from 'web/hooks/use-group' +import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' import { useRouter } from 'next/router' import { scoreCreators, scoreTraders } from 'common/scoring' import { Leaderboard } from 'web/components/leaderboard' @@ -114,7 +114,7 @@ export async function getStaticPaths() { } const groupSubpages = [ undefined, - 'chat', + GROUP_CHAT_SLUG, 'questions', 'rankings', 'about', @@ -218,7 +218,7 @@ export default function GroupPage(props: { ) : ( <LoadingIndicator /> ), - href: groupPath(group.slug, 'chat'), + href: groupPath(group.slug, GROUP_CHAT_SLUG), }, ]), { @@ -246,7 +246,7 @@ export default function GroupPage(props: { href: groupPath(group.slug, 'about'), }, ] - const tabIndex = tabs.map((t) => t.title).indexOf(page ?? 'chat') + const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG) return ( <Page rightSidebar={rightSidebar} className="!pb-0"> <SEO @@ -403,7 +403,7 @@ function GroupOverview(props: { </Row> )} <Col className={'mt-2'}> - <GroupMemberSearch members={members} /> + <GroupMemberSearch members={members} group={group} /> </Col> </Col> </> @@ -426,9 +426,16 @@ function SearchBar(props: { setQuery: (query: string) => void }) { ) } -function GroupMemberSearch(props: { members: User[] }) { +function GroupMemberSearch(props: { members: User[]; group: Group }) { const [query, setQuery] = useState('') - const { members } = props + const { group } = props + let { members } = props + + // Use static members on load, but also listen to member changes: + const listenToMembers = useMembers(group) + if (listenToMembers) { + members = listenToMembers + } // TODO use find-active-contracts to sort by? const matches = sortBy(members, [(member) => member.name]).filter( diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 2523b789..87ac1501 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -79,7 +79,11 @@ export default function Groups(props: { ) const matchesOrderedByRecentActivity = sortBy(groups, [ - (group) => -1 * group.mostRecentActivityTime, + (group) => + -1 * + (group.mostRecentChatActivityTime ?? + group.mostRecentContractAddedTime ?? + group.mostRecentActivityTime), ]).filter( (g) => checkAgainstQuery(query, g.name) || From a9018d77c77067417a6c31f0aaa97670baf98b5f Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 14 Jul 2022 18:01:35 -0500 Subject: [PATCH 216/220] If a limit bet doesn't match any orders, don't update the contract, don't redeem shares. Perf win! --- functions/src/place-bet.ts | 50 ++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 73c60187..b8c0ca0e 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -128,34 +128,38 @@ export const placebet = newEndpoint({}, async (req, auth) => { updateMakers(makers, betDoc.id, contractDoc, trans) } - trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) }) - log('Updated user balance.') - trans.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - p: newP, - totalShares: newTotalShares, - totalBets: newTotalBets, - totalLiquidity: newTotalLiquidity, - collectedFees: addObjects(newBet.fees, collectedFees), - volume: volume + newBet.amount, - }) - ) - log('Updated contract properties.') + if (newBet.amount !== 0) { + trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) }) + log('Updated user balance.') - return { betId: betDoc.id, makers } + trans.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + p: newP, + totalShares: newTotalShares, + totalBets: newTotalBets, + totalLiquidity: newTotalLiquidity, + collectedFees: addObjects(newBet.fees, collectedFees), + volume: volume + newBet.amount, + }) + ) + log('Updated contract properties.') + } + + return { betId: betDoc.id, makers, newBet } }) log('Main transaction finished.') - await redeemShares(auth.uid, contractId) - const userIds = [ - auth.uid, - ...(result.makers ?? []).map((maker) => maker.bet.userId), - ] - await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) - log('Share redemption transaction finished.') + if (result.newBet.amount !== 0) { + const userIds = [ + auth.uid, + ...(result.makers ?? []).map((maker) => maker.bet.userId), + ] + await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) + log('Share redemption transaction finished.') + } return { betId: result.betId } }) From 44d993a5884dd5cb83941e6d04eb1d7f4109b429 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 14 Jul 2022 17:03:08 -0600 Subject: [PATCH 217/220] Bold group for old chat notif --- web/components/nav/sidebar.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index b260553b..8553b506 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -321,7 +321,10 @@ function GroupsList(props: { className={clsx( 'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900', preferredNotifications.some( - (n) => !n.isSeen && n.isSeenOnHref === item.href + (n) => + !n.isSeen && + (n.isSeenOnHref === item.href || + n.isSeenOnHref === item.href.replace('/chat', '')) ) && 'font-bold' )} > From 2f02e4d3e050da56d362af7ebb045496ae13f533 Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Thu, 14 Jul 2022 17:43:06 -0700 Subject: [PATCH 218/220] minor tweaks of manalink form (#647) * minor tweaks of manalink form, adding M$ in front of amount and changing expire time to dropdown instead of calendar selection * made minimum for uses and amount 1, it seems otherwise it does not generate a link at all --- .../manalinks/create-links-button.tsx | 87 ++++++++++++------- 1 file changed, 54 insertions(+), 33 deletions(-) diff --git a/web/components/manalinks/create-links-button.tsx b/web/components/manalinks/create-links-button.tsx index d74980cf..12ab8c87 100644 --- a/web/components/manalinks/create-links-button.tsx +++ b/web/components/manalinks/create-links-button.tsx @@ -63,15 +63,39 @@ function CreateManalinkForm(props: { const [finishedCreating, setFinishedCreating] = useState(false) const [copyPressed, setCopyPressed] = useState(false) setTimeout(() => setCopyPressed(false), 300) + const defaultExpire = 'week' + const [expiresIn, setExpiresIn] = useState(defaultExpire) const [newManalink, setNewManalink] = useState<ManalinkInfo>({ - expiresTime: null, + expiresTime: dayjs().add(1, defaultExpire).valueOf(), amount: 100, maxUses: 1, uses: 0, message: '', }) + const EXPIRE_OPTIONS = { + day: '1 Day', + week: '1 Week', + month: '1 Month', + never: 'Never', + } + + const expireOptions = Object.entries(EXPIRE_OPTIONS).map(([key, value]) => { + return <option value={key}>{value}</option> + }) + + function setExpireTime(timeDelta: string) { + const expiresTime = + timeDelta === 'never' ? null : dayjs().add(1, timeDelta).valueOf() + setNewManalink((m) => { + return { + ...m, + expiresTime, + } + }) + } + return ( <> {!finishedCreating && ( @@ -87,23 +111,30 @@ function CreateManalinkForm(props: { <div className="flex flex-col flex-wrap gap-x-5 gap-y-2"> <div className="form-control flex-auto"> <label className="label">Amount</label> - <input - className="input input-bordered" - type="number" - value={newManalink.amount} - onChange={(e) => - setNewManalink((m) => { - return { ...m, amount: parseInt(e.target.value) } - }) - } - ></input> + <div className="relative"> + <span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> + M$ + </span> + <input + className="input input-bordered w-full pl-10" + type="number" + min="1" + value={newManalink.amount} + onChange={(e) => + setNewManalink((m) => { + return { ...m, amount: parseInt(e.target.value) } + }) + } + /> + </div> </div> <div className="flex flex-col gap-2 md:flex-row"> - <div className="form-control"> + <div className="form-control w-full md:w-1/2"> <label className="label">Uses</label> <input - className="input input-bordered w-full" + className="input input-bordered" type="number" + min="1" value={newManalink.maxUses ?? ''} onChange={(e) => setNewManalink((m) => { @@ -112,29 +143,19 @@ function CreateManalinkForm(props: { } ></input> </div> - <div className="form-control"> + <div className="form-control w-full md:w-1/2"> <label className="label">Expires in</label> - <input - value={ - newManalink.expiresTime != null - ? dayjs(newManalink.expiresTime).format( - 'YYYY-MM-DDTHH:mm' - ) - : '' - } - className="input input-bordered" - type="datetime-local" + <select + className="!select !select-bordered" + value={expiresIn} + defaultValue={defaultExpire} onChange={(e) => { - setNewManalink((m) => { - return { - ...m, - expiresTime: e.target.value - ? dayjs(e.target.value, 'YYYY-MM-DDTHH:mm').valueOf() - : null, - } - }) + setExpiresIn(e.target.value) + setExpireTime(e.target.value) }} - ></input> + > + {expireOptions} + </select> </div> </div> <div className="form-control w-full"> From 17c9beca2846b358505b3048a1c7b731eb931dfc Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 14 Jul 2022 20:51:38 -0500 Subject: [PATCH 219/220] Revert "Order groups by most recent chat activity (#650)" This reverts commit 6e1aa4b0f458deac09e56eaaebcc30a5c80bce62. --- common/group.ts | 3 -- functions/src/create-notification.ts | 49 +++++++-------------- functions/src/on-create-comment-on-group.ts | 18 +++++--- functions/src/on-update-group.ts | 10 +---- web/components/groups/groups-button.tsx | 4 +- web/components/nav/sidebar.tsx | 20 ++------- web/components/user-page.tsx | 5 +-- web/hooks/use-group.ts | 27 +++++------- web/lib/firebase/groups.ts | 19 +++----- web/pages/group/[...slugs]/index.tsx | 23 ++++------ web/pages/groups.tsx | 6 +-- 11 files changed, 59 insertions(+), 125 deletions(-) diff --git a/common/group.ts b/common/group.ts index e367ded7..15348d5a 100644 --- a/common/group.ts +++ b/common/group.ts @@ -11,11 +11,8 @@ export type Group = { contractIds: string[] chatDisabled?: boolean - mostRecentChatActivityTime?: number - mostRecentContractAddedTime?: number } export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome'] -export const GROUP_CHAT_SLUG = 'chat' diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 4c42b00e..1fb6c3af 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -15,11 +15,11 @@ import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' -import { Group, GROUP_CHAT_SLUG } from '../../common/group' +import { Group } from '../../common/group' const firestore = admin.firestore() type user_to_reason_texts = { - [userId: string]: { reason: notification_reason_types } + [userId: string]: { reason: notification_reason_types; isSeeOnHref?: string } } export const createNotification = async ( @@ -72,6 +72,7 @@ export const createNotification = async ( sourceContractSlug: sourceContract?.slug, sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, + isSeenOnHref: userToReasonTexts[userId].isSeeOnHref, } await notificationRef.set(removeUndefinedProps(notification)) }) @@ -276,6 +277,17 @@ export const createNotification = async ( } } + const notifyOtherGroupMembersOfComment = async ( + userToReasons: user_to_reason_texts, + userId: string + ) => { + if (shouldGetNotification(userId, userToReasons)) + userToReasons[userId] = { + reason: 'on_group_you_are_member_of', + isSeeOnHref: sourceSlug, + } + } + const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -286,6 +298,8 @@ export const createNotification = async ( await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) } else if (sourceType === 'user' && relatedUserId) { await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId) + } else if (sourceType === 'comment' && !sourceContract && relatedUserId) { + await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId) } // The following functions need sourceContract to be defined. @@ -403,34 +417,3 @@ export const createBetFillNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } - -export const createGroupCommentNotification = async ( - fromUser: User, - toUserId: string, - comment: Comment, - group: Group, - idempotencyKey: string -) => { - const notificationRef = firestore - .collection(`/users/${toUserId}/notifications`) - .doc(idempotencyKey) - const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}` - const notification: Notification = { - id: idempotencyKey, - userId: toUserId, - reason: 'on_group_you_are_member_of', - createdTime: Date.now(), - isSeen: false, - sourceId: comment.id, - sourceType: 'comment', - sourceUpdateType: 'created', - sourceUserName: fromUser.name, - sourceUserUsername: fromUser.username, - sourceUserAvatarUrl: fromUser.avatarUrl, - sourceText: comment.text, - sourceSlug, - sourceTitle: `${group.name}`, - isSeenOnHref: sourceSlug, - } - await notificationRef.set(removeUndefinedProps(notification)) -} diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts index 0064480f..7217e602 100644 --- a/functions/src/on-create-comment-on-group.ts +++ b/functions/src/on-create-comment-on-group.ts @@ -3,7 +3,7 @@ import { Comment } from '../../common/comment' import * as admin from 'firebase-admin' import { Group } from '../../common/group' import { User } from '../../common/user' -import { createGroupCommentNotification } from './create-notification' +import { createNotification } from './create-notification' const firestore = admin.firestore() export const onCreateCommentOnGroup = functions.firestore @@ -29,17 +29,23 @@ export const onCreateCommentOnGroup = functions.firestore const group = groupSnapshot.data() as Group await firestore.collection('groups').doc(groupId).update({ - mostRecentChatActivityTime: comment.createdTime, + mostRecentActivityTime: comment.createdTime, }) await Promise.all( group.memberIds.map(async (memberId) => { - return await createGroupCommentNotification( + return await createNotification( + comment.id, + 'comment', + 'created', creatorSnapshot.data() as User, + eventId, + comment.text, + undefined, + undefined, memberId, - comment, - group, - eventId + `/group/${group.slug}`, + `${group.name}` ) }) ) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index 3ab2a249..feaa6443 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -12,15 +12,7 @@ export const onUpdateGroup = functions.firestore // ignore the update we just made if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return - - if (prevGroup.contractIds.length < group.contractIds.length) { - await firestore - .collection('groups') - .doc(group.id) - .update({ mostRecentContractAddedTime: Date.now() }) - //TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url - // but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two - } + // TODO: create notification with isSeeOnHref set to the group's /group/questions url await firestore .collection('groups') diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index b510f44d..f3ae77a2 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -17,9 +17,7 @@ import toast from 'react-hot-toast' export function GroupsButton(props: { user: User }) { const { user } = props const [isOpen, setIsOpen] = useState(false) - const groups = useMemberGroups(user.id, undefined, { - by: 'mostRecentChatActivityTime', - }) + const groups = useMemberGroups(user.id) return ( <> diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 8553b506..f4abc6c7 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -24,7 +24,7 @@ import { CreateQuestionButton } from 'web/components/create-question-button' import { useMemberGroups } from 'web/hooks/use-group' import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' -import { Group, GROUP_CHAT_SLUG } from 'common/group' +import { Group } from 'common/group' import { Spacer } from '../layout/spacer' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { setNotificationsAsSeen } from 'web/pages/notifications' @@ -194,14 +194,10 @@ export default function Sidebar(props: { className?: string }) { ? signedOutMobileNavigation : signedInMobileNavigation const memberItems = ( - useMemberGroups( - user?.id, - { withChatEnabled: true }, - { by: 'mostRecentChatActivityTime' } - ) ?? [] + useMemberGroups(user?.id, { withChatEnabled: true }) ?? [] ).map((group: Group) => ({ name: group.name, - href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`, + href: groupPath(group.slug), })) return ( @@ -282,16 +278,8 @@ function GroupsList(props: { // Set notification as seen if our current page is equal to the isSeenOnHref property useEffect(() => { - const currentPageGroupSlug = currentPage.split('/')[2] preferredNotifications.forEach((notification) => { - if ( - notification.isSeenOnHref === currentPage || - // Old chat style group chat notif ended just with the group slug - notification.isSeenOnHref?.endsWith(currentPageGroupSlug) || - // They're on the home page, so if they've a chat notif, they're seeing the chat - (notification.isSeenOnHref?.endsWith(GROUP_CHAT_SLUG) && - currentPage.endsWith(currentPageGroupSlug)) - ) { + if (notification.isSeenOnHref === currentPage) { setNotificationsAsSeen([notification]) } }) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 85d70e86..be3f3ac4 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -38,7 +38,6 @@ import { GroupsButton } from 'web/components/groups/groups-button' import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { filterDefined } from 'common/util/array' import { useUserBets } from 'web/hooks/use-user-bets' -import { ReferralsButton } from 'web/components/referrals-button' export function UserLink(props: { name: string @@ -203,9 +202,7 @@ export function UserPage(props: { <Row className="gap-4"> <FollowingButton user={user} /> <FollowersButton user={user} /> - {currentUser?.username === 'Ian' && ( - <ReferralsButton user={user} currentUser={currentUser} /> - )} + {/* <ReferralsButton user={user} currentUser={currentUser} /> */} <GroupsButton user={user} /> </Row> diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 4f968005..c3098ba4 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -32,26 +32,19 @@ export const useGroups = () => { export const useMemberGroups = ( userId: string | null | undefined, - options?: { withChatEnabled: boolean }, - sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' } + options?: { withChatEnabled: boolean } ) => { const [memberGroups, setMemberGroups] = useState<Group[] | undefined>() useEffect(() => { if (userId) - return listenForMemberGroups( - userId, - (groups) => { - if (options?.withChatEnabled) - return setMemberGroups( - filterDefined( - groups.filter((group) => group.chatDisabled !== true) - ) - ) - return setMemberGroups(groups) - }, - sort - ) - }, [options?.withChatEnabled, sort, userId]) + return listenForMemberGroups(userId, (groups) => { + if (options?.withChatEnabled) + return setMemberGroups( + filterDefined(groups.filter((group) => group.chatDisabled !== true)) + ) + return setMemberGroups(groups) + }) + }, [options?.withChatEnabled, userId]) return memberGroups } @@ -95,7 +88,7 @@ export async function listMembers(group: Group, max?: number) { const { memberIds } = group const numToRetrieve = max ?? memberIds.length if (memberIds.length === 0) return [] - if (numToRetrieve > 100) + if (numToRetrieve) return (await getUsers()).filter((user) => group.memberIds.includes(user.id) ) diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index e49b012a..6d695b7f 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -7,7 +7,7 @@ import { where, } from 'firebase/firestore' import { sortBy, uniq } from 'lodash' -import { Group, GROUP_CHAT_SLUG } from 'common/group' +import { Group } from 'common/group' import { updateContract } from './contracts' import { coll, @@ -22,7 +22,7 @@ export const groups = coll<Group>('groups') export function groupPath( groupSlug: string, - subpath?: 'edit' | 'questions' | 'about' | typeof GROUP_CHAT_SLUG | 'rankings' + subpath?: 'edit' | 'questions' | 'about' | 'chat' | 'rankings' ) { return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` } @@ -62,21 +62,12 @@ export function listenForGroup( export function listenForMemberGroups( userId: string, - setGroups: (groups: Group[]) => void, - sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' } + setGroups: (groups: Group[]) => void ) { const q = query(groups, where('memberIds', 'array-contains', userId)) - const sorter = (group: Group) => { - if (sort?.by === 'mostRecentChatActivityTime') { - return group.mostRecentChatActivityTime ?? group.mostRecentActivityTime - } - if (sort?.by === 'mostRecentContractAddedTime') { - return group.mostRecentContractAddedTime ?? group.mostRecentActivityTime - } - return group.mostRecentActivityTime - } + return listenForValues<Group>(q, (groups) => { - const sorted = sortBy(groups, [(group) => -sorter(group)]) + const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime]) setGroups(sorted) }) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 3fa64964..a364de43 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,6 +1,6 @@ import { take, sortBy, debounce } from 'lodash' -import { Group, GROUP_CHAT_SLUG } from 'common/group' +import { Group } from 'common/group' import { Page } from 'web/components/page' import { listAllBets } from 'web/lib/firebase/bets' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' @@ -21,7 +21,7 @@ import { } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' +import { listMembers, useGroup } from 'web/hooks/use-group' import { useRouter } from 'next/router' import { scoreCreators, scoreTraders } from 'common/scoring' import { Leaderboard } from 'web/components/leaderboard' @@ -114,7 +114,7 @@ export async function getStaticPaths() { } const groupSubpages = [ undefined, - GROUP_CHAT_SLUG, + 'chat', 'questions', 'rankings', 'about', @@ -218,7 +218,7 @@ export default function GroupPage(props: { ) : ( <LoadingIndicator /> ), - href: groupPath(group.slug, GROUP_CHAT_SLUG), + href: groupPath(group.slug, 'chat'), }, ]), { @@ -246,7 +246,7 @@ export default function GroupPage(props: { href: groupPath(group.slug, 'about'), }, ] - const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG) + const tabIndex = tabs.map((t) => t.title).indexOf(page ?? 'chat') return ( <Page rightSidebar={rightSidebar} className="!pb-0"> <SEO @@ -403,7 +403,7 @@ function GroupOverview(props: { </Row> )} <Col className={'mt-2'}> - <GroupMemberSearch members={members} group={group} /> + <GroupMemberSearch members={members} /> </Col> </Col> </> @@ -426,16 +426,9 @@ function SearchBar(props: { setQuery: (query: string) => void }) { ) } -function GroupMemberSearch(props: { members: User[]; group: Group }) { +function GroupMemberSearch(props: { members: User[] }) { const [query, setQuery] = useState('') - const { group } = props - let { members } = props - - // Use static members on load, but also listen to member changes: - const listenToMembers = useMembers(group) - if (listenToMembers) { - members = listenToMembers - } + const { members } = props // TODO use find-active-contracts to sort by? const matches = sortBy(members, [(member) => member.name]).filter( diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 87ac1501..2523b789 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -79,11 +79,7 @@ export default function Groups(props: { ) const matchesOrderedByRecentActivity = sortBy(groups, [ - (group) => - -1 * - (group.mostRecentChatActivityTime ?? - group.mostRecentContractAddedTime ?? - group.mostRecentActivityTime), + (group) => -1 * group.mostRecentActivityTime, ]).filter( (g) => checkAgainstQuery(query, g.name) || From 590c63e911340f088907124369868aad6e340410 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 14 Jul 2022 21:27:00 -0500 Subject: [PATCH 220/220] Small fixes for limit order table --- web/components/bets-list.tsx | 18 ++++++++++-------- web/components/limit-bets.tsx | 8 ++++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index e57b0c38..db6b0d05 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -45,7 +45,7 @@ import { useUnfilledBets } from 'web/hooks/use-bets' import { LimitBet } from 'common/bet' import { floatingEqual } from 'common/util/math' import { Pagination } from './pagination' -import { LimitBets } from './limit-bets' +import { LimitOrderTable } from './limit-bets' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' @@ -364,18 +364,20 @@ function ContractBets(props: { {contract.mechanism === 'cpmm-1' && limitBets.length > 0 && ( <> - <div className="bg-gray-50 px-4 py-2">Your limit orders</div> - <LimitBets - className="max-w-md px-2 py-0 sm:px-4" - contract={contract} - bets={limitBets} - hideLabel - /> + <div className="max-w-md"> + <div className="bg-gray-50 px-4 py-2">Limit orders</div> + <LimitOrderTable + contract={contract} + limitBets={limitBets} + isYou={true} + /> + </div> </> )} <Spacer h={4} /> + <div className="bg-gray-50 px-4 py-2">Bets</div> <ContractBetsTable contract={contract} bets={bets} diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx index f81d4294..93647a5e 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -16,7 +16,6 @@ import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' export function LimitBets(props: { contract: CPMMBinaryContract | PseudoNumericContract bets: LimitBet[] - hideLabel?: boolean className?: string }) { const { contract, bets, className } = props @@ -67,20 +66,21 @@ export function LimitBets(props: { ) } -function LimitOrderTable(props: { +export function LimitOrderTable(props: { limitBets: LimitBet[] contract: CPMMBinaryContract | PseudoNumericContract isYou: boolean }) { const { limitBets, contract, isYou } = props + const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' return ( <table className="table-compact table w-full rounded text-gray-500"> <thead> - {!isYou && <th>User</th>} + {!isYou && <th></th>} <th>Outcome</th> <th>Amount</th> - <th>Prob</th> + <th>{isPseudoNumeric ? 'Value' : 'Prob'}</th> {isYou && <th></th>} </thead> <tbody>