From aa717a767dd5867f8d1bd880e89b992e95152cfb Mon Sep 17 00:00:00 2001 From: mantikoros Date: Wed, 12 Oct 2022 16:05:09 -0500 Subject: [PATCH 01/26] backfill subsidyPool --- .../src/scripts/backfill-subsidy-pool.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 functions/src/scripts/backfill-subsidy-pool.ts diff --git a/functions/src/scripts/backfill-subsidy-pool.ts b/functions/src/scripts/backfill-subsidy-pool.ts new file mode 100644 index 00000000..092e026d --- /dev/null +++ b/functions/src/scripts/backfill-subsidy-pool.ts @@ -0,0 +1,24 @@ +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' + +initAdmin() +const firestore = admin.firestore() + +if (require.main === module) { + const contractsRef = firestore.collection('contracts') + contractsRef.get().then(async (contractsSnaps) => { + + console.log(`Loaded ${contractsSnaps.size} contracts.`) + + const needsFilling = contractsSnaps.docs.filter((ct) => { + return !('subsidyPool' in ct.data()) + }) + + console.log(`Found ${needsFilling.length} contracts to update.`) + await Promise.all( + needsFilling.map((ct) => ct.ref.update({ subsidyPool: 0 })) + ) + + console.log(`Updated all contracts.`) + }) +} From c44f223064971077bd21f1aacdb929bab9ad68c8 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Wed, 12 Oct 2022 14:07:07 -0700 Subject: [PATCH 02/26] Fix some hydration issues (#1033) * Extract `useIsClient` hook * Fix a bunch of hydration bugs relevant to the contract page --- web/components/contract/contract-details.tsx | 19 +++++++++++++------ web/components/contract/contract-mention.tsx | 4 +++- web/components/feed/copy-link-date-time.tsx | 4 +++- web/components/relative-timestamp.tsx | 12 +++--------- web/hooks/use-is-client.ts | 7 +++++++ 5 files changed, 29 insertions(+), 17 deletions(-) create mode 100644 web/hooks/use-is-client.ts diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index a277ae4d..1ed2c49d 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -34,6 +34,7 @@ import { ExtraContractActionsRow } from './extra-contract-actions-row' import { GroupLink } from 'common/group' import { Subtitle } from '../subtitle' import { useIsMobile } from 'web/hooks/use-is-mobile' +import { useIsClient } from 'web/hooks/use-is-client' import { BountiedContractBadge, BountiedContractSmallBadge, @@ -52,22 +53,23 @@ export function MiscDetails(props: { const { volume, closeTime, isResolved, createdTime, resolutionTime } = contract + const isClient = useIsClient() const isNew = createdTime > Date.now() - DAY_MS && !isResolved const groupToDisplay = getGroupLinkToDisplay(contract) return ( - {showTime === 'close-date' ? ( + {isClient && showTime === 'close-date' ? ( {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '} {fromNow(closeTime || 0)} - ) : showTime === 'resolve-date' && resolutionTime !== undefined ? ( + ) : isClient && showTime === 'resolve-date' && resolutionTime ? ( {'Resolved '} - {fromNow(resolutionTime || 0)} + {fromNow(resolutionTime)} ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( @@ -390,6 +392,7 @@ function EditableCloseDate(props: { }) { const { closeTime, contract, isCreator, disabled } = props + const isClient = useIsClient() const dayJsCloseTime = dayjs(closeTime) const dayJsNow = dayjs() @@ -452,7 +455,7 @@ function EditableCloseDate(props: { className="w-full shrink-0 sm:w-fit" onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseDate(e.target.value)} - min={Date.now()} + min={isClient ? Date.now() : undefined} value={closeDate} /> Date.now() ? 'Trading ends:' : 'Trading ended:'} + text={ + isClient && closeTime <= Date.now() + ? 'Trading ended:' + : 'Trading ends:' + } time={closeTime} > !disabled && isCreator && setIsEditingCloseTime(true)} > - {isSameDay ? ( + {isSameDay && isClient ? ( {fromNow(closeTime)} ) : isSameYear ? ( dayJsCloseTime.format('MMM D') diff --git a/web/components/contract/contract-mention.tsx b/web/components/contract/contract-mention.tsx index 09e47c79..a5fa8879 100644 --- a/web/components/contract/contract-mention.tsx +++ b/web/components/contract/contract-mention.tsx @@ -6,17 +6,19 @@ import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts' import { fromNow } from 'web/lib/util/time' import { BinaryContractOutcomeLabel } from '../outcome-label' import { getColor } from './quick-bet' +import { useIsClient } from 'web/hooks/use-is-client' export function ContractMention(props: { contract: Contract }) { const { contract } = props const { outcomeType, resolution } = contract const probTextColor = `text-${getColor(contract)}` + const isClient = useIsClient() return ( {contract.question} diff --git a/web/components/feed/copy-link-date-time.tsx b/web/components/feed/copy-link-date-time.tsx index 6b6b911a..9f2d3da4 100644 --- a/web/components/feed/copy-link-date-time.tsx +++ b/web/components/feed/copy-link-date-time.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { fromNow } from 'web/lib/util/time' import { ToastClipboard } from 'web/components/toast-clipboard' import { LinkIcon } from '@heroicons/react/outline' +import { useIsClient } from 'web/hooks/use-is-client' export function CopyLinkDateTimeComponent(props: { prefix: string @@ -14,6 +15,7 @@ export function CopyLinkDateTimeComponent(props: { className?: string }) { const { prefix, slug, elementId, createdTime, className } = props + const isClient = useIsClient() const [showToast, setShowToast] = useState(false) function copyLinkToComment( @@ -36,7 +38,7 @@ export function CopyLinkDateTimeComponent(props: { 'text-greyscale-4 hover:bg-greyscale-1.5 mx-1 whitespace-nowrap rounded-sm px-1 text-xs transition-colors' } > - {fromNow(createdTime)} + {isClient && fromNow(createdTime)} {showToast && } diff --git a/web/components/relative-timestamp.tsx b/web/components/relative-timestamp.tsx index eb03e324..6f343a73 100644 --- a/web/components/relative-timestamp.tsx +++ b/web/components/relative-timestamp.tsx @@ -1,22 +1,16 @@ import { DateTimeTooltip } from './datetime-tooltip' -import React, { useEffect, useState } from 'react' import { fromNow } from 'web/lib/util/time' +import { useIsClient } from 'web/hooks/use-is-client' export function RelativeTimestamp(props: { time: number }) { const { time } = props - const [isClient, setIsClient] = useState(false) - - useEffect(() => { - // Only render on client to prevent difference from server. - setIsClient(true) - }, []) - + const isClient = useIsClient() return ( - {isClient ? fromNow(time) : ''} + {isClient && fromNow(time)} ) } diff --git a/web/hooks/use-is-client.ts b/web/hooks/use-is-client.ts new file mode 100644 index 00000000..a192fd26 --- /dev/null +++ b/web/hooks/use-is-client.ts @@ -0,0 +1,7 @@ +import { useEffect, useState } from 'react' + +export const useIsClient = () => { + const [isClient, setIsClient] = useState(false) + useEffect(() => setIsClient(true), []) + return isClient +} From ae39c1175b515a2eb08d8c32aba46bdcda1517a2 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 12 Oct 2022 16:21:37 -0500 Subject: [PATCH 03/26] Better resolve market payouts (#1038) * Check payout preconditions first. Try to pay out market in 1 transaction. * Format * toBatch => lodash's chunk --- functions/src/resolve-market.ts | 77 ++++++++++++------------ functions/src/utils.ts | 100 ++++++++++++++++++++++++-------- 2 files changed, 117 insertions(+), 60 deletions(-) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 4230f0ac..f29ff124 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,6 +1,6 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { mapValues, groupBy, sumBy } from 'lodash' +import { mapValues, groupBy, sumBy, uniqBy } from 'lodash' import { Contract, @@ -15,14 +15,14 @@ import { getValues, isProd, log, - payUser, + payUsers, + payUsersMultipleTransactions, revalidateStaticProps, } from './utils' import { getLoanPayouts, getPayouts, groupPayoutsByUser, - Payout, } from '../../common/payouts' import { isAdmin, isManifoldId } from '../../common/envs/constants' import { removeUndefinedProps } from '../../common/util/object' @@ -131,15 +131,19 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { (doc) => doc.data() as LiquidityProvision ) - const { payouts, creatorPayout, liquidityPayouts, collectedFees } = - getPayouts( - outcome, - contract, - bets, - liquidities, - resolutions, - resolutionProbability - ) + const { + payouts: traderPayouts, + creatorPayout, + liquidityPayouts, + collectedFees, + } = getPayouts( + outcome, + contract, + bets, + liquidities, + resolutions, + resolutionProbability + ) const updatedContract = { ...contract, @@ -156,30 +160,43 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { subsidyPool: 0, } - await contractDoc.update(updatedContract) - - console.log('contract ', contractId, 'resolved to:', outcome) - const openBets = bets.filter((b) => !b.isSold && !b.sale) const loanPayouts = getLoanPayouts(openBets) + const payouts = [ + { userId: creatorId, payout: creatorPayout, deposit: creatorPayout }, + ...liquidityPayouts.map((p) => ({ ...p, deposit: p.payout })), + ...traderPayouts, + ...loanPayouts, + ] + if (!isProd()) console.log( - 'payouts:', - payouts, + 'trader payouts:', + traderPayouts, 'creator payout:', creatorPayout, - 'liquidity payout:' + 'liquidity payout:', + liquidityPayouts, + 'loan payouts:', + loanPayouts ) - if (creatorPayout) - await processPayouts([{ userId: creatorId, payout: creatorPayout }], true) + const userCount = uniqBy(payouts, 'userId').length - await processPayouts(liquidityPayouts, true) + if (userCount <= 499) { + await firestore.runTransaction(async (transaction) => { + payUsers(transaction, payouts) + transaction.update(contractDoc, updatedContract) + }) + } else { + await payUsersMultipleTransactions(payouts) + await contractDoc.update(updatedContract) + } + + console.log('contract ', contractId, 'resolved to:', outcome) - await processPayouts([...payouts, ...loanPayouts]) await undoUniqueBettorRewardsIfCancelResolution(contract, outcome) - await revalidateStaticProps(getContractPath(contract)) const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) @@ -211,18 +228,6 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { return updatedContract }) -const processPayouts = async (payouts: Payout[], isDeposit = false) => { - const userPayouts = groupPayoutsByUser(payouts) - - const payoutPromises = Object.entries(userPayouts).map(([userId, payout]) => - payUser(userId, payout, isDeposit) - ) - - return await Promise.all(payoutPromises) - .catch((e) => ({ status: 'error', message: e })) - .then(() => ({ status: 'success' })) -} - function getResolutionParams(contract: Contract, body: string) { const { outcomeType } = contract diff --git a/functions/src/utils.ts b/functions/src/utils.ts index e0cd269a..9516db64 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -1,7 +1,8 @@ import * as admin from 'firebase-admin' import fetch from 'node-fetch' +import { FieldValue, Transaction } from 'firebase-admin/firestore' +import { chunk, groupBy, mapValues, sumBy } from 'lodash' -import { chunk } from 'lodash' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' import { Group } from '../../common/group' @@ -128,38 +129,29 @@ export const getUserByUsername = async (username: string) => { return snap.empty ? undefined : (snap.docs[0].data() as User) } +const firestore = admin.firestore() + const updateUserBalance = ( + transaction: Transaction, userId: string, - delta: number, - isDeposit = false + balanceDelta: number, + depositDelta: number ) => { - const firestore = admin.firestore() - return firestore.runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) return - const user = userSnap.data() as User + const userDoc = firestore.doc(`users/${userId}`) - const newUserBalance = user.balance + delta - - // if (newUserBalance < 0) - // throw new Error( - // `User (${userId}) balance cannot be negative: ${newUserBalance}` - // ) - - if (isDeposit) { - const newTotalDeposits = (user.totalDeposits || 0) + delta - transaction.update(userDoc, { totalDeposits: newTotalDeposits }) - } - - transaction.update(userDoc, { balance: newUserBalance }) + // Note: Balance is allowed to go negative. + transaction.update(userDoc, { + balance: FieldValue.increment(balanceDelta), + totalDeposits: FieldValue.increment(depositDelta), }) } export const payUser = (userId: string, payout: number, isDeposit = false) => { if (!isFinite(payout)) throw new Error('Payout is not finite: ' + payout) - return updateUserBalance(userId, payout, isDeposit) + return firestore.runTransaction(async (transaction) => { + updateUserBalance(transaction, userId, payout, isDeposit ? payout : 0) + }) } export const chargeUser = ( @@ -170,7 +162,67 @@ export const chargeUser = ( if (!isFinite(charge) || charge <= 0) throw new Error('User charge is not positive: ' + charge) - return updateUserBalance(userId, -charge, isAnte) + return payUser(userId, -charge, isAnte) +} + +const checkAndMergePayouts = ( + payouts: { + userId: string + payout: number + deposit?: number + }[] +) => { + for (const { payout, deposit } of payouts) { + if (!isFinite(payout)) { + throw new Error('Payout is not finite: ' + payout) + } + if (deposit !== undefined && !isFinite(deposit)) { + throw new Error('Deposit is not finite: ' + deposit) + } + } + + const groupedPayouts = groupBy(payouts, 'userId') + return Object.values( + mapValues(groupedPayouts, (payouts, userId) => ({ + userId, + payout: sumBy(payouts, 'payout'), + deposit: sumBy(payouts, (p) => p.deposit ?? 0), + })) + ) +} + +// Max 500 users in one transaction. +export const payUsers = ( + transaction: Transaction, + payouts: { + userId: string + payout: number + deposit?: number + }[] +) => { + const mergedPayouts = checkAndMergePayouts(payouts) + for (const { userId, payout, deposit } of mergedPayouts) { + updateUserBalance(transaction, userId, payout, deposit) + } +} + +export const payUsersMultipleTransactions = async ( + payouts: { + userId: string + payout: number + deposit?: number + }[] +) => { + const mergedPayouts = checkAndMergePayouts(payouts) + const payoutChunks = chunk(mergedPayouts, 500) + + for (const payoutChunk of payoutChunks) { + await firestore.runTransaction(async (transaction) => { + for (const { userId, payout, deposit } of payoutChunk) { + updateUserBalance(transaction, userId, payout, deposit) + } + }) + } } export const getContractPath = (contract: Contract) => { From 204d302d87d0676ac5257cf1b6a9bf9aa482f5f5 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Wed, 12 Oct 2022 16:57:21 -0500 Subject: [PATCH 04/26] portfolio copy: "Open" => "Active" --- web/components/bets-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 2b1f9243..5fb64e9e 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -205,7 +205,7 @@ export function BetsList(props: { user: User }) { value={filter} onChange={(e) => setFilter(e.target.value as BetFilter)} > - + From e2dc4c6b8f1cf6d73af4b964f4d78abf05e806bc Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 12 Oct 2022 17:30:19 -0500 Subject: [PATCH 05/26] Resolve markets again script --- functions/src/resolve-market.ts | 23 ++++++-- .../src/scripts/resolve-markets-again.ts | 59 +++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 functions/src/scripts/resolve-markets-again.ts diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index f29ff124..347bf055 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -36,6 +36,7 @@ import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, } from '../../common/antes' +import { User } from 'common/user' const bodySchema = z.object({ contractId: z.string(), @@ -89,13 +90,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, closeTime } = contract + const { creatorId } = contract const firebaseUser = await admin.auth().getUser(auth.uid) - const { value, resolutions, probabilityInt, outcome } = getResolutionParams( - contract, - req.body - ) + const resolutionParams = getResolutionParams(contract, req.body) if ( creatorId !== auth.uid && @@ -109,6 +107,16 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { const creator = await getUser(creatorId) if (!creator) throw new APIError(500, 'Creator not found') + return await resolveMarket(contract, creator, resolutionParams) +}) + +export const resolveMarket = async ( + contract: Contract, + creator: User, + { value, resolutions, probabilityInt, outcome }: ResolutionParams +) => { + const { creatorId, closeTime, id: contractId } = contract + const resolutionProbability = probabilityInt !== undefined ? probabilityInt / 100 : undefined @@ -183,6 +191,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { ) const userCount = uniqBy(payouts, 'userId').length + const contractDoc = firestore.doc(`contracts/${contractId}`) if (userCount <= 499) { await firestore.runTransaction(async (transaction) => { @@ -226,7 +235,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { ) return updatedContract -}) +} function getResolutionParams(contract: Contract, body: string) { const { outcomeType } = contract @@ -292,6 +301,8 @@ function getResolutionParams(contract: Contract, body: string) { throw new APIError(500, `Invalid outcome type: ${outcomeType}`) } +type ResolutionParams = ReturnType + function validateAnswer( contract: FreeResponseContract | MultipleChoiceContract, answer: number diff --git a/functions/src/scripts/resolve-markets-again.ts b/functions/src/scripts/resolve-markets-again.ts new file mode 100644 index 00000000..c1ff3156 --- /dev/null +++ b/functions/src/scripts/resolve-markets-again.ts @@ -0,0 +1,59 @@ +import { initAdmin } from './script-init' +initAdmin() + +import { zip } from 'lodash' +import { filterDefined } from 'common/util/array' +import { resolveMarket } from '../resolve-market' +import { getContract, getUser } from '../utils' + +if (require.main === module) { + const contractIds = process.argv.slice(2) + if (contractIds.length === 0) { + throw new Error('No contract ids provided') + } + resolveMarketsAgain(contractIds).then(() => process.exit(0)) +} + +async function resolveMarketsAgain(contractIds: string[]) { + const maybeContracts = await Promise.all(contractIds.map(getContract)) + if (maybeContracts.some((c) => !c)) { + throw new Error('Invalid contract id') + } + const contracts = filterDefined(maybeContracts) + + const maybeCreators = await Promise.all( + contracts.map((c) => getUser(c.creatorId)) + ) + if (maybeCreators.some((c) => !c)) { + throw new Error('No creator found') + } + const creators = filterDefined(maybeCreators) + + if ( + !contracts.every((c) => c.resolution === 'YES' || c.resolution === 'NO') + ) { + throw new Error('Only YES or NO resolutions supported') + } + + const resolutionParams = contracts.map((c) => ({ + outcome: c.resolution as string, + value: undefined, + probabilityInt: undefined, + resolutions: undefined, + })) + + const params = zip(contracts, creators, resolutionParams) + + for (const [contract, creator, resolutionParams] of params) { + if (contract && creator && resolutionParams) { + console.log('Resolving', contract.question) + try { + await resolveMarket(contract, creator, resolutionParams) + } catch (e) { + console.log(e) + } + } + } + + console.log(`Resolved all contracts.`) +} From 7a412fdb0d6eb0c18fa3965b422c30031c28bcd0 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Wed, 12 Oct 2022 17:29:56 -0500 Subject: [PATCH 06/26] add key props --- web/components/groups/group-overview.tsx | 2 +- web/pages/home/index.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/web/components/groups/group-overview.tsx b/web/components/groups/group-overview.tsx index 1e8f7ee7..fc2faa14 100644 --- a/web/components/groups/group-overview.tsx +++ b/web/components/groups/group-overview.tsx @@ -264,7 +264,7 @@ export function PinnedItems(props: { )} {pinned.map((element, index) => ( -
+
{element} {editMode && onDeleteClicked(index)} />} diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index 87f2cfa9..1fb0c9ae 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -277,6 +277,7 @@ function renderSections( if (id === 'featured') { return ( Date: Wed, 12 Oct 2022 17:33:11 -0500 Subject: [PATCH 07/26] fix welcome dialog page freezing bug --- web/components/onboarding/welcome.tsx | 72 ++++++++++++++------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/web/components/onboarding/welcome.tsx b/web/components/onboarding/welcome.tsx index 2ef24764..692aa656 100644 --- a/web/components/onboarding/welcome.tsx +++ b/web/components/onboarding/welcome.tsx @@ -54,47 +54,48 @@ export default function Welcome() { if (isTwitch || !user || (!user.shouldShowWelcome && !groupSelectorOpen)) return <> - return ( - <> + if (groupSelectorOpen) + return ( setGroupSelectorOpen(false)} /> + ) - - - {page === 0 && } - {page === 1 && } - {page === 2 && } - {page === 3 && } - - - - - - - toggleOpen(false)} - > - I got the gist, exit welcome - - + return ( + + + {page === 0 && } + {page === 1 && } + {page === 2 && } + {page === 3 && } + + + + + + + toggleOpen(false)} + > + I got the gist, exit welcome + - - + + ) } @@ -117,6 +118,7 @@ function PageIndicator(props: { page: number; totalpages: number }) { {[...Array(totalpages)].map((e, i) => (
Date: Wed, 12 Oct 2022 17:39:13 -0500 Subject: [PATCH 08/26] Return listener --- web/hooks/use-global-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/hooks/use-global-config.ts b/web/hooks/use-global-config.ts index 8653a731..32200b8d 100644 --- a/web/hooks/use-global-config.ts +++ b/web/hooks/use-global-config.ts @@ -6,7 +6,7 @@ export const useGlobalConfig = () => { const [globalConfig, setGlobalConfig] = useState(null) useEffect(() => { - listenForGlobalConfig(setGlobalConfig) + return listenForGlobalConfig(setGlobalConfig) }, []) return globalConfig } From 789bec2a4f2eac64094d6bd69bb43c53facff104 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 12 Oct 2022 17:49:04 -0500 Subject: [PATCH 09/26] Expand replies by default (quick fix for comment links not working) --- web/components/feed/feed-comments.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 07a0d214..9bdeed53 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -37,7 +37,7 @@ export function FeedCommentThread(props: { }) { const { contract, threadComments, tips, parentComment } = props const [replyTo, setReplyTo] = useState() - const [seeReplies, setSeeReplies] = useState(false) + const [seeReplies, setSeeReplies] = useState(true) const user = useUser() const onSubmitComment = useEvent(() => setReplyTo(undefined)) From 9eff69be75041f78a809839438b5c7eb831396c9 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Wed, 12 Oct 2022 17:25:17 -0700 Subject: [PATCH 10/26] Add /createcomment API endpoint (#946) * /dream api: Upload StableDiffusion image to Firestore * Minor tweaks * Set content type on uploaded image This makes it so the image doesn't auto-download when opened in a new tab * Allow users to dream directly from within Manifold * Remove unused import * Implement a /comment endpoint which supports html and markdown * Upgrade @tiptap/core to latest * Update all tiptap deps to beta.199 * Add @tiptap/suggestion * Import @tiptap/html in the right place * ... add deps everywhere So I have no idea how common deps work apparently * Add tiptap/suggestion too * Clean up dream * More cleanups * Rework /comment endpoint * Move API to /comment * Change imports in case that matters * Add a couple todos * Dynamically import micromark * Parallellize gsutil with -m option * Adding comments via api working, editor.tsx erroring out * Unused import * Remove disabled state from useTextEditor Co-authored-by: Ian Philips --- common/package.json | 12 +- common/util/parse.ts | 5 + functions/package.json | 18 +- functions/src/create-comment.ts | 105 +++++ functions/src/index.ts | 3 + functions/src/serve.ts | 2 + .../contract/contract-description.tsx | 4 - web/components/create-post.tsx | 1 - web/components/editor.tsx | 7 +- web/components/groups/group-overview-post.tsx | 4 - web/package.json | 18 +- web/pages/api/v0/comment.ts | 23 + web/pages/create.tsx | 1 - web/pages/date-docs/create.tsx | 4 +- web/pages/post/[...slugs]/index.tsx | 4 - yarn.lock | 427 ++++++++++-------- 16 files changed, 402 insertions(+), 236 deletions(-) create mode 100644 functions/src/create-comment.ts create mode 100644 web/pages/api/v0/comment.ts diff --git a/common/package.json b/common/package.json index 52195398..11f92e89 100644 --- a/common/package.json +++ b/common/package.json @@ -8,11 +8,13 @@ }, "sideEffects": false, "dependencies": { - "@tiptap/core": "2.0.0-beta.182", - "@tiptap/extension-image": "2.0.0-beta.30", - "@tiptap/extension-link": "2.0.0-beta.43", - "@tiptap/extension-mention": "2.0.0-beta.102", - "@tiptap/starter-kit": "2.0.0-beta.191", + "@tiptap/core": "2.0.0-beta.199", + "@tiptap/extension-image": "2.0.0-beta.199", + "@tiptap/extension-link": "2.0.0-beta.199", + "@tiptap/extension-mention": "2.0.0-beta.199", + "@tiptap/html": "2.0.0-beta.199", + "@tiptap/starter-kit": "2.0.0-beta.199", + "@tiptap/suggestion": "2.0.0-beta.199", "lodash": "4.17.21" }, "devDependencies": { diff --git a/common/util/parse.ts b/common/util/parse.ts index 04faffe4..6500d6ef 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,4 +1,5 @@ import { generateText, JSONContent } from '@tiptap/core' +import { generateJSON } from '@tiptap/html' // Tiptap starter extensions import { Blockquote } from '@tiptap/extension-blockquote' import { Bold } from '@tiptap/extension-bold' @@ -86,3 +87,7 @@ export function richTextToString(text?: JSONContent) { if (!text) return '' return generateText(text, stringParseExts) } + +export function htmlToRichText(html: string) { + return generateJSON(html, stringParseExts) +} diff --git a/functions/package.json b/functions/package.json index cd2a9ec5..399b307a 100644 --- a/functions/package.json +++ b/functions/package.json @@ -15,9 +15,9 @@ "dev": "nodemon src/serve.ts", "localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --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:update-local-from-remote": "yarn db:backup-remote && gsutil -m 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)", + "db:rename-remote-backup-folder": "gsutil -m 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:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty" @@ -26,11 +26,13 @@ "dependencies": { "@amplitude/node": "1.10.0", "@google-cloud/functions-framework": "3.1.2", - "@tiptap/core": "2.0.0-beta.182", - "@tiptap/extension-image": "2.0.0-beta.30", - "@tiptap/extension-link": "2.0.0-beta.43", - "@tiptap/extension-mention": "2.0.0-beta.102", - "@tiptap/starter-kit": "2.0.0-beta.191", + "@tiptap/core": "2.0.0-beta.199", + "@tiptap/extension-image": "2.0.0-beta.199", + "@tiptap/extension-link": "2.0.0-beta.199", + "@tiptap/extension-mention": "2.0.0-beta.199", + "@tiptap/html": "2.0.0-beta.199", + "@tiptap/starter-kit": "2.0.0-beta.199", + "@tiptap/suggestion": "2.0.0-beta.199", "cors": "2.8.5", "dayjs": "1.11.4", "express": "4.18.1", @@ -38,6 +40,7 @@ "firebase-functions": "3.21.2", "lodash": "4.17.21", "mailgun-js": "0.22.0", + "marked": "4.1.1", "module-alias": "2.2.2", "node-fetch": "2", "stripe": "8.194.0", @@ -45,6 +48,7 @@ }, "devDependencies": { "@types/mailgun-js": "0.22.12", + "@types/marked": "4.0.7", "@types/module-alias": "2.0.1", "@types/node-fetch": "2.6.2", "firebase-functions-test": "0.3.3", diff --git a/functions/src/create-comment.ts b/functions/src/create-comment.ts new file mode 100644 index 00000000..e0191276 --- /dev/null +++ b/functions/src/create-comment.ts @@ -0,0 +1,105 @@ +import * as admin from 'firebase-admin' + +import { getContract, getUser, log } from './utils' +import { APIError, newEndpoint, validate } from './api' +import { JSONContent } from '@tiptap/core' +import { z } from 'zod' +import { removeUndefinedProps } from '../../common/util/object' +import { htmlToRichText } from '../../common/util/parse' +import { marked } from 'marked' + +const contentSchema: z.ZodType = z.lazy(() => + z.intersection( + z.record(z.any()), + z.object({ + type: z.string().optional(), + attrs: z.record(z.any()).optional(), + content: z.array(contentSchema).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 postSchema = z.object({ + contractId: z.string(), + content: contentSchema.optional(), + html: z.string().optional(), + markdown: z.string().optional(), +}) + +const MAX_COMMENT_JSON_LENGTH = 20000 + +// For now, only supports creating a new top-level comment on a contract. +// Replies, posts, chats are not supported yet. +export const createcomment = newEndpoint({}, async (req, auth) => { + const firestore = admin.firestore() + const { contractId, content, html, markdown } = validate(postSchema, req.body) + + const creator = await getUser(auth.uid) + const contract = await getContract(contractId) + + if (!creator) { + throw new APIError(400, 'No user exists with the authenticated user ID.') + } + if (!contract) { + throw new APIError(400, 'No contract exists with the given ID.') + } + + let contentJson = null + if (content) { + contentJson = content + } else if (html) { + console.log('html', html) + contentJson = htmlToRichText(html) + } else if (markdown) { + const markedParse = marked.parse(markdown) + log('parsed', markedParse) + contentJson = htmlToRichText(markedParse) + log('json', contentJson) + } + + if (!contentJson) { + throw new APIError(400, 'No comment content provided.') + } + + if (JSON.stringify(contentJson).length > MAX_COMMENT_JSON_LENGTH) { + throw new APIError( + 400, + `Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.` + ) + } + + const ref = firestore.collection(`contracts/${contractId}/comments`).doc() + + const comment = removeUndefinedProps({ + id: ref.id, + content: contentJson, + createdTime: Date.now(), + + userId: creator.id, + userName: creator.name, + userUsername: creator.username, + userAvatarUrl: creator.avatarUrl, + + // OnContract fields + commentType: 'contract', + contractId: contractId, + contractSlug: contract.slug, + contractQuestion: contract.question, + }) + + await ref.set(comment) + + return { status: 'success', comment } +}) diff --git a/functions/src/index.ts b/functions/src/index.ts index b64155a3..c4e9e5f7 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -65,6 +65,7 @@ import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' +import { createcomment } from './create-comment' import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' @@ -94,6 +95,7 @@ const claimManalinkFunction = toCloudFunction(claimmanalink) const createMarketFunction = toCloudFunction(createmarket) const addSubsidyFunction = toCloudFunction(addsubsidy) const addCommentBounty = toCloudFunction(addcommentbounty) +const createCommentFunction = toCloudFunction(createcomment) const awardCommentBounty = toCloudFunction(awardcommentbounty) const createGroupFunction = toCloudFunction(creategroup) const resolveMarketFunction = toCloudFunction(resolvemarket) @@ -130,6 +132,7 @@ export { acceptChallenge as acceptchallenge, createPostFunction as createpost, saveTwitchCredentials as savetwitchcredentials, + createCommentFunction as createcomment, addCommentBounty as addcommentbounty, awardCommentBounty as awardcommentbounty, updateMetricsFunction as updatemetrics, diff --git a/functions/src/serve.ts b/functions/src/serve.ts index bc09029d..597e144c 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -19,6 +19,7 @@ import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' +import { createcomment } from './create-comment' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' import { unsubscribe } from './unsubscribe' @@ -53,6 +54,7 @@ addJsonEndpointRoute('/transact', transact) addJsonEndpointRoute('/changeuserinfo', changeuserinfo) addJsonEndpointRoute('/createuser', createuser) addJsonEndpointRoute('/createanswer', createanswer) +addJsonEndpointRoute('/createcomment', createcomment) addJsonEndpointRoute('/placebet', placebet) addJsonEndpointRoute('/cancelbet', cancelbet) addJsonEndpointRoute('/sellbet', sellbet) diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index 855bc750..ecb6358f 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -45,13 +45,11 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) { const { contract, isAdmin } = props const [editing, setEditing] = useState(false) const [editingQ, setEditingQ] = useState(false) - const [isSubmitting, setIsSubmitting] = useState(false) const { editor, upload } = useTextEditor({ // key: `description ${contract.id}`, max: MAX_DESCRIPTION_LENGTH, defaultValue: contract.description, - disabled: isSubmitting, }) async function saveDescription() { @@ -66,10 +64,8 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) { ) } From c2d112e5167279d40fef313f55dee1c1b375beee Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 12 Oct 2022 20:51:20 -0500 Subject: [PATCH 14/26] Position => shares --- web/components/contract/contract-card.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 66d56ee8..c46cd4da 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -427,8 +427,7 @@ export function ContractCardProbChange(props: { )} > -
Position
- {Math.floor(metrics.totalShares[outcome])} {outcome} + {Math.floor(metrics.totalShares[outcome])} {outcome} shares
{dayMetrics && ( From bc6fab399edbc6c061f84f38f3e1b9872a57ca30 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 12 Oct 2022 20:51:48 -0500 Subject: [PATCH 15/26] Make text grayer --- web/components/contract/contract-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index c46cd4da..8942aab0 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -426,7 +426,7 @@ export function ContractCardProbChange(props: { 'items-center justify-between gap-4 pl-6 pr-4 pb-2 text-sm' )} > - + {Math.floor(metrics.totalShares[outcome])} {outcome} shares From 4e5b78f4ee5d34923a2928d917e81e078f49a102 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 12 Oct 2022 21:15:40 -0500 Subject: [PATCH 16/26] Use in-memory store for home featured section data --- web/hooks/use-global-config.ts | 11 ++++++++--- web/hooks/use-persistent-state.ts | 11 +++++++++++ web/pages/home/index.tsx | 13 ++++++++++--- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/web/hooks/use-global-config.ts b/web/hooks/use-global-config.ts index 32200b8d..db8dd9cb 100644 --- a/web/hooks/use-global-config.ts +++ b/web/hooks/use-global-config.ts @@ -1,12 +1,17 @@ import { GlobalConfig } from 'common/globalConfig' -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import { listenForGlobalConfig } from 'web/lib/firebase/globalConfig' +import { inMemoryStore, usePersistentState } from './use-persistent-state' export const useGlobalConfig = () => { - const [globalConfig, setGlobalConfig] = useState(null) + const [globalConfig, setGlobalConfig] = + usePersistentState(null, { + store: inMemoryStore(), + key: 'globalConfig', + }) useEffect(() => { return listenForGlobalConfig(setGlobalConfig) - }, []) + }, [setGlobalConfig]) return globalConfig } diff --git a/web/hooks/use-persistent-state.ts b/web/hooks/use-persistent-state.ts index 4448bb77..ab1cd4a2 100644 --- a/web/hooks/use-persistent-state.ts +++ b/web/hooks/use-persistent-state.ts @@ -89,6 +89,17 @@ export const historyStore = (prefix = '__manifold'): PersistentStore => ({ }, }) +const store: Record = {} + +export const inMemoryStore = (): PersistentStore => ({ + get: (k: string) => { + return store[k] + }, + set: (k: string, v: T | undefined) => { + store[k] = v + }, +}) + export const usePersistentState = ( initial: T, persist?: PersistenceOptions diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index 1fb0c9ae..4d46b12e 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useEffect, useState } from 'react' +import React, { ReactNode, useEffect } from 'react' import Router from 'next/router' import { AdjustmentsIcon, @@ -63,6 +63,10 @@ import { useAllPosts } from 'web/hooks/use-post' import { useGlobalConfig } from 'web/hooks/use-global-config' import { useAdmin } from 'web/hooks/use-admin' import { GlobalConfig } from 'common/globalConfig' +import { + inMemoryStore, + usePersistentState, +} from 'web/hooks/use-persistent-state' export default function Home() { const user = useUser() @@ -105,7 +109,10 @@ export default function Home() { groups?.map((g) => g.slug) ) - const [pinned, setPinned] = useState(null) + const [pinned, setPinned] = usePersistentState(null, { + store: inMemoryStore(), + key: 'home-pinned', + }) useEffect(() => { const pinnedItems = globalConfig?.pinnedItems @@ -139,7 +146,7 @@ export default function Home() { } } getPinned() - }, [globalConfig]) + }, [globalConfig, setPinned]) const isLoading = !user || From 5ba4a9dce75c1629a48fd47a64f7cca92fa24d01 Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Thu, 13 Oct 2022 01:53:26 -0500 Subject: [PATCH 17/26] Inga/tip button (#1043) * added tip jar * made market actions/comments and manalink buttons IconButtons --- web/components/button.tsx | 36 ++ .../contract/contract-info-dialog.tsx | 320 +++++++++--------- .../contract/extra-contract-actions-row.tsx | 11 +- web/components/contract/tip-button.tsx | 67 ++-- web/components/feed/feed-comments.tsx | 92 +++-- web/components/follow-market-button.tsx | 19 +- web/components/manalink-card.tsx | 27 +- web/components/share-icon-button.tsx | 26 +- web/public/custom-components/tipJar.tsx | 23 ++ 9 files changed, 323 insertions(+), 298 deletions(-) create mode 100644 web/public/custom-components/tipJar.tsx diff --git a/web/components/button.tsx b/web/components/button.tsx index af5ad007..8e00a077 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -82,3 +82,39 @@ export function Button(props: { ) } + +export function IconButton(props: { + className?: string + onClick?: MouseEventHandler | undefined + children?: ReactNode + size?: SizeType + type?: 'button' | 'reset' | 'submit' + disabled?: boolean + loading?: boolean +}) { + const { + children, + className, + onClick, + size = 'md', + type = 'button', + disabled = false, + loading, + } = props + + return ( + + ) +} diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index a41be451..3ddeccac 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -19,11 +19,9 @@ import ShortToggle from '../widgets/short-toggle' import { DuplicateContractButton } from '../duplicate-contract-button' import { Row } from '../layout/row' import { BETTORS, User } from 'common/user' -import { Button } from '../button' +import { IconButton } from '../button' import { AddLiquidityButton } from './add-liquidity-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' +import { Tooltip } from '../tooltip' export function ContractInfoDialog(props: { contract: Contract @@ -84,171 +82,173 @@ export function ContractInfoDialog(props: { return ( <> - + + setOpen(true)} + > + - - - + <Modal open={open} setOpen={setOpen}> + <Col className="gap-4 rounded bg-white p-6"> + <Title className="!mt-0 !mb-0" text="This Market" /> - <table className="table-compact table-zebra table w-full text-gray-500"> - <tbody> - <tr> - <td>Type</td> - <td>{typeDisplay}</td> - </tr> - - <tr> - <td>Payout</td> - <td className="flex gap-1"> - {mechanism === 'cpmm-1' ? ( - <> - Fixed{' '} - <InfoTooltip text="Each YES share is worth M$1 if YES wins." /> - </> - ) : ( - <> - Parimutuel{' '} - <InfoTooltip text="Each share is a fraction of the pool. " /> - </> - )} - </td> - </tr> - - <tr> - <td>Market created</td> - <td>{formatTime(createdTime)}</td> - </tr> - - {closeTime && ( + <table className="table-compact table-zebra table w-full text-gray-500"> + <tbody> <tr> - <td>Market close{closeTime > Date.now() ? 's' : 'd'}</td> - <td>{formatTime(closeTime)}</td> + <td>Type</td> + <td>{typeDisplay}</td> </tr> - )} - {resolutionTime && ( <tr> - <td>Market resolved</td> - <td>{formatTime(resolutionTime)}</td> - </tr> - )} - - <tr> - <td> - <span className="mr-1">Volume</span> - <InfoTooltip text="Total amount bought or sold" /> - </td> - <td>{formatMoney(contract.volume)}</td> - </tr> - - <tr> - <td>{capitalize(BETTORS)}</td> - <td>{uniqueBettorCount ?? '0'}</td> - </tr> - - <tr> - <td> - <Row> - <span className="mr-1">Elasticity</span> - <InfoTooltip - text={ - mechanism === 'cpmm-1' - ? 'Probability change between a M$50 bet on YES and NO' - : 'Probability change from a M$100 bet' - } - /> - </Row> - </td> - <td>{formatPercent(elasticity)}</td> - </tr> - - <tr> - <td>Liquidity subsidies</td> - <td> - {mechanism === 'cpmm-1' - ? formatMoney(contract.totalLiquidity) - : formatMoney(100)} - </td> - </tr> - - <tr> - <td>Pool</td> - <td> - {mechanism === 'cpmm-1' && outcomeType === 'BINARY' - ? `${Math.round(pool.YES)} YES, ${Math.round(pool.NO)} NO` - : mechanism === 'cpmm-1' && outcomeType === 'PSEUDO_NUMERIC' - ? `${Math.round(pool.YES)} HIGHER, ${Math.round( - pool.NO - )} LOWER` - : contractPool(contract)} - </td> - </tr> - - {/* Show a path to Firebase if user is an admin, or we're on localhost */} - {(isAdmin || isDev) && ( - <tr> - <td>[ADMIN] Firestore</td> - <td> - <SiteLink href={firestoreConsolePath(id)}> - Console link - </SiteLink> + <td>Payout</td> + <td className="flex gap-1"> + {mechanism === 'cpmm-1' ? ( + <> + Fixed{' '} + <InfoTooltip text="Each YES share is worth M$1 if YES wins." /> + </> + ) : ( + <> + Parimutuel{' '} + <InfoTooltip text="Each share is a fraction of the pool. " /> + </> + )} </td> </tr> - )} - {isAdmin && ( - <tr> - <td>[ADMIN] Featured</td> - <td> - <ShortToggle - on={featured} - setOn={setFeatured} - onChange={onFeaturedToggle} - /> - </td> - </tr> - )} - {user && ( - <tr> - <td>{isAdmin ? '[ADMIN]' : ''} Unlisted</td> - <td> - <ShortToggle - disabled={ - isUnlisted - ? !(isAdmin || (isCreator && wasUnlistedByCreator)) - : !(isCreator || isAdmin) - } - on={contract.visibility === 'unlisted'} - setOn={(b) => - updateContract(id, { - visibility: b ? 'unlisted' : 'public', - unlistedById: b ? user.id : '', - }) - } - /> - </td> - </tr> - )} - </tbody> - </table> - <Row className="flex-wrap"> - {mechanism === 'cpmm-1' && ( - <AddLiquidityButton contract={contract} className="mr-2" /> - )} - <DuplicateContractButton contract={contract} /> - </Row> - </Col> - </Modal> + <tr> + <td>Market created</td> + <td>{formatTime(createdTime)}</td> + </tr> + + {closeTime && ( + <tr> + <td>Market close{closeTime > Date.now() ? 's' : 'd'}</td> + <td>{formatTime(closeTime)}</td> + </tr> + )} + + {resolutionTime && ( + <tr> + <td>Market resolved</td> + <td>{formatTime(resolutionTime)}</td> + </tr> + )} + + <tr> + <td> + <span className="mr-1">Volume</span> + <InfoTooltip text="Total amount bought or sold" /> + </td> + <td>{formatMoney(contract.volume)}</td> + </tr> + + <tr> + <td>{capitalize(BETTORS)}</td> + <td>{uniqueBettorCount ?? '0'}</td> + </tr> + + <tr> + <td> + <Row> + <span className="mr-1">Elasticity</span> + <InfoTooltip + text={ + mechanism === 'cpmm-1' + ? 'Probability change between a M$50 bet on YES and NO' + : 'Probability change from a M$100 bet' + } + /> + </Row> + </td> + <td>{formatPercent(elasticity)}</td> + </tr> + + <tr> + <td>Liquidity subsidies</td> + <td> + {mechanism === 'cpmm-1' + ? formatMoney(contract.totalLiquidity) + : formatMoney(100)} + </td> + </tr> + + <tr> + <td>Pool</td> + <td> + {mechanism === 'cpmm-1' && outcomeType === 'BINARY' + ? `${Math.round(pool.YES)} YES, ${Math.round(pool.NO)} NO` + : mechanism === 'cpmm-1' && + outcomeType === 'PSEUDO_NUMERIC' + ? `${Math.round(pool.YES)} HIGHER, ${Math.round( + pool.NO + )} LOWER` + : contractPool(contract)} + </td> + </tr> + + {/* Show a path to Firebase if user is an admin, or we're on localhost */} + {(isAdmin || isDev) && ( + <tr> + <td>[ADMIN] Firestore</td> + <td> + <SiteLink href={firestoreConsolePath(id)}> + Console link + </SiteLink> + </td> + </tr> + )} + {isAdmin && ( + <tr> + <td>[ADMIN] Featured</td> + <td> + <ShortToggle + on={featured} + setOn={setFeatured} + onChange={onFeaturedToggle} + /> + </td> + </tr> + )} + {user && ( + <tr> + <td>{isAdmin ? '[ADMIN]' : ''} Unlisted</td> + <td> + <ShortToggle + disabled={ + isUnlisted + ? !(isAdmin || (isCreator && wasUnlistedByCreator)) + : !(isCreator || isAdmin) + } + on={contract.visibility === 'unlisted'} + setOn={(b) => + updateContract(id, { + visibility: b ? 'unlisted' : 'public', + unlistedById: b ? user.id : '', + }) + } + /> + </td> + </tr> + )} + </tbody> + </table> + + <Row className="flex-wrap"> + {mechanism === 'cpmm-1' && ( + <AddLiquidityButton contract={contract} className="mr-2" /> + )} + <DuplicateContractButton contract={contract} /> + </Row> + </Col> + </Modal> + </Tooltip> </> ) } diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 0c77c666..7353bb6e 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -2,7 +2,7 @@ import { ShareIcon } from '@heroicons/react/outline' import { Row } from '../layout/row' import { Contract } from 'web/lib/firebase/contracts' import React, { useState } from 'react' -import { Button } from 'web/components/button' +import { IconButton } from 'web/components/button' import { useUser } from 'web/hooks/use-user' import { ShareModal } from './share-modal' import { FollowMarketButton } from 'web/components/follow-market-button' @@ -16,15 +16,14 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { const [isShareOpen, setShareOpen] = useState(false) return ( - <Row> + <Row className="gap-1"> <FollowMarketButton contract={contract} user={user} /> <LikeMarketButton contract={contract} user={user} /> <Tooltip text="Share" placement="bottom" noTap noFade> - <Button - size="sm" - color="gray-white" + <IconButton + size="2xs" className={'flex'} onClick={() => setShareOpen(true)} > @@ -35,7 +34,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { contract={contract} user={user} /> - </Button> + </IconButton> </Tooltip> <ContractInfoDialog contract={contract} user={user} /> diff --git a/web/components/contract/tip-button.tsx b/web/components/contract/tip-button.tsx index df245c68..fd80d97f 100644 --- a/web/components/contract/tip-button.tsx +++ b/web/components/contract/tip-button.tsx @@ -1,10 +1,9 @@ import clsx from 'clsx' -import { HeartIcon } from '@heroicons/react/outline' - -import { Button } from 'web/components/button' import { formatMoney, shortFormatNumber } from 'common/util/format' import { Col } from 'web/components/layout/col' import { Tooltip } from '../tooltip' +import TipJar from 'web/public/custom-components/tipJar' +import { useState } from 'react' export function TipButton(props: { tipAmount: number @@ -14,11 +13,12 @@ export function TipButton(props: { isCompact?: boolean disabled?: boolean }) { - const { tipAmount, totalTipped, userTipped, isCompact, onClick, disabled } = - props + const { tipAmount, totalTipped, userTipped, onClick, disabled } = props const tipDisplay = shortFormatNumber(Math.ceil(totalTipped / 10)) + const [hover, setHover] = useState(false) + return ( <Tooltip text={ @@ -30,39 +30,40 @@ export function TipButton(props: { noTap noFade > - <Button - size={'sm'} - className={clsx( - 'max-w-xs self-center', - isCompact && 'px-0 py-0', - disabled && 'hover:bg-inherit' - )} - color={'gray-white'} + <button onClick={onClick} disabled={disabled} + className={clsx( + 'px-2 py-1 text-xs', //2xs button + 'text-greyscale-6 transition-transform hover:text-indigo-600 disabled:cursor-not-allowed', + !disabled ? 'hover:rotate-12' : '' + )} + onMouseOver={() => setHover(true)} + onMouseLeave={() => setHover(false)} > - <Col className={'relative items-center sm:flex-row'}> - <HeartIcon - className={clsx( - 'h-5 w-5', - totalTipped > 0 ? 'mr-2' : '', - userTipped ? 'fill-teal-500 text-teal-500' : '' - )} + <Col className={clsx('relative', disabled ? 'opacity-30' : '')}> + <TipJar + size={18} + color={userTipped || (hover && !disabled) ? '#4f46e5' : '#66667C'} + fill={userTipped ? '#4f46e5' : 'none'} /> - {totalTipped > 0 && ( - <div - className={clsx( - 'bg-greyscale-5 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', - tipDisplay.length > 2 - ? 'text-[0.4rem] sm:text-[0.5rem]' - : 'sm:text-2xs text-[0.5rem]' - )} - > - {tipDisplay} - </div> - )} + <div + className={clsx( + ' absolute top-[2px] text-[0.5rem]', + userTipped ? 'text-white' : '', + tipDisplay.length === 1 + ? 'left-[7px]' + : tipDisplay.length === 2 + ? 'left-[4.5px]' + : tipDisplay.length > 2 + ? 'left-[4px] top-[2.5px] text-[0.35rem]' + : '' + )} + > + {totalTipped > 0 ? tipDisplay : ''} + </div> </Col> - </Button> + </button> </Tooltip> ) } diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 9bdeed53..73d89c67 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -24,7 +24,7 @@ import { UserLink } from 'web/components/user-link' import { CommentInput } from '../comment-input' import { AwardBountyButton } from 'web/components/award-bounty-button' import { ReplyIcon } from '@heroicons/react/solid' -import { Button } from '../button' +import { IconButton } from '../button' import { ReplyToggle } from '../comments/reply-toggle' export type ReplyTo = { id: string; username: string } @@ -154,36 +154,46 @@ export function ParentFeedComment(props: { numComments={numComments} onClick={onSeeReplyClick} /> - <Row className="grow justify-end gap-2"> - {onReplyClick && ( - <Button - size={'sm'} - className={clsx( - 'hover:bg-greyscale-2 mt-0 mb-1 max-w-xs px-0 py-0' - )} - color={'gray-white'} - onClick={() => onReplyClick(comment)} - > - <ReplyIcon className="h-5 w-5" /> - </Button> - )} - {showTip && ( - <Tipper - comment={comment} - myTip={myTip ?? 0} - totalTip={totalTip ?? 0} - /> - )} - {(contract.openCommentBounties ?? 0) > 0 && ( - <AwardBountyButton comment={comment} contract={contract} /> - )} - </Row> + <CommentActions + onReplyClick={onReplyClick} + comment={comment} + showTip={showTip} + myTip={myTip} + totalTip={totalTip} + contract={contract} + /> </Row> </Col> </Row> ) } +export function CommentActions(props: { + onReplyClick?: (comment: ContractComment) => void + comment: ContractComment + showTip?: boolean + myTip?: number + totalTip?: number + contract: Contract +}) { + const { onReplyClick, comment, showTip, myTip, totalTip, contract } = props + return ( + <Row className="grow justify-end"> + {onReplyClick && ( + <IconButton size={'xs'} onClick={() => onReplyClick(comment)}> + <ReplyIcon className="h-5 w-5" /> + </IconButton> + )} + {showTip && ( + <Tipper comment={comment} myTip={myTip ?? 0} totalTip={totalTip ?? 0} /> + )} + {(contract.openCommentBounties ?? 0) > 0 && ( + <AwardBountyButton comment={comment} contract={contract} /> + )} + </Row> + ) +} + export const FeedComment = memo(function FeedComment(props: { contract: Contract comment: ContractComment @@ -233,30 +243,14 @@ export const FeedComment = memo(function FeedComment(props: { content={content || text} smallImage /> - <Row className="grow justify-end gap-2"> - {onReplyClick && ( - <Button - size={'sm'} - className={clsx( - 'hover:bg-greyscale-2 mt-0 mb-1 max-w-xs px-0 py-0' - )} - color={'gray-white'} - onClick={() => onReplyClick(comment)} - > - <ReplyIcon className="h-5 w-5" /> - </Button> - )} - {showTip && ( - <Tipper - comment={comment} - myTip={myTip ?? 0} - totalTip={totalTip ?? 0} - /> - )} - {(contract.openCommentBounties ?? 0) > 0 && ( - <AwardBountyButton comment={comment} contract={contract} /> - )} - </Row> + <CommentActions + onReplyClick={onReplyClick} + comment={comment} + showTip={showTip} + myTip={myTip} + totalTip={totalTip} + contract={contract} + /> </Col> </Row> ) diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 319d4af6..5185390b 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -1,4 +1,4 @@ -import { Button } from 'web/components/button' +import { IconButton } from 'web/components/button' import { Contract, followContract, @@ -33,9 +33,8 @@ export const FollowMarketButton = (props: { noTap noFade > - <Button - size={'sm'} - color={'gray-white'} + <IconButton + size="2xs" onClick={async () => { if (!user) return firebaseLogin() if (followers?.includes(user.id)) { @@ -65,18 +64,12 @@ export const FollowMarketButton = (props: { > {watching ? ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeOffIcon - className={clsx('h-5 w-5 sm:h-6 sm:w-6')} - aria-hidden="true" - /> + <EyeOffIcon className={clsx('h-5 w-5')} aria-hidden="true" /> {/* Unwatch */} </Col> ) : ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeIcon - className={clsx('h-5 w-5 sm:h-6 sm:w-6')} - aria-hidden="true" - /> + <EyeIcon className={clsx('h-5 w-5')} aria-hidden="true" /> {/* Watch */} </Col> )} @@ -87,7 +80,7 @@ export const FollowMarketButton = (props: { followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched' } a question!`} /> - </Button> + </IconButton> </Tooltip> ) } diff --git a/web/components/manalink-card.tsx b/web/components/manalink-card.tsx index b04fd0da..62b39d74 100644 --- a/web/components/manalink-card.tsx +++ b/web/components/manalink-card.tsx @@ -9,9 +9,9 @@ import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Claim, Manalink } from 'common/manalink' import { ShareIconButton } from './share-icon-button' -import { contractDetailsButtonClassName } from './contract/contract-info-dialog' import { useUserById } from 'web/hooks/use-user' import getManalinkUrl from 'web/get-manalink-url' +import { IconButton } from './button' export type ManalinkInfo = { expiresTime: number | null @@ -123,7 +123,7 @@ export function ManalinkCardFromView(props: { src="/logo-white.svg" /> </Col> - <Row className="relative w-full gap-1 rounded-b-lg bg-white px-4 py-2 text-lg"> + <Row className="relative w-full rounded-b-lg bg-white px-4 py-2 align-middle text-lg"> <div className={clsx( 'my-auto mb-1 w-full', @@ -133,32 +133,23 @@ export function ManalinkCardFromView(props: { {formatMoney(amount)} </div> - <button - onClick={() => (window.location.href = qrUrl)} - className={clsx(contractDetailsButtonClassName)} - > + <IconButton size="2xs" onClick={() => (window.location.href = qrUrl)}> <QrcodeIcon className="h-6 w-6" /> - </button> + </IconButton> <ShareIconButton toastClassName={'-left-48 min-w-[250%]'} - buttonClassName={'transition-colors'} - onCopyButtonClassName={ - 'bg-gray-200 text-gray-600 transition-none hover:bg-gray-200 hover:text-gray-600' - } copyPayload={getManalinkUrl(link.slug)} /> - <button + <IconButton + size="xs" onClick={() => setShowDetails(!showDetails)} className={clsx( - contractDetailsButtonClassName, - showDetails - ? 'bg-gray-200 text-gray-600 hover:bg-gray-200 hover:text-gray-600' - : '' + showDetails ? ' text-indigo-600 hover:text-indigo-700' : '' )} > - <DotsHorizontalIcon className="h-[24px] w-5" /> - </button> + <DotsHorizontalIcon className="h-5 w-5" /> + </IconButton> </Row> </Col> <div className="mt-2 mb-4 text-xs text-gray-500 md:text-sm"> diff --git a/web/components/share-icon-button.tsx b/web/components/share-icon-button.tsx index da1fc570..86a554f5 100644 --- a/web/components/share-icon-button.tsx +++ b/web/components/share-icon-button.tsx @@ -5,34 +5,22 @@ import clsx from 'clsx' import { copyToClipboard } from 'web/lib/util/copy' import { ToastClipboard } from 'web/components/toast-clipboard' import { track } from 'web/lib/service/analytics' -import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog' +import { IconButton } from './button' export function ShareIconButton(props: { - buttonClassName?: string - onCopyButtonClassName?: string toastClassName?: string children?: React.ReactNode iconClassName?: string copyPayload: string }) { - const { - buttonClassName, - onCopyButtonClassName, - toastClassName, - children, - iconClassName, - copyPayload, - } = props + const { toastClassName, children, iconClassName, copyPayload } = props const [showToast, setShowToast] = useState(false) return ( <div className="relative z-10 flex-shrink-0"> - <button - className={clsx( - contractDetailsButtonClassName, - buttonClassName, - showToast ? onCopyButtonClassName : '' - )} + <IconButton + size="2xs" + className={clsx('mt-1', showToast ? 'text-indigo-600' : '')} onClick={() => { copyToClipboard(copyPayload) track('copy share link') @@ -41,11 +29,11 @@ export function ShareIconButton(props: { }} > <LinkIcon - className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')} + className={clsx(iconClassName ? iconClassName : 'h-5 w-5')} aria-hidden="true" /> {children} - </button> + </IconButton> {showToast && <ToastClipboard className={toastClassName} />} </div> diff --git a/web/public/custom-components/tipJar.tsx b/web/public/custom-components/tipJar.tsx new file mode 100644 index 00000000..5682b779 --- /dev/null +++ b/web/public/custom-components/tipJar.tsx @@ -0,0 +1,23 @@ +export default function TipJar({ + size = 18, + color = '#66667C', + strokeWidth = 1.5, + fill = 'none', +}) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 18 18" + width={size} + height={size} + fill={fill} + stroke={color} + strokeWidth={strokeWidth} + opacity={50} + > + <path d="M15.5,8.1v5.8c0,1.43-1.16,2.6-2.6,2.6H5.1c-1.44,0-2.6-1.16-2.6-2.6v-5.8c0-1.04,.89-3.25,1.5-4.1h0v-2c0-.55,.45-1,1-1H13c.55,0,1,.45,1,1v2h0c.61,.85,1.5,3.06,1.5,4.1Z" /> + <line x1="4" y1="4" x2="9" y2="4" /> + <line x1="11.26" y1="4" x2="14" y2="4" /> + </svg> + ) +} From 3e1876f0dcc01c9351bde27dd912381ab155ca0a Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 13 Oct 2022 07:58:14 -0600 Subject: [PATCH 18/26] Add flaggedByUsernames to firestore rules --- firestore.rules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firestore.rules b/firestore.rules index 0a0ecfe0..bfcb7183 100644 --- a/firestore.rules +++ b/firestore.rules @@ -110,7 +110,7 @@ service cloud.firestore { match /contracts/{contractId} { allow read; allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); + .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks', 'flaggedByUsernames']); allow update: if request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById']) && resource.data.creatorId == request.auth.uid; From 9bf82c6082c9f59bf244de070a893b15982d15ce Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 13 Oct 2022 08:00:04 -0600 Subject: [PATCH 19/26] Match colors on portfolio --- 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 b1380bfe..1fa3cd19 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -291,7 +291,7 @@ export function ProfilePrivateStats(props: { <Row className={'justify-between gap-4 sm:justify-end'}> <Col className={'text-greyscale-4 text-md sm:text-lg'}> <span - className={clsx(profit >= 0 ? 'text-green-600' : 'text-red-400')} + className={clsx(profit >= 0 ? 'text-teal-600' : 'text-red-400')} > {formatMoney(profit)} </span> From e7ba7e715f6309793adb68e4cb2960be015667be Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 13 Oct 2022 08:04:01 -0600 Subject: [PATCH 20/26] default cursor on open answer --- web/components/answers/answers-panel.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index f048d8e9..e22db8cc 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -227,10 +227,7 @@ function OpenAnswer(props: { username={username} avatarUrl={avatarUrl} /> - <Linkify - className="text-md cursor-pointer whitespace-pre-line" - text={text} - /> + <Linkify className="text-md whitespace-pre-line" text={text} /> </Row> <Row className="gap-2"> <div className="my-auto text-xl">{probPercent}</div> From fa476c78dd41836f978608facb7ba4685bc6deb1 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 13 Oct 2022 08:18:16 -0600 Subject: [PATCH 21/26] Handle numeric outcomes in movers --- web/components/contract/contract-card.tsx | 14 ++++++++++---- web/components/contract/prob-change-table.tsx | 9 +++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 8942aab0..aa18befa 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -35,7 +35,7 @@ import { trackCallback } from 'web/lib/service/analytics' import { getMappedValue } from 'common/pseudo-numeric' import { Tooltip } from '../tooltip' import { SiteLink } from '../site-link' -import { ProbChange } from './prob-change-table' +import { ProbOrNumericChange } from './prob-change-table' import { Card } from '../card' import { ProfitBadgeMana } from '../profit-badge' import { floatingEqual } from 'common/util/math' @@ -396,12 +396,17 @@ export function ContractCardProbChange(props: { className?: string }) { const { noLinkAvatar, showPosition, className } = props + const yesOutcomeLabel = + props.contract.outcomeType === 'PSEUDO_NUMERIC' ? 'HIGHER' : 'YES' + const noOutcomeLabel = + props.contract.outcomeType === 'PSEUDO_NUMERIC' ? 'LOWER' : 'NO' + const contract = useContractWithPreload(props.contract) as CPMMBinaryContract const user = useUser() const metrics = useUserContractMetrics(user?.id, contract.id) const dayMetrics = metrics && metrics.from && metrics.from.day - const outcome = + const binaryOutcome = metrics && floatingEqual(metrics.totalShares.NO ?? 0, 0) ? 'YES' : 'NO' return ( @@ -418,7 +423,7 @@ export function ContractCardProbChange(props: { > <span className="line-clamp-3">{contract.question}</span> </SiteLink> - <ProbChange className="py-2 pr-4" contract={contract} /> + <ProbOrNumericChange className="py-2 pr-4" contract={contract} /> </Row> {showPosition && metrics && metrics.hasShares && ( <Row @@ -427,7 +432,8 @@ export function ContractCardProbChange(props: { )} > <Row className="gap-1 text-gray-500"> - {Math.floor(metrics.totalShares[outcome])} {outcome} shares + {Math.floor(metrics.totalShares[binaryOutcome])}{' '} + {binaryOutcome === 'YES' ? yesOutcomeLabel : noOutcomeLabel} shares </Row> {dayMetrics && ( diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index 594f0653..727fed35 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -7,6 +7,7 @@ import { formatPercent } from 'common/util/format' import { Col } from '../layout/col' import { LoadingIndicator } from '../loading-indicator' import { ContractCardProbChange } from './contract-card' +import { formatNumericProbability } from 'common/pseudo-numeric' export function ProfitChangeTable(props: { contracts: CPMMBinaryContract[] @@ -118,7 +119,7 @@ export function ProbChangeTable(props: { ) } -export function ProbChange(props: { +export function ProbOrNumericChange(props: { contract: CPMMContract className?: string }) { @@ -127,13 +128,17 @@ export function ProbChange(props: { prob, probChanges: { day: change }, } = contract + const number = + contract.outcomeType === 'PSEUDO_NUMERIC' + ? formatNumericProbability(prob, contract) + : null const color = change >= 0 ? 'text-teal-500' : 'text-red-400' return ( <Col className={clsx('flex flex-col items-end', className)}> <div className="mb-0.5 mr-0.5 text-2xl"> - {formatPercent(Math.round(100 * prob) / 100)} + {number ? number : formatPercent(Math.round(100 * prob) / 100)} </div> <div className={clsx('text-base', color)}> {(change > 0 ? '+' : '') + (change * 100).toFixed(0) + '%'} From da32a756a8cc5862de0e609ebc38b181f2ca2422 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 13 Oct 2022 08:41:33 -0600 Subject: [PATCH 22/26] Need not follow contract for tag notification --- functions/src/create-notification.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 204105ac..e04ddedc 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -197,6 +197,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( return await notificationRef.set(removeUndefinedProps(notification)) } + const needNotFollowContractReasons = ['tagged_user'] const stillFollowingContract = (userId: string) => { return contractFollowersIds.includes(userId) } @@ -205,7 +206,12 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( userId: string, reason: notification_reason_types ) => { - if (!stillFollowingContract(userId) || sourceUser.id == userId) return + if ( + (!stillFollowingContract(userId) && + !needNotFollowContractReasons.includes(reason)) || + sourceUser.id == userId + ) + return const privateUser = await getPrivateUser(userId) if (!privateUser) return const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( From d6525bae9f16a034a3cf68833acb089adc655e84 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 13 Oct 2022 09:19:28 -0600 Subject: [PATCH 23/26] Revert "New implementation of market card embeddings (#1025)" This reverts commit 3fc53112b98c4eff29b47bee1cb092fdc5f9814c. --- web/components/editor.tsx | 4 -- web/components/editor/market-modal.tsx | 7 +-- web/components/editor/tiptap-grid-cards.tsx | 55 --------------------- web/hooks/use-contract.ts | 13 ----- 4 files changed, 2 insertions(+), 77 deletions(-) delete mode 100644 web/components/editor/tiptap-grid-cards.tsx diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 8f49c498..018d94a6 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -21,8 +21,6 @@ import { useMutation } from 'react-query' import { linkClass } from './site-link' import { DisplayMention } from './editor/mention' import { DisplayContractMention } from './editor/contract-mention' -import GridComponent from './editor/tiptap-grid-cards' - import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' @@ -80,7 +78,6 @@ export const editorExtensions = (simple = false): Extensions => [ DisplayLink, DisplayMention, DisplayContractMention, - GridComponent, Iframe, TiptapTweet, TiptapSpoiler.configure({ @@ -361,7 +358,6 @@ export function RichContent(props: { DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayMention, DisplayContractMention, - GridComponent, Iframe, TiptapTweet, TiptapSpoiler.configure({ diff --git a/web/components/editor/market-modal.tsx b/web/components/editor/market-modal.tsx index ae0f50e1..1e2c1482 100644 --- a/web/components/editor/market-modal.tsx +++ b/web/components/editor/market-modal.tsx @@ -1,7 +1,7 @@ import { Editor } from '@tiptap/react' import { Contract } from 'common/contract' import { SelectMarketsModal } from '../contract-select-modal' -import { embedContractCode } from '../share-embed-button' +import { embedContractCode, embedContractGridCode } from '../share-embed-button' import { insertContent } from './utils' export function MarketModal(props: { @@ -15,10 +15,7 @@ export function MarketModal(props: { if (contracts.length == 1) { insertContent(editor, embedContractCode(contracts[0])) } else if (contracts.length > 1) { - insertContent( - editor, - `<grid-cards-component contractIds="${contracts.map((c) => c.id)}" />` - ) + insertContent(editor, embedContractGridCode(contracts)) } } diff --git a/web/components/editor/tiptap-grid-cards.tsx b/web/components/editor/tiptap-grid-cards.tsx deleted file mode 100644 index 48242ea2..00000000 --- a/web/components/editor/tiptap-grid-cards.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { mergeAttributes, Node } from '@tiptap/core' -import React from 'react' -import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' -import { ContractsGrid } from '../contract/contracts-grid' - -import { useContractsFromIds } from 'web/hooks/use-contract' -import { LoadingIndicator } from '../loading-indicator' - -export default Node.create({ - name: 'gridCardsComponent', - - group: 'block', - - atom: true, - - addAttributes() { - return { - contractIds: [], - } - }, - - parseHTML() { - return [ - { - tag: 'grid-cards-component', - }, - ] - }, - - renderHTML({ HTMLAttributes }) { - return ['grid-cards-component', mergeAttributes(HTMLAttributes)] - }, - - addNodeView() { - return ReactNodeViewRenderer(GridComponent) - }, -}) - -export function GridComponent(props: any) { - const contractIds = props.node.attrs.contractIds - const contracts = useContractsFromIds(contractIds.split(',')) - - return ( - <NodeViewWrapper className="grid-cards-component"> - {contracts ? ( - <ContractsGrid - contracts={contracts} - breakpointColumns={{ default: 2, 650: 1 }} - /> - ) : ( - <LoadingIndicator /> - )} - </NodeViewWrapper> - ) -} diff --git a/web/hooks/use-contract.ts b/web/hooks/use-contract.ts index 2e7c4f84..acaf7730 100644 --- a/web/hooks/use-contract.ts +++ b/web/hooks/use-contract.ts @@ -3,12 +3,10 @@ import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { Contract, contracts, - getContractFromId, listenForContract, } from 'web/lib/firebase/contracts' import { useStateCheckEquality } from './use-state-check-equality' import { doc, DocumentData } from 'firebase/firestore' -import { useQuery } from 'react-query' export const useContract = (contractId: string) => { const result = useFirestoreDocumentData<DocumentData, Contract>( @@ -20,17 +18,6 @@ export const useContract = (contractId: string) => { return result.isLoading ? undefined : result.data } -export const useContractsFromIds = (contractIds: string[]) => { - const contractResult = useQuery(['contracts', contractIds], () => - Promise.all(contractIds.map(getContractFromId)) - ) - const contracts = contractResult.data?.filter( - (contract): contract is Contract => !!contract - ) - - return contractResult.isLoading ? undefined : contracts -} - export const useContractWithPreload = ( initial: Contract | null | undefined ) => { From 34c9dbb3e770d7c6a2a71883dc1b1d849763bd66 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Thu, 13 Oct 2022 16:25:15 +0100 Subject: [PATCH 24/26] Revert "Revert "New implementation of market card embeddings (#1025)"" This reverts commit d6525bae9f16a034a3cf68833acb089adc655e84. --- web/components/editor.tsx | 4 ++ web/components/editor/market-modal.tsx | 7 ++- web/components/editor/tiptap-grid-cards.tsx | 55 +++++++++++++++++++++ web/hooks/use-contract.ts | 13 +++++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 web/components/editor/tiptap-grid-cards.tsx diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 018d94a6..8f49c498 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -21,6 +21,8 @@ import { useMutation } from 'react-query' import { linkClass } from './site-link' import { DisplayMention } from './editor/mention' import { DisplayContractMention } from './editor/contract-mention' +import GridComponent from './editor/tiptap-grid-cards' + import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' @@ -78,6 +80,7 @@ export const editorExtensions = (simple = false): Extensions => [ DisplayLink, DisplayMention, DisplayContractMention, + GridComponent, Iframe, TiptapTweet, TiptapSpoiler.configure({ @@ -358,6 +361,7 @@ export function RichContent(props: { DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayMention, DisplayContractMention, + GridComponent, Iframe, TiptapTweet, TiptapSpoiler.configure({ diff --git a/web/components/editor/market-modal.tsx b/web/components/editor/market-modal.tsx index 1e2c1482..ae0f50e1 100644 --- a/web/components/editor/market-modal.tsx +++ b/web/components/editor/market-modal.tsx @@ -1,7 +1,7 @@ import { Editor } from '@tiptap/react' import { Contract } from 'common/contract' import { SelectMarketsModal } from '../contract-select-modal' -import { embedContractCode, embedContractGridCode } from '../share-embed-button' +import { embedContractCode } from '../share-embed-button' import { insertContent } from './utils' export function MarketModal(props: { @@ -15,7 +15,10 @@ export function MarketModal(props: { if (contracts.length == 1) { insertContent(editor, embedContractCode(contracts[0])) } else if (contracts.length > 1) { - insertContent(editor, embedContractGridCode(contracts)) + insertContent( + editor, + `<grid-cards-component contractIds="${contracts.map((c) => c.id)}" />` + ) } } diff --git a/web/components/editor/tiptap-grid-cards.tsx b/web/components/editor/tiptap-grid-cards.tsx new file mode 100644 index 00000000..48242ea2 --- /dev/null +++ b/web/components/editor/tiptap-grid-cards.tsx @@ -0,0 +1,55 @@ +import { mergeAttributes, Node } from '@tiptap/core' +import React from 'react' +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { ContractsGrid } from '../contract/contracts-grid' + +import { useContractsFromIds } from 'web/hooks/use-contract' +import { LoadingIndicator } from '../loading-indicator' + +export default Node.create({ + name: 'gridCardsComponent', + + group: 'block', + + atom: true, + + addAttributes() { + return { + contractIds: [], + } + }, + + parseHTML() { + return [ + { + tag: 'grid-cards-component', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['grid-cards-component', mergeAttributes(HTMLAttributes)] + }, + + addNodeView() { + return ReactNodeViewRenderer(GridComponent) + }, +}) + +export function GridComponent(props: any) { + const contractIds = props.node.attrs.contractIds + const contracts = useContractsFromIds(contractIds.split(',')) + + return ( + <NodeViewWrapper className="grid-cards-component"> + {contracts ? ( + <ContractsGrid + contracts={contracts} + breakpointColumns={{ default: 2, 650: 1 }} + /> + ) : ( + <LoadingIndicator /> + )} + </NodeViewWrapper> + ) +} diff --git a/web/hooks/use-contract.ts b/web/hooks/use-contract.ts index acaf7730..2e7c4f84 100644 --- a/web/hooks/use-contract.ts +++ b/web/hooks/use-contract.ts @@ -3,10 +3,12 @@ import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { Contract, contracts, + getContractFromId, listenForContract, } from 'web/lib/firebase/contracts' import { useStateCheckEquality } from './use-state-check-equality' import { doc, DocumentData } from 'firebase/firestore' +import { useQuery } from 'react-query' export const useContract = (contractId: string) => { const result = useFirestoreDocumentData<DocumentData, Contract>( @@ -18,6 +20,17 @@ export const useContract = (contractId: string) => { return result.isLoading ? undefined : result.data } +export const useContractsFromIds = (contractIds: string[]) => { + const contractResult = useQuery(['contracts', contractIds], () => + Promise.all(contractIds.map(getContractFromId)) + ) + const contracts = contractResult.data?.filter( + (contract): contract is Contract => !!contract + ) + + return contractResult.isLoading ? undefined : contracts +} + export const useContractWithPreload = ( initial: Contract | null | undefined ) => { From 2c2bc617888911141b99f6ece6f45dfe9b692906 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Thu, 13 Oct 2022 16:56:35 +0100 Subject: [PATCH 25/26] Fix bug with parsing in abritrary react components --- common/util/parse.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/common/util/parse.ts b/common/util/parse.ts index 6500d6ef..102a9e90 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,4 +1,4 @@ -import { generateText, JSONContent } from '@tiptap/core' +import { generateText, JSONContent, Node } from '@tiptap/core' import { generateJSON } from '@tiptap/html' // Tiptap starter extensions import { Blockquote } from '@tiptap/extension-blockquote' @@ -52,6 +52,26 @@ export function parseMentions(data: JSONContent): string[] { return uniq(mentions) } +// TODO: this is a hack to get around the fact that tiptap doesn't have a +// way to add a node view without bundling in tsx +function skippableComponent(name: string): Node<any, any> { + return Node.create({ + name, + + group: 'block', + + content: 'inline*', + + parseHTML() { + return [ + { + tag: 'grid-cards-component', + }, + ] + }, + }) +} + const stringParseExts = [ // StarterKit extensions Blockquote, @@ -79,6 +99,7 @@ const stringParseExts = [ renderText: ({ node }) => '[embed]' + node.attrs.src ? `(${node.attrs.src})` : '', }), + skippableComponent('gridCardsComponent'), TiptapTweet.extend({ renderText: () => '[tweet]' }), TiptapSpoiler.extend({ renderHTML: () => ['span', '[spoiler]', 0] }), ] From 8bb44593f330af761b2ec595cbf694777f905070 Mon Sep 17 00:00:00 2001 From: Phil <phil.bladen@gmail.com> Date: Thu, 13 Oct 2022 17:19:50 +0100 Subject: [PATCH 26/26] Updated dev config to point to new Twitch dev bot hosting location. (#1044) --- common/envs/dev.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/envs/dev.ts b/common/envs/dev.ts index ff3fd37d..78c48264 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -16,7 +16,6 @@ export const DEV_CONFIG: EnvConfig = { cloudRunId: 'w3txbmd3ba', cloudRunRegion: 'uc', amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', - // this is Phil's deployment - twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app', + twitchBotEndpoint: 'https://dev-twitch-bot.manifold.markets', sprigEnvironmentId: 'Tu7kRZPm7daP', }