From 97fa5fa636d1b66020d169d91b84248f968932cc Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Wed, 17 Aug 2022 23:15:25 -0700 Subject: [PATCH 01/48] Replace `/markets` with `/home` (#766) * Make /home not kick out logged out users * Point people at /home instead of /markets --- web/components/nav/nav-bar.tsx | 2 +- web/components/nav/sidebar.tsx | 4 ++-- web/pages/home.tsx | 15 +++++++++------ web/pages/index.tsx | 6 ------ web/pages/markets.tsx | 19 ------------------- web/public/sitemap-0.xml | 4 ++-- 6 files changed, 14 insertions(+), 36 deletions(-) delete mode 100644 web/pages/markets.tsx diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index a935173a..680b8946 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -33,7 +33,7 @@ function getNavigation() { const signedOutNavigation = [ { name: 'Home', href: '/', icon: HomeIcon }, - { name: 'Explore', href: '/markets', icon: SearchIcon }, + { name: 'Explore', href: '/home', icon: SearchIcon }, ] // From https://codepen.io/chris__sev/pen/QWGvYbL diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index dfb7805e..6c4addc4 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -99,8 +99,8 @@ function getMoreNavigation(user?: User | null) { } const signedOutNavigation = [ - { name: 'Home', href: '/home', icon: HomeIcon }, - { name: 'Explore', href: '/markets', icon: SearchIcon }, + { name: 'Home', href: '/', icon: HomeIcon }, + { name: 'Explore', href: '/home', icon: SearchIcon }, { name: 'About', href: 'https://docs.manifold.markets/$how-to', diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 1fd163ea..8153baea 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -12,15 +12,18 @@ import { getContractFromSlug } from 'web/lib/firebase/contracts' import { getUserAndPrivateUser } from 'web/lib/firebase/users' import { useTracking } from 'web/hooks/use-tracking' import { track } from 'web/lib/service/analytics' -import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { authenticateOnServer } from 'web/lib/firebase/server-auth' import { useSaveReferral } from 'web/hooks/use-save-referral' +import { GetServerSideProps } from 'next' -export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { - return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } -}) +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const creds = await authenticateOnServer(ctx) + const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null + return { props: { auth } } +} -const Home = (props: { auth: { user: User } }) => { - const { user } = props.auth +const Home = (props: { auth: { user: User } | null }) => { + const user = props.auth ? props.auth.user : null const [contract, setContract] = useContractPage() const router = useRouter() diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 01c24fcf..4013f57a 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -30,12 +30,6 @@ export default function Home(props: { hotContracts: Contract[] }) { - {/*

- View{' '} - - all markets - -

*/} diff --git a/web/pages/markets.tsx b/web/pages/markets.tsx deleted file mode 100644 index c42364d5..00000000 --- a/web/pages/markets.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useUser } from 'web/hooks/use-user' -import { ContractSearch } from '../components/contract-search' -import { Page } from '../components/page' -import { SEO } from '../components/SEO' - -// TODO: Rename endpoint to "Explore" -export default function Markets() { - const user = useUser() - return ( - - - - - ) -} diff --git a/web/public/sitemap-0.xml b/web/public/sitemap-0.xml index 3b4618fb..d0750f46 100644 --- a/web/public/sitemap-0.xml +++ b/web/public/sitemap-0.xml @@ -1,6 +1,6 @@ https://manifold.marketshourly1.0 -https://manifold.markets/marketshourly0.2 +https://manifold.markets/homehourly0.2 https://manifold.markets/leaderboardsdaily0.2 - \ No newline at end of file + From c3d09e5323c5ee4194c41de44c61696dcc6a8e17 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 18 Aug 2022 07:53:19 -0600 Subject: [PATCH 02/48] Add links to challenge page --- web/pages/challenges/index.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx index e548e56f..9fe68ff0 100644 --- a/web/pages/challenges/index.tsx +++ b/web/pages/challenges/index.tsx @@ -71,7 +71,16 @@ export default function ChallengesListPage() { </Row> - <p>Find or create a question to challenge someone to a bet.</p> + <p> + <SiteLink className={'font-bold'} href={'/home'}> + Find + </SiteLink>{' '} + or{' '} + <SiteLink className={'font-bold'} href={'/create'}> + create + </SiteLink>{' '} + a market to challenge someone to a bet. + </p> <Tabs tabs={[...userTab, ...publicTab]} /> </Col> From 33edd3c0fb2a77ee75b4adbf4809a225cf16ff02 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 18 Aug 2022 08:15:20 -0600 Subject: [PATCH 03/48] Create challenge without previous market (#718) * Create challenge without previous market * Check if they've balance to create both on fe * Change wording slightly * Finish merge --- .../challenges/create-challenge-modal.tsx | 156 ++++++++++++------ web/lib/firebase/challenges.ts | 4 +- web/pages/challenges/index.tsx | 14 +- 3 files changed, 124 insertions(+), 50 deletions(-) diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index e93ec314..b1ac7704 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -11,7 +11,7 @@ import { User } from 'common/user' import { Modal } from 'web/components/layout/modal' import { Button } from '../button' import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' -import { BinaryContract } from 'common/contract' +import { BinaryContract, MAX_QUESTION_LENGTH } from 'common/contract' import { SiteLink } from 'web/components/site-link' import { formatMoney } from 'common/util/format' import { NoLabel, YesLabel } from '../outcome-label' @@ -19,24 +19,32 @@ import { QRCode } from '../qr-code' import { copyToClipboard } from 'web/lib/util/copy' import { AmountInput } from '../amount-input' import { getProbability } from 'common/calculate' +import { createMarket } from 'web/lib/firebase/api' +import { removeUndefinedProps } from 'common/util/object' +import { FIXED_ANTE } from 'common/antes' +import Textarea from 'react-expanding-textarea' +import { useTextEditor } from 'web/components/editor' +import { LoadingIndicator } from 'web/components/loading-indicator' import { track } from 'web/lib/service/analytics' type challengeInfo = { amount: number expiresTime: number | null - message: string outcome: 'YES' | 'NO' | number acceptorAmount: number + question: string } export function CreateChallengeModal(props: { user: User | null | undefined - contract: BinaryContract isOpen: boolean setOpen: (open: boolean) => void + contract?: BinaryContract }) { const { user, contract, isOpen, setOpen } = props const [challengeSlug, setChallengeSlug] = useState('') + const [loading, setLoading] = useState(false) + const { editor } = useTextEditor({ placeholder: '' }) return ( <Modal open={isOpen} setOpen={setOpen}> @@ -46,24 +54,42 @@ export function CreateChallengeModal(props: { <CreateChallengeForm user={user} contract={contract} + loading={loading} onCreate={async (newChallenge) => { - const challenge = await createChallenge({ - creator: user, - creatorAmount: newChallenge.amount, - expiresTime: newChallenge.expiresTime, - message: newChallenge.message, - acceptorAmount: newChallenge.acceptorAmount, - outcome: newChallenge.outcome, - contract: contract, - }) - if (challenge) { - setChallengeSlug(getChallengeUrl(challenge)) - track('challenge created', { - creator: user.username, - amount: newChallenge.amount, - contractId: contract.id, + setLoading(true) + try { + const challengeContract = contract + ? contract + : await createMarket( + removeUndefinedProps({ + question: newChallenge.question, + outcomeType: 'BINARY', + initialProb: 50, + description: editor?.getJSON(), + ante: FIXED_ANTE, + closeTime: dayjs().add(30, 'day').valueOf(), + }) + ) + const challenge = await createChallenge({ + creator: user, + creatorAmount: newChallenge.amount, + expiresTime: newChallenge.expiresTime, + acceptorAmount: newChallenge.acceptorAmount, + outcome: newChallenge.outcome, + contract: challengeContract as BinaryContract, }) + if (challenge) { + setChallengeSlug(getChallengeUrl(challenge)) + track('challenge created', { + creator: user.username, + amount: newChallenge.amount, + contractId: challengeContract.id, + }) + } + } catch (e) { + console.error("couldn't create market/challenge:", e) } + setLoading(false) }} challengeSlug={challengeSlug} /> @@ -75,25 +101,24 @@ export function CreateChallengeModal(props: { function CreateChallengeForm(props: { user: User - contract: BinaryContract onCreate: (m: challengeInfo) => Promise<void> challengeSlug: string + loading: boolean + contract?: BinaryContract }) { - const { user, onCreate, contract, challengeSlug } = props + const { user, onCreate, contract, challengeSlug, loading } = props const [isCreating, setIsCreating] = useState(false) const [finishedCreating, setFinishedCreating] = useState(false) const [error, setError] = useState<string>('') const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false) const defaultExpire = 'week' - const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}` - const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({ expiresTime: dayjs().add(2, defaultExpire).valueOf(), outcome: 'YES', amount: 100, acceptorAmount: 100, - message: defaultMessage, + question: contract ? contract.question : '', }) useEffect(() => { setError('') @@ -106,7 +131,15 @@ function CreateChallengeForm(props: { onSubmit={(e) => { e.preventDefault() if (user.balance < challengeInfo.amount) { - setError('You do not have enough mana to create this challenge') + setError("You don't have enough mana to create this challenge") + return + } + if (!contract && user.balance < FIXED_ANTE + challengeInfo.amount) { + setError( + `You don't have enough mana to create this challenge and market. You need ${formatMoney( + FIXED_ANTE + challengeInfo.amount + )}` + ) return } setIsCreating(true) @@ -118,7 +151,23 @@ function CreateChallengeForm(props: { <div className="mb-8"> Challenge a friend to bet on{' '} - <span className="underline">{contract.question}</span> + {contract ? ( + <span className="underline">{contract.question}</span> + ) : ( + <Textarea + placeholder="e.g. Will a Democrat be the next president?" + className="input input-bordered mt-1 w-full resize-none" + autoFocus={true} + maxLength={MAX_QUESTION_LENGTH} + value={challengeInfo.question} + onChange={(e) => + setChallengeInfo({ + ...challengeInfo, + question: e.target.value, + }) + } + /> + )} </div> <div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> @@ -187,22 +236,23 @@ function CreateChallengeForm(props: { {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} </Row> </div> - <Button - size="2xs" - color="gray" - onClick={() => { - setEditingAcceptorAmount(true) - - const p = getProbability(contract) - const prob = challengeInfo.outcome === 'YES' ? p : 1 - p - const { amount } = challengeInfo - const acceptorAmount = Math.round(amount / prob - amount) - setChallengeInfo({ ...challengeInfo, acceptorAmount }) - }} - > - Use market odds - </Button> + {contract && ( + <Button + size="2xs" + color="gray" + onClick={() => { + setEditingAcceptorAmount(true) + const p = getProbability(contract) + const prob = challengeInfo.outcome === 'YES' ? p : 1 - p + const { amount } = challengeInfo + const acceptorAmount = Math.round(amount / prob - amount) + setChallengeInfo({ ...challengeInfo, acceptorAmount }) + }} + > + Use market odds + </Button> + )} <div className="mt-8"> If the challenge is accepted, whoever is right will earn{' '} <span className="font-semibold"> @@ -210,7 +260,18 @@ function CreateChallengeForm(props: { challengeInfo.acceptorAmount + challengeInfo.amount || 0 )} </span>{' '} - in total. + in total.{' '} + <span> + {!contract && ( + <span> + Because there's no market yet, you'll be charged + <span className={'mx-1 font-semibold'}> + {formatMoney(FIXED_ANTE)} + </span> + to create it. + </span> + )} + </span> </div> <Row className="mt-8 items-center"> @@ -218,10 +279,8 @@ function CreateChallengeForm(props: { type="submit" color={'gradient'} size="xl" - className={clsx( - 'whitespace-nowrap drop-shadow-md', - isCreating ? 'disabled' : '' - )} + disabled={isCreating || challengeInfo.question === ''} + className={clsx('whitespace-nowrap drop-shadow-md')} > Create challenge bet </Button> @@ -229,7 +288,12 @@ function CreateChallengeForm(props: { <Row className={'text-error'}>{error} </Row> </form> )} - {finishedCreating && ( + {loading && ( + <Col className={'h-56 w-full items-center justify-center'}> + <LoadingIndicator /> + </Col> + )} + {finishedCreating && !loading && ( <> <Title className="!my-0" text="Challenge Created!" /> diff --git a/web/lib/firebase/challenges.ts b/web/lib/firebase/challenges.ts index 89da7f80..0d0fb9ed 100644 --- a/web/lib/firebase/challenges.ts +++ b/web/lib/firebase/challenges.ts @@ -29,13 +29,11 @@ export async function createChallenge(data: { creatorAmount: number acceptorAmount: number expiresTime: number | null - message: string }) { const { creator, creatorAmount, expiresTime, - message, contract, outcome, acceptorAmount, @@ -73,7 +71,7 @@ export async function createChallenge(data: { acceptedByUserIds: [], acceptances: [], isResolved: false, - message, + message: '', } await setDoc(doc(challenges(contract.id), slug), challenge) diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx index 9fe68ff0..f3ab8ac1 100644 --- a/web/pages/challenges/index.tsx +++ b/web/pages/challenges/index.tsx @@ -16,7 +16,7 @@ import { useAcceptedChallenges, useUserChallenges, } from 'web/lib/firebase/challenges' -import { Challenge } from 'common/challenge' +import { Challenge, CHALLENGES_ENABLED } from 'common/challenge' import { Tabs } from 'web/components/layout/tabs' import { SiteLink } from 'web/components/site-link' import { UserLink } from 'web/components/user-page' @@ -29,6 +29,7 @@ import { copyToClipboard } from 'web/lib/util/copy' import toast from 'react-hot-toast' import { Modal } from 'web/components/layout/modal' import { QRCode } from 'web/components/qr-code' +import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' dayjs.extend(customParseFormat) const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate' @@ -37,6 +38,7 @@ const amountClass = columnClass + ' max-w-[75px] font-bold' export default function ChallengesListPage() { const user = useUser() const challenges = useAcceptedChallenges() + const [open, setOpen] = React.useState(false) const userChallenges = useUserChallenges(user?.id) .concat( user ? challenges.filter((c) => c.acceptances[0].userId === user.id) : [] @@ -70,6 +72,16 @@ export default function ChallengesListPage() { <Col className="w-full px-8"> <Row className="items-center justify-between"> <Title text="Challenges" /> + {CHALLENGES_ENABLED && ( + <Button size="lg" color="gradient" onClick={() => setOpen(true)}> + Create Challenge + <CreateChallengeModal + isOpen={open} + setOpen={setOpen} + user={user} + /> + </Button> + )} </Row> <p> <SiteLink className={'font-bold'} href={'/home'}> From 68a949de3525c814a8a8b4dda1318e85c5822211 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 18 Aug 2022 08:22:37 -0600 Subject: [PATCH 04/48] Change Challenge page wording --- web/pages/challenges/index.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx index f3ab8ac1..ad4136f0 100644 --- a/web/pages/challenges/index.tsx +++ b/web/pages/challenges/index.tsx @@ -84,14 +84,12 @@ export default function ChallengesListPage() { )} </Row> <p> - <SiteLink className={'font-bold'} href={'/home'}> + Want to create your own challenge? + <SiteLink className={'mx-1 font-bold'} href={'/home'}> Find - </SiteLink>{' '} - or{' '} - <SiteLink className={'font-bold'} href={'/create'}> - create - </SiteLink>{' '} - a market to challenge someone to a bet. + </SiteLink> + a market you and a friend disagree on and hit the challenge button, or + tap the button above to create a new market & challenge in one. </p> <Tabs tabs={[...userTab, ...publicTab]} /> From 87561503c18dfea4cf656aeab395f35312ad4aed Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 18 Aug 2022 10:39:48 -0500 Subject: [PATCH 05/48] accept challenge: redeem shares --- functions/src/accept-challenge.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/functions/src/accept-challenge.ts b/functions/src/accept-challenge.ts index eae6ab55..a96a4027 100644 --- a/functions/src/accept-challenge.ts +++ b/functions/src/accept-challenge.ts @@ -11,6 +11,7 @@ import { CandidateBet } from '../../common/new-bet' import { createChallengeAcceptedNotification } from './create-notification' import { noFees } from '../../common/fees' import { formatMoney, formatPercent } from '../../common/util/format' +import { redeemShares } from './redeem-shares' const bodySchema = z.object({ contractId: z.string(), @@ -163,5 +164,7 @@ export const acceptchallenge = newEndpoint({}, async (req, auth) => { return yourNewBetDoc }) + await redeemShares(auth.uid, contractId) + return { betId: result.id } }) From c9c3a95d2af1591b8d0d9af6d25cd170a337689f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 18 Aug 2022 09:54:30 -0600 Subject: [PATCH 06/48] Condense user profile bits --- web/components/user-page.tsx | 65 ++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index f412e38b..c8a697c3 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -58,8 +58,6 @@ export function UserLink(props: { ) } -export const TAB_IDS = ['markets', 'comments', 'bets', 'groups'] - export function UserPage(props: { user: User }) { const { user } = props const router = useRouter() @@ -103,10 +101,10 @@ export function UserPage(props: { user: User }) { </div> {/* Top right buttons (e.g. edit, follow) */} - <div className="absolute right-0 top-0 mt-4 mr-4"> + <div className="absolute right-0 top-0 mt-2 mr-4"> {!isCurrentUser && <UserFollowButton userId={user.id} />} {isCurrentUser && ( - <SiteLink className="btn" href="/profile"> + <SiteLink className="sm:btn-md btn-sm btn" href="/profile"> <PencilIcon className="h-5 w-5" />{' '} <div className="ml-2">Edit</div> </SiteLink> @@ -116,19 +114,22 @@ export function UserPage(props: { user: User }) { {/* Profile details: name, username, bio, and link to twitter/discord */} <Col className="mx-4 -mt-6"> - <span className="text-2xl font-bold">{user.name}</span> + <Row className={'items-center gap-2'}> + <span className="text-2xl font-bold">{user.name}</span> + <span className="mt-1 text-gray-500"> + <span + className={clsx( + 'text-md', + profit >= 0 ? 'text-green-600' : 'text-red-400' + )} + > + {formatMoney(profit)} + </span>{' '} + profit + </span> + </Row> <span className="text-gray-500">@{user.username}</span> - <span className="text-gray-500"> - <span - className={clsx( - 'text-md', - profit >= 0 ? 'text-green-600' : 'text-red-400' - )} - > - {formatMoney(profit)} - </span>{' '} - profit - </span> + <Spacer h={4} /> {user.bio && ( <> @@ -138,17 +139,7 @@ export function UserPage(props: { user: User }) { <Spacer h={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} /> - {currentUser && - ['ian', 'Austin', 'SG', 'JamesGrugett'].includes( - currentUser.username - ) && <ReferralsButton user={user} />} - <GroupsButton user={user} /> - </Row> - + <Row className="flex-wrap items-center gap-2 sm:gap-4"> {user.website && ( <SiteLink href={ @@ -198,7 +189,7 @@ export function UserPage(props: { user: User }) { </Row> </SiteLink> )} - </Col> + </Row> <Spacer h={5} /> {currentUser?.id === user.id && ( <Row @@ -208,7 +199,7 @@ export function UserPage(props: { user: User }) { > <span> <SiteLink href="/referrals"> - Refer a friend and earn {formatMoney(500)} when they sign up! + Earn {formatMoney(500)} when you refer a friend! </SiteLink>{' '} You have <ReferralsButton user={user} currentUser={currentUser} /> </span> @@ -244,6 +235,22 @@ export function UserPage(props: { user: User }) { </> ), }, + { + title: 'Social', + content: ( + <Row + className={'mt-2 flex-wrap items-center justify-center gap-6'} + > + <FollowingButton user={user} /> + <FollowersButton user={user} /> + {currentUser && + ['ian', 'Austin', 'SG', 'JamesGrugett'].includes( + currentUser.username + ) && <ReferralsButton user={user} />} + <GroupsButton user={user} /> + </Row> + ), + }, ]} /> </Col> From 56e9b5fa2f6850b83a39915b81bb5f68b950f65c Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 18 Aug 2022 11:02:52 -0500 Subject: [PATCH 07/48] create contract: ante no longer user liquidity provision --- 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 cef0dd48..3e9998ed 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -14,15 +14,17 @@ import { import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' -import { chargeUser, getContract } from './utils' +import { chargeUser, getContract, isProd } from './utils' import { APIError, newEndpoint, validate, zTimestamp } from './api' import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, FIXED_ANTE, getCpmmInitialLiquidity, getFreeAnswerAnte, getMultipleChoiceAntes, getNumericAnte, + HOUSE_LIQUIDITY_PROVIDER_ID, } from '../../common/antes' import { Answer, getNoneAnswer } from '../../common/answer' import { getNewContract } from '../../common/new-contract' @@ -211,7 +213,9 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } } - const providerId = user.id + const providerId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { const liquidityDoc = firestore From d216b298bab5972e44fc072d0ec2c796bff2d32c Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 18 Aug 2022 11:03:46 -0500 Subject: [PATCH 08/48] "create-contract.ts" => "create-market.ts" --- functions/src/{create-contract.ts => create-market.ts} | 0 functions/src/index.ts | 4 ++-- functions/src/serve.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename functions/src/{create-contract.ts => create-market.ts} (100%) diff --git a/functions/src/create-contract.ts b/functions/src/create-market.ts similarity index 100% rename from functions/src/create-contract.ts rename to functions/src/create-market.ts diff --git a/functions/src/index.ts b/functions/src/index.ts index 07b37648..5cfa27db 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -37,7 +37,7 @@ export * from './cancel-bet' export * from './sell-bet' export * from './sell-shares' export * from './claim-manalink' -export * from './create-contract' +export * from './create-market' export * from './add-liquidity' export * from './withdraw-liquidity' export * from './create-group' @@ -56,7 +56,7 @@ import { cancelbet } from './cancel-bet' import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' -import { createmarket } from './create-contract' +import { createmarket } from './create-market' import { addliquidity } from './add-liquidity' import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' diff --git a/functions/src/serve.ts b/functions/src/serve.ts index bf96db20..8d848f7f 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -18,7 +18,7 @@ import { cancelbet } from './cancel-bet' import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' -import { createmarket } from './create-contract' +import { createmarket } from './create-market' import { addliquidity } from './add-liquidity' import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' From 097000c9da8d7701be585ed321802bd6af6add9d Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 18 Aug 2022 11:23:16 -0500 Subject: [PATCH 09/48] Don't scroll to top on search change except on home --- web/components/contract-search.tsx | 4 +++- web/pages/home.tsx | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 11d65a13..ebcba985 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -91,6 +91,7 @@ export function ContractSearch(props: { headerClassName?: string useQuerySortLocalStorage?: boolean useQuerySortUrlParams?: boolean + isWholePage?: boolean }) { const { user, @@ -105,6 +106,7 @@ export function ContractSearch(props: { headerClassName, useQuerySortLocalStorage, useQuerySortUrlParams, + isWholePage, } = props const [numPages, setNumPages] = useState(1) @@ -139,7 +141,7 @@ export function ContractSearch(props: { setNumPages(results.nbPages) if (freshQuery) { setPages([newPage]) - window.scrollTo(0, 0) + if (isWholePage) window.scrollTo(0, 0) } else { setPages((pages) => [...pages, newPage]) } diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 8153baea..e61d5c32 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -45,6 +45,7 @@ const Home = (props: { auth: { user: User } | null }) => { // Update the url without switching pages in Nextjs. history.pushState(null, '', `/${c.creatorUsername}/${c.slug}`) }} + isWholePage /> </Col> <button From c2db558b85ded89d6c32839874e860ba4407a5b3 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 18 Aug 2022 10:12:38 -0700 Subject: [PATCH 10/48] Describe why subsidizing is good --- web/components/liquidity-panel.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/components/liquidity-panel.tsx b/web/components/liquidity-panel.tsx index 94cf63b5..0474abf7 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/liquidity-panel.tsx @@ -12,6 +12,7 @@ import { Tabs } from './layout/tabs' import { NoLabel, YesLabel } from './outcome-label' import { Col } from './layout/col' import { track } from 'web/lib/service/analytics' +import { InfoTooltip } from './info-tooltip' export function LiquidityPanel(props: { contract: CPMMContract }) { const { contract } = props @@ -101,8 +102,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { return ( <> - <div className="align-center mb-4 text-gray-500"> - Subsidize this market by adding M$ to the liquidity pool. + <div className="mb-4 text-gray-500"> + Contribute your M$ to make this market more accurate.{' '} + <InfoTooltip text="More liquidity stabilizes the market, encouraging traders to bet. You can withdraw your subsidy at any time." /> </div> <Row> From c37997bcb7f3be6cb7ebd91237c26bb7feb6ad2b Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 18 Aug 2022 12:47:35 -0700 Subject: [PATCH 11/48] Add comment type field to comments (#772) --- common/comment.ts | 2 ++ .../src/scripts/backfill-comment-types.ts | 31 +++++++++++++++++++ web/lib/firebase/comments.ts | 2 ++ 3 files changed, 35 insertions(+) create mode 100644 functions/src/scripts/backfill-comment-types.ts diff --git a/common/comment.ts b/common/comment.ts index 77b211d3..c1b721cc 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -4,6 +4,8 @@ import type { JSONContent } from '@tiptap/core' // They're uniquely identified by the pair contractId/betId. export type Comment = { id: string + commentType: 'contract' | 'group' + contractId?: string groupId?: string betId?: string diff --git a/functions/src/scripts/backfill-comment-types.ts b/functions/src/scripts/backfill-comment-types.ts new file mode 100644 index 00000000..cce1c85d --- /dev/null +++ b/functions/src/scripts/backfill-comment-types.ts @@ -0,0 +1,31 @@ +// Comment types were introduced in August 2022. + +import { initAdmin } from './script-init' +import { log, writeAsync } from '../utils' + +if (require.main === module) { + const app = initAdmin() + const firestore = app.firestore() + const commentsRef = firestore.collectionGroup('comments') + commentsRef.get().then(async (commentsSnaps) => { + log(`Loaded ${commentsSnaps.size} contracts.`) + const needsFilling = commentsSnaps.docs.filter((ct) => { + return !('commentType' in ct.data()) + }) + log(`Found ${needsFilling.length} comments to update.`) + const updates = needsFilling.map((d) => { + const comment = d.data() + const fields: { [k: string]: unknown } = {} + if (comment.contractId != null && comment.groupId == null) { + fields.commentType = 'contract' + } else if (comment.groupId != null && comment.contractId == null) { + fields.commentType = 'group' + } else { + log(`Invalid comment ${comment}; not touching it.`) + } + return { doc: d.ref, fields, info: comment } + }) + await writeAsync(firestore, updates) + log(`Updated all comments.`) + }) +} diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index e82c6d45..f0678367 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -33,6 +33,7 @@ export async function createCommentOnContract( : doc(getCommentsCollection(contractId)) const comment: Comment = removeUndefinedProps({ id: ref.id, + commentType: 'contract', contractId, userId: commenter.id, content: content, @@ -61,6 +62,7 @@ export async function createCommentOnGroup( const ref = doc(getCommentsOnGroupCollection(groupId)) const comment: Comment = removeUndefinedProps({ id: ref.id, + commentType: 'group', groupId, userId: user.id, content: content, From 06ced7042da0e1774ef88090d14a9670fa55978e Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 18 Aug 2022 12:49:01 -0700 Subject: [PATCH 12/48] Fix a typo in my script --- functions/src/scripts/backfill-comment-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/scripts/backfill-comment-types.ts b/functions/src/scripts/backfill-comment-types.ts index cce1c85d..6b61170e 100644 --- a/functions/src/scripts/backfill-comment-types.ts +++ b/functions/src/scripts/backfill-comment-types.ts @@ -8,7 +8,7 @@ if (require.main === module) { const firestore = app.firestore() const commentsRef = firestore.collectionGroup('comments') commentsRef.get().then(async (commentsSnaps) => { - log(`Loaded ${commentsSnaps.size} contracts.`) + log(`Loaded ${commentsSnaps.size} comments.`) const needsFilling = commentsSnaps.docs.filter((ct) => { return !('commentType' in ct.data()) }) From 4f6d47821192d4c8e32aeb8350bf52b86721d680 Mon Sep 17 00:00:00 2001 From: Keri Warr <keri@warr.ca> Date: Thu, 18 Aug 2022 17:59:18 -0400 Subject: [PATCH 13/48] List manifold-sdk on the Awesome Manifold page (#774) --- docs/docs/awesome-manifold.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index 0871be52..458b81ee 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -18,6 +18,7 @@ A list of community-created projects built on, or related to, Manifold Markets. - [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics) - [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets - [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets +- [manifold-sdk](https://github.com/keriwarr/manifold-sdk) - TypeScript/JavaScript client for the Manifold API ## Bots From 0cf9a90cfbc39fc561cab2e6f0ed4bcd222fabf6 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 18 Aug 2022 15:46:11 -0700 Subject: [PATCH 14/48] Remove some dead code related to tags, categories, and old feed stuff (#765) * Remove dead image storage code * Kill tag page * Kill tag and categories related component UI * Kill some old algo feed kind of code --- common/recommended-contracts.ts | 187 ---------------------- web/components/feed/category-selector.tsx | 167 ------------------- web/components/tags-input.tsx | 55 ------- web/components/tags-list.tsx | 86 ---------- web/hooks/use-algo-feed.ts | 51 ------ web/lib/firebase/contracts.ts | 11 -- web/lib/firebase/users.ts | 45 +----- web/pages/tag/[tag].tsx | 24 --- 8 files changed, 1 insertion(+), 625 deletions(-) delete mode 100644 common/recommended-contracts.ts delete mode 100644 web/components/feed/category-selector.tsx delete mode 100644 web/components/tags-input.tsx delete mode 100644 web/components/tags-list.tsx delete mode 100644 web/hooks/use-algo-feed.ts delete mode 100644 web/pages/tag/[tag].tsx diff --git a/common/recommended-contracts.ts b/common/recommended-contracts.ts deleted file mode 100644 index 3a6eca38..00000000 --- a/common/recommended-contracts.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { union, sum, sumBy, sortBy, groupBy, mapValues } from 'lodash' -import { Bet } from './bet' -import { Contract } from './contract' -import { ClickEvent } from './tracking' -import { filterDefined } from './util/array' -import { addObjects } from './util/object' - -export const MAX_FEED_CONTRACTS = 75 - -export const getRecommendedContracts = ( - contractsById: { [contractId: string]: Contract }, - yourBetOnContractIds: string[] -) => { - const contracts = Object.values(contractsById) - const yourContracts = filterDefined( - yourBetOnContractIds.map((contractId) => contractsById[contractId]) - ) - - const yourContractIds = new Set(yourContracts.map((c) => c.id)) - const notYourContracts = contracts.filter((c) => !yourContractIds.has(c.id)) - - const yourWordFrequency = contractsToWordFrequency(yourContracts) - const otherWordFrequency = contractsToWordFrequency(notYourContracts) - const words = union( - Object.keys(yourWordFrequency), - Object.keys(otherWordFrequency) - ) - - const yourWeightedFrequency = Object.fromEntries( - words.map((word) => { - const [yourFreq, otherFreq] = [ - yourWordFrequency[word] ?? 0, - otherWordFrequency[word] ?? 0, - ] - - const score = yourFreq / (yourFreq + otherFreq + 0.0001) - - return [word, score] - }) - ) - - // console.log( - // 'your weighted frequency', - // _.sortBy(_.toPairs(yourWeightedFrequency), ([, freq]) => -freq) - // ) - - const scoredContracts = contracts.map((contract) => { - const wordFrequency = contractToWordFrequency(contract) - - const score = sumBy(Object.keys(wordFrequency), (word) => { - const wordFreq = wordFrequency[word] ?? 0 - const weight = yourWeightedFrequency[word] ?? 0 - return wordFreq * weight - }) - - return { - contract, - score, - } - }) - - return sortBy(scoredContracts, (scored) => -scored.score).map( - (scored) => scored.contract - ) -} - -const contractToText = (contract: Contract) => { - const { description, question, tags, creatorUsername } = contract - return `${creatorUsername} ${question} ${tags.join(' ')} ${description}` -} - -const MAX_CHARS_IN_WORD = 100 - -const getWordsCount = (text: string) => { - const normalizedText = text.replace(/[^a-zA-Z]/g, ' ').toLowerCase() - const words = normalizedText - .split(' ') - .filter((word) => word) - .filter((word) => word.length <= MAX_CHARS_IN_WORD) - - const counts: { [word: string]: number } = {} - for (const word of words) { - if (counts[word]) counts[word]++ - else counts[word] = 1 - } - return counts -} - -const toFrequency = (counts: { [word: string]: number }) => { - const total = sum(Object.values(counts)) - return mapValues(counts, (count) => count / total) -} - -const contractToWordFrequency = (contract: Contract) => - toFrequency(getWordsCount(contractToText(contract))) - -const contractsToWordFrequency = (contracts: Contract[]) => { - const frequencySum = contracts - .map(contractToWordFrequency) - .reduce(addObjects, {}) - - return toFrequency(frequencySum) -} - -export const getWordScores = ( - contracts: Contract[], - contractViewCounts: { [contractId: string]: number }, - clicks: ClickEvent[], - bets: Bet[] -) => { - const contractClicks = groupBy(clicks, (click) => click.contractId) - const contractBets = groupBy(bets, (bet) => bet.contractId) - - const yourContracts = contracts.filter( - (c) => - contractViewCounts[c.id] || contractClicks[c.id] || contractBets[c.id] - ) - const yourTfIdf = calculateContractTfIdf(yourContracts) - - const contractWordScores = mapValues(yourTfIdf, (wordsTfIdf, contractId) => { - const viewCount = contractViewCounts[contractId] ?? 0 - const clickCount = contractClicks[contractId]?.length ?? 0 - const betCount = contractBets[contractId]?.length ?? 0 - - const factor = - -1 * Math.log(viewCount + 1) + - 10 * Math.log(betCount + clickCount / 4 + 1) - - return mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor) - }) - - const wordScores = Object.values(contractWordScores).reduce(addObjects, {}) - const minScore = Math.min(...Object.values(wordScores)) - const maxScore = Math.max(...Object.values(wordScores)) - const normalizedWordScores = mapValues( - wordScores, - (score) => (score - minScore) / (maxScore - minScore) - ) - - // console.log( - // 'your word scores', - // _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(0, 100), - // _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(-100) - // ) - - return normalizedWordScores -} - -export function getContractScore( - contract: Contract, - wordScores: { [word: string]: number } -) { - if (Object.keys(wordScores).length === 0) return 1 - - const wordFrequency = contractToWordFrequency(contract) - const score = sumBy(Object.keys(wordFrequency), (word) => { - const wordFreq = wordFrequency[word] ?? 0 - const weight = wordScores[word] ?? 0 - return wordFreq * weight - }) - - return score -} - -// Caluculate Term Frequency-Inverse Document Frequency (TF-IDF): -// https://medium.datadriveninvestor.com/tf-idf-in-natural-language-processing-8db8ef4a7736 -function calculateContractTfIdf(contracts: Contract[]) { - const contractFreq = contracts.map((c) => contractToWordFrequency(c)) - const contractWords = contractFreq.map((freq) => Object.keys(freq)) - - const wordsCount: { [word: string]: number } = {} - for (const words of contractWords) { - for (const word of words) { - wordsCount[word] = (wordsCount[word] ?? 0) + 1 - } - } - - const wordIdf = mapValues(wordsCount, (count) => - Math.log(contracts.length / count) - ) - const contractWordsTfIdf = contractFreq.map((wordFreq) => - mapValues(wordFreq, (freq, word) => freq * wordIdf[word]) - ) - return Object.fromEntries( - contracts.map((c, i) => [c.id, contractWordsTfIdf[i]]) - ) -} diff --git a/web/components/feed/category-selector.tsx b/web/components/feed/category-selector.tsx deleted file mode 100644 index a39f7402..00000000 --- a/web/components/feed/category-selector.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import clsx from 'clsx' -import { PencilIcon } from '@heroicons/react/outline' -import { union, difference } from 'lodash' - -import { Row } from '../layout/row' -import { CATEGORIES, category, CATEGORY_LIST } from '../../../common/categories' -import { Modal } from '../layout/modal' -import { Col } from '../layout/col' -import { useState } from 'react' -import { updateUser, User } from 'web/lib/firebase/users' -import { Checkbox } from '../checkbox' -import { track } from 'web/lib/service/analytics' - -export function CategorySelector(props: { - category: string - setCategory: (category: string) => void - className?: string -}) { - const { className, category, setCategory } = props - - return ( - <Row - className={clsx( - 'carousel mr-2 items-center space-x-2 space-y-2 overflow-x-scroll pb-4 sm:flex-wrap', - className - )} - > - <div /> - <CategoryButton - key="all" - category="All" - isFollowed={category === 'all'} - toggle={() => { - setCategory('all') - }} - /> - - <CategoryButton - key="following" - category="Following" - isFollowed={category === 'following'} - toggle={() => { - setCategory('following') - }} - /> - - {CATEGORY_LIST.map((cat) => ( - <CategoryButton - key={cat} - category={CATEGORIES[cat as category].split(' ')[0]} - isFollowed={cat === category} - toggle={() => { - setCategory(cat) - }} - /> - ))} - </Row> - ) -} - -function CategoryButton(props: { - category: string - isFollowed: boolean - toggle: () => void - className?: string -}) { - const { toggle, category, isFollowed, className } = props - - return ( - <div - className={clsx( - className, - 'rounded-full border-2 px-4 py-1 shadow-md hover:bg-gray-200', - 'cursor-pointer select-none', - isFollowed ? 'border-gray-300 bg-gray-300' : 'bg-white' - )} - onClick={toggle} - > - <span className="text-sm text-gray-500">{category}</span> - </div> - ) -} - -export function EditCategoriesButton(props: { - user: User - className?: string -}) { - const { user, className } = props - const [isOpen, setIsOpen] = useState(false) - - return ( - <div - className={clsx( - className, - 'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700' - )} - onClick={() => { - setIsOpen(true) - track('edit categories button') - }} - > - <PencilIcon className="inline h-4 w-4" /> - Categories - <CategorySelectorModal - user={user} - isOpen={isOpen} - setIsOpen={setIsOpen} - /> - </div> - ) -} - -function CategorySelectorModal(props: { - user: User - isOpen: boolean - setIsOpen: (isOpen: boolean) => void -}) { - const { user, isOpen, setIsOpen } = props - const followedCategories = - user?.followedCategories === undefined - ? CATEGORY_LIST - : user.followedCategories - - const selectAll = - user.followedCategories === undefined || - followedCategories.length < CATEGORY_LIST.length - - return ( - <Modal open={isOpen} setOpen={setIsOpen}> - <Col className="rounded bg-white p-6"> - <button - className="btn btn-sm btn-outline mb-4 self-start normal-case" - onClick={() => { - if (selectAll) { - updateUser(user.id, { - followedCategories: CATEGORY_LIST, - }) - } else { - updateUser(user.id, { - followedCategories: [], - }) - } - }} - > - Select {selectAll ? 'all' : 'none'} - </button> - <Col className="grid w-full grid-cols-2 gap-4"> - {CATEGORY_LIST.map((cat) => ( - <Checkbox - className="col-span-1" - key={cat} - label={CATEGORIES[cat as category].split(' ')[0]} - checked={followedCategories.includes(cat)} - toggle={(checked) => { - updateUser(user.id, { - followedCategories: checked - ? difference(followedCategories, [cat]) - : union([cat], followedCategories), - }) - }} - /> - ))} - </Col> - </Col> - </Modal> - ) -} diff --git a/web/components/tags-input.tsx b/web/components/tags-input.tsx deleted file mode 100644 index dd8a2f1d..00000000 --- a/web/components/tags-input.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import clsx from 'clsx' -import { useState } from 'react' -import { parseWordsAsTags } from 'common/util/parse' -import { Contract, updateContract } from 'web/lib/firebase/contracts' -import { Col } from './layout/col' -import { Row } from './layout/row' -import { TagsList } from './tags-list' -import { MAX_TAG_LENGTH } from 'common/contract' - -export function TagsInput(props: { contract: Contract; className?: string }) { - const { contract, className } = props - const { tags } = contract - - const [tagText, setTagText] = useState('') - const newTags = parseWordsAsTags(`${tags.join(' ')} ${tagText}`) - - const [isSubmitting, setIsSubmitting] = useState(false) - - const updateTags = async () => { - setIsSubmitting(true) - await updateContract(contract.id, { - tags: newTags, - lowercaseTags: newTags.map((tag) => tag.toLowerCase()), - }) - setIsSubmitting(false) - setTagText('') - } - - return ( - <Col className={clsx('gap-4', className)}> - <TagsList tags={newTags} noLabel /> - - <Row className="items-center gap-4"> - <input - style={{ maxWidth: 150 }} - placeholder="Type a tag..." - className="input input-sm input-bordered resize-none" - disabled={isSubmitting} - value={tagText} - maxLength={MAX_TAG_LENGTH} - onChange={(e) => setTagText(e.target.value || '')} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - updateTags() - } - }} - /> - <button className="btn btn-xs btn-outline" onClick={updateTags}> - Save tags - </button> - </Row> - </Col> - ) -} diff --git a/web/components/tags-list.tsx b/web/components/tags-list.tsx deleted file mode 100644 index a13bcd35..00000000 --- a/web/components/tags-list.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import clsx from 'clsx' -import { CATEGORIES, category } from '../../common/categories' -import { Col } from './layout/col' - -import { Row } from './layout/row' -import { SiteLink } from './site-link' - -function Hashtag(props: { tag: string; noLink?: boolean }) { - const { tag, noLink } = props - const category = CATEGORIES[tag.replace('#', '').toLowerCase() as category] - - const body = ( - <div className={clsx('', !noLink && 'cursor-pointer')}> - <span className="text-sm">{category ? '#' + category : tag} </span> - </div> - ) - - if (noLink) return body - return ( - <SiteLink href={`/tag/${tag.substring(1)}`} className="flex items-center"> - {body} - </SiteLink> - ) -} - -export function TagsList(props: { - tags: string[] - className?: string - noLink?: boolean - noLabel?: boolean - label?: string -}) { - const { tags, className, noLink, noLabel, label } = props - return ( - <Row className={clsx('flex-wrap items-center gap-2', className)}> - {!noLabel && <div className="mr-1">{label || 'Tags'}</div>} - {tags.map((tag) => ( - <Hashtag - key={tag} - tag={tag.startsWith('#') ? tag : `#${tag}`} - noLink={noLink} - /> - ))} - </Row> - ) -} - -export function FoldTag(props: { fold: { slug: string; name: string } }) { - const { fold } = props - const { slug, name } = fold - - return ( - <SiteLink href={`/fold/${slug}`} className="flex items-center"> - <div - className={clsx( - 'rounded-full border-2 bg-white px-4 py-1 shadow-md', - 'cursor-pointer' - )} - > - <span className="text-sm text-gray-500">{name}</span> - </div> - </SiteLink> - ) -} - -export function FoldTagList(props: { - folds: { slug: string; name: string }[] - noLabel?: boolean - className?: string -}) { - const { folds, noLabel, className } = props - return ( - <Col className="gap-2"> - {!noLabel && <div className="mr-1 text-gray-500">Communities</div>} - <Row className={clsx('flex-wrap items-center gap-2', className)}> - {folds.length > 0 && ( - <> - {folds.map((fold) => ( - <FoldTag key={fold.slug} fold={fold} /> - ))} - </> - )} - </Row> - </Col> - ) -} diff --git a/web/hooks/use-algo-feed.ts b/web/hooks/use-algo-feed.ts deleted file mode 100644 index e195936f..00000000 --- a/web/hooks/use-algo-feed.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useState, useEffect } from 'react' -import type { feed } from 'common/feed' -import { useTimeSinceFirstRender } from './use-time-since-first-render' -import { trackLatency } from 'web/lib/firebase/tracking' -import { User } from 'common/user' -import { getCategoryFeeds, getUserFeed } from 'web/lib/firebase/users' -import { - getRecentBetsAndComments, - getTopWeeklyContracts, -} from 'web/lib/firebase/contracts' - -export const useAlgoFeed = ( - user: User | null | undefined, - category: string -) => { - const [allFeed, setAllFeed] = useState<feed>() - const [categoryFeeds, setCategoryFeeds] = useState<{ [x: string]: feed }>() - - const getTime = useTimeSinceFirstRender() - - useEffect(() => { - if (user) { - getUserFeed(user.id).then((feed) => { - if (feed.length === 0) { - getDefaultFeed().then((feed) => setAllFeed(feed)) - } else setAllFeed(feed) - - trackLatency(user.id, 'feed', getTime()) - console.log('"all" feed load time', getTime()) - }) - - getCategoryFeeds(user.id).then((feeds) => { - setCategoryFeeds(feeds) - console.log('category feeds load time', getTime()) - }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user?.id]) - - const feed = category === 'all' ? allFeed : categoryFeeds?.[category] - - return feed -} - -const getDefaultFeed = async () => { - const contracts = await getTopWeeklyContracts() - const feed = await Promise.all( - contracts.map((c) => getRecentBetsAndComments(c)) - ) - return feed -} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index b31b8d04..243a453a 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -22,7 +22,6 @@ import { createRNG, shuffle } from 'common/util/random' import { getCpmmProbability } from 'common/calculate-cpmm' import { formatMoney, formatPercent } from 'common/util/format' import { DAY_MS } from 'common/util/time' -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' @@ -285,16 +284,6 @@ export async function getContractsBySlugs(slugs: string[]) { return sortBy(data, (contract) => -1 * contract.volume24Hours) } -const topWeeklyQuery = query( - contracts, - where('isResolved', '==', false), - orderBy('volume7Days', 'desc'), - limit(MAX_FEED_CONTRACTS) -) -export async function getTopWeeklyContracts() { - return await getValues<Contract>(topWeeklyQuery) -} - const closingSoonQuery = query( contracts, where('isResolved', '==', false), diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index ac0eb099..6cfee163 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -14,20 +14,10 @@ import { onSnapshot, } from 'firebase/firestore' import { getAuth } from 'firebase/auth' -import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth' -import { zip } from 'lodash' import { app, db } from './init' import { PortfolioMetrics, PrivateUser, User } from 'common/user' -import { - coll, - getValue, - getValues, - listenForValue, - listenForValues, -} from './utils' -import { feed } from 'common/feed' -import { CATEGORY_LIST } from 'common/categories' +import { coll, getValues, listenForValue, listenForValues } from './utils' import { safeLocalStorage } from '../util/local' import { filterDefined } from 'common/util/array' import { addUserToGroupViaId } from 'web/lib/firebase/groups' @@ -202,20 +192,6 @@ export async function firebaseLogout() { await auth.signOut() } -const storage = getStorage(app) -// Example: uploadData('avatars/ajfi8iejsf.png', data) -export async function uploadData( - path: string, - data: ArrayBuffer | Blob | Uint8Array -) { - const uploadRef = ref(storage, path) - // Uploaded files should be cached for 1 day, then revalidated - // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - const metadata = { cacheControl: 'public, max-age=86400, must-revalidate' } - await uploadBytes(uploadRef, data, metadata) - return await getDownloadURL(uploadRef) -} - export async function listUsers(userIds: string[]) { if (userIds.length > 10) { throw new Error('Too many users requested at once; Firestore limits to 10') @@ -263,25 +239,6 @@ export function getUsers() { return getValues<User>(users) } -export async function getUserFeed(userId: string) { - const feedDoc = doc(privateUsers, userId, 'cache', 'feed') - const userFeed = await getValue<{ - feed: feed - }>(feedDoc) - return userFeed?.feed ?? [] -} - -export async function getCategoryFeeds(userId: string) { - const cacheCollection = collection(privateUsers, userId, 'cache') - const feedData = await Promise.all( - CATEGORY_LIST.map((category) => - getValue<{ feed: feed }>(doc(cacheCollection, `feed-${category}`)) - ) - ) - const feeds = feedData.map((data) => data?.feed ?? []) - return Object.fromEntries(zip(CATEGORY_LIST, feeds) as [string, feed][]) -} - export async function follow(userId: string, followedUserId: string) { const followDoc = doc(collection(users, userId, 'follows'), followedUserId) await setDoc(followDoc, { diff --git a/web/pages/tag/[tag].tsx b/web/pages/tag/[tag].tsx deleted file mode 100644 index f2554f49..00000000 --- a/web/pages/tag/[tag].tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { useRouter } from 'next/router' -import { useUser } from 'web/hooks/use-user' -import { ContractSearch } from '../../components/contract-search' -import { Page } from '../../components/page' -import { Title } from '../../components/title' - -export default function TagPage() { - const router = useRouter() - const user = useUser() - const { tag } = router.query as { tag: string } - if (!router.isReady) return <div /> - - return ( - <Page> - <Title text={`#${tag}`} /> - <ContractSearch - user={user} - defaultSort="newest" - defaultFilter="all" - additionalFilter={{ tag }} - /> - </Page> - ) -} From 2537663a57fa6332008f2dd595e3b9d0b4ab257f Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 18 Aug 2022 17:20:40 -0700 Subject: [PATCH 15/48] Fix user avatar in mention list not updating (#778) --- web/components/editor/mention-list.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/components/editor/mention-list.tsx b/web/components/editor/mention-list.tsx index f9e67daf..aeab4636 100644 --- a/web/components/editor/mention-list.tsx +++ b/web/components/editor/mention-list.tsx @@ -51,6 +51,7 @@ export const MentionList = forwardRef((props: SuggestionProps<User>, ref) => { selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900' )} onClick={() => submitUser(i)} + key={user.id} > <Avatar avatarUrl={user.avatarUrl} size="xs" /> {user.username} From f2764e9258e51984ad33a3a2f0f97f7a968dace3 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 18 Aug 2022 18:54:09 -0700 Subject: [PATCH 16/48] Remove keyboard accessibility for tooltips (#779) Headless UI's Modal component autofocuses the first focusable item inside it when opened. This is by design for accessibility reasons. See https://headlessui.com/react/dialog#managing-initial-focus Ironically this means we'll have to remove keyboard focus for tooltips because this causes the tooltips to pop up unnecessarily for all users whenever the dialog is opened. The alternative is managing focus manually for several dialogs, which may not be possible as some of our modals lack a sensible element to focus by default. --- web/components/tooltip.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/components/tooltip.tsx b/web/components/tooltip.tsx index db6a934f..b053c6e7 100644 --- a/web/components/tooltip.tsx +++ b/web/components/tooltip.tsx @@ -6,7 +6,6 @@ import { Placement, shift, useFloating, - useFocus, useHover, useInteractions, useRole, @@ -48,7 +47,6 @@ export function Tooltip(props: { const { getReferenceProps, getFloatingProps } = useInteractions([ useHover(context, { mouseOnly: noTap }), - useFocus(context), useRole(context, { role: 'tooltip' }), ]) // which side of tooltip arrow is on. like: if tooltip is top-left, arrow is on bottom of tooltip @@ -64,7 +62,6 @@ export function Tooltip(props: { <div className={clsx('inline-block', className)} ref={reference} - tabIndex={noTap ? undefined : 0} {...getReferenceProps()} > {children} From 0972de9025541dbd01ea5d52721c8afd70d684ba Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 19 Aug 2022 01:06:40 -0700 Subject: [PATCH 17/48] Make typing for comments more fancy (#776) --- common/comment.ts | 29 +++++++++++++------ .../src/on-create-comment-on-contract.ts | 6 ++-- functions/src/on-create-comment-on-group.ts | 4 +-- web/components/comments-list.tsx | 12 +++----- .../contract/contract-leaderboard.tsx | 4 +-- web/components/contract/contract-tabs.tsx | 4 +-- web/components/feed/activity-items.ts | 18 ++++++------ web/components/feed/contract-activity.tsx | 4 +-- .../feed/feed-answer-comment-group.tsx | 6 ++-- web/components/feed/feed-comments.tsx | 20 ++++++------- web/components/feed/find-active-contracts.ts | 6 ++-- web/components/groups/group-chat.tsx | 11 +++---- web/components/tipper.tsx | 14 ++++----- web/hooks/use-comments.ts | 6 ++-- web/lib/firebase/comments.ts | 17 ++++++----- web/pages/[username]/[contractSlug].tsx | 7 +++-- web/pages/group/[...slugs]/index.tsx | 4 +-- 17 files changed, 91 insertions(+), 81 deletions(-) diff --git a/common/comment.ts b/common/comment.ts index c1b721cc..c7f9b855 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,15 +1,11 @@ import type { JSONContent } from '@tiptap/core' +export type AnyCommentType = OnContract | OnGroup + // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. -export type Comment = { +export type Comment<T extends AnyCommentType = AnyCommentType> = { id: string - commentType: 'contract' | 'group' - - contractId?: string - groupId?: string - betId?: string - answerOutcome?: string replyToCommentId?: string userId: string @@ -22,6 +18,21 @@ export type Comment = { userName: string userUsername: string userAvatarUrl?: string - contractSlug?: string - contractQuestion?: string +} & T + +type OnContract = { + commentType: 'contract' + contractId: string + contractSlug: string + contractQuestion: string + answerOutcome?: string + betId?: string } + +type OnGroup = { + commentType: 'group' + groupId: string +} + +export type ContractComment = Comment<OnContract> +export type GroupComment = Comment<OnGroup> diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 3fa0983d..9f19dfcc 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { compact, uniq } from 'lodash' import { getContract, getUser, getValues } from './utils' -import { Comment } from '../../common/comment' +import { ContractComment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' @@ -29,7 +29,7 @@ export const onCreateCommentOnContract = functions contractQuestion: contract.question, }) - const comment = change.data() as Comment + const comment = change.data() as ContractComment const lastCommentTime = comment.createdTime const commentCreator = await getUser(comment.userId) @@ -64,7 +64,7 @@ export const onCreateCommentOnContract = functions : undefined } - const comments = await getValues<Comment>( + const comments = await getValues<ContractComment>( firestore.collection('contracts').doc(contractId).collection('comments') ) const relatedSourceType = comment.replyToCommentId diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts index 0064480f..15f2bbc1 100644 --- a/functions/src/on-create-comment-on-group.ts +++ b/functions/src/on-create-comment-on-group.ts @@ -1,5 +1,5 @@ import * as functions from 'firebase-functions' -import { Comment } from '../../common/comment' +import { GroupComment } from '../../common/comment' import * as admin from 'firebase-admin' import { Group } from '../../common/group' import { User } from '../../common/user' @@ -14,7 +14,7 @@ export const onCreateCommentOnGroup = functions.firestore groupId: string } - const comment = change.data() as Comment + const comment = change.data() as GroupComment const creatorSnapshot = await firestore .collection('users') .doc(comment.userId) diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index 94799f4e..280787dd 100644 --- a/web/components/comments-list.tsx +++ b/web/components/comments-list.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { Comment } from 'common/comment' +import { Comment, ContractComment } from 'common/comment' import { groupConsecutive } from 'common/util/array' import { getUsersComments } from 'web/lib/firebase/comments' import { SiteLink } from './site-link' @@ -16,12 +16,6 @@ import { LoadingIndicator } from './loading-indicator' const COMMENTS_PER_PAGE = 50 -type ContractComment = Comment & { - contractId: string - contractSlug: string - contractQuestion: string -} - function contractPath(slug: string) { // by convention this includes the contract creator username, but we don't // have that handy, so we just put /market/ @@ -38,7 +32,9 @@ export function UserCommentsList(props: { user: User }) { useEffect(() => { getUsersComments(user.id).then((cs) => { // we don't show comments in groups here atm, just comments on contracts - setComments(cs.filter((c) => c.contractId) as ContractComment[]) + setComments( + cs.filter((c) => c.commentType == 'contract') as ContractComment[] + ) }) }, [user.id]) diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index 6f1a778d..22175876 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -1,5 +1,5 @@ import { Bet } from 'common/bet' -import { Comment } from 'common/comment' +import { ContractComment } from 'common/comment' import { resolvedPayout } from 'common/calculate' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' @@ -65,7 +65,7 @@ export function ContractLeaderboard(props: { export function ContractTopTrades(props: { contract: Contract bets: Bet[] - comments: Comment[] + comments: ContractComment[] tips: CommentTipMap }) { const { contract, bets, comments, tips } = props diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index eb455df0..9e9f62bf 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -1,6 +1,6 @@ import { Bet } from 'common/bet' import { Contract } from 'common/contract' -import { Comment } from 'web/lib/firebase/comments' +import { ContractComment } from 'common/comment' import { User } from 'common/user' import { ContractActivity } from '../feed/contract-activity' import { ContractBetsTable, BetsSummary } from '../bets-list' @@ -15,7 +15,7 @@ export function ContractTabs(props: { contract: Contract user: User | null | undefined bets: Bet[] - comments: Comment[] + comments: ContractComment[] tips: CommentTipMap }) { const { contract, user, bets, tips } = props diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index 511767c6..bcbb6721 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -3,7 +3,7 @@ import { uniq, sortBy } from 'lodash' import { Answer } from 'common/answer' import { Bet } from 'common/bet' import { getOutcomeProbability } from 'common/calculate' -import { Comment } from 'common/comment' +import { ContractComment } from 'common/comment' import { Contract, FreeResponseContract } from 'common/contract' import { User } from 'common/user' import { CommentTipMap } from 'web/hooks/use-tip-txns' @@ -28,7 +28,7 @@ type BaseActivityItem = { export type CommentInputItem = BaseActivityItem & { type: 'commentInput' betsByCurrentUser: Bet[] - commentsByCurrentUser: Comment[] + commentsByCurrentUser: ContractComment[] } export type DescriptionItem = BaseActivityItem & { @@ -50,8 +50,8 @@ export type BetItem = BaseActivityItem & { export type CommentThreadItem = BaseActivityItem & { type: 'commentThread' - parentComment: Comment - comments: Comment[] + parentComment: ContractComment + comments: ContractComment[] tips: CommentTipMap bets: Bet[] } @@ -60,7 +60,7 @@ export type AnswerGroupItem = BaseActivityItem & { type: 'answergroup' user: User | undefined | null answer: Answer - comments: Comment[] + comments: ContractComment[] tips: CommentTipMap bets: Bet[] } @@ -84,7 +84,7 @@ export type LiquidityItem = BaseActivityItem & { function getAnswerAndCommentInputGroups( contract: FreeResponseContract, bets: Bet[], - comments: Comment[], + comments: ContractComment[], tips: CommentTipMap, user: User | undefined | null ) { @@ -116,7 +116,7 @@ function getAnswerAndCommentInputGroups( function getCommentThreads( bets: Bet[], - comments: Comment[], + comments: ContractComment[], tips: CommentTipMap, contract: Contract ) { @@ -135,7 +135,7 @@ function getCommentThreads( return items } -function commentIsGeneralComment(comment: Comment, contract: Contract) { +function commentIsGeneralComment(comment: ContractComment, contract: Contract) { return ( comment.answerOutcome === undefined && (contract.outcomeType === 'FREE_RESPONSE' @@ -147,7 +147,7 @@ function commentIsGeneralComment(comment: Comment, contract: Contract) { export function getSpecificContractActivityItems( contract: Contract, bets: Bet[], - comments: Comment[], + comments: ContractComment[], liquidityProvisions: LiquidityProvision[], tips: CommentTipMap, user: User | null | undefined, diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index cd490701..3cc0acb0 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -1,5 +1,5 @@ import { Contract } from 'web/lib/firebase/contracts' -import { Comment } from 'web/lib/firebase/comments' +import { ContractComment } from 'common/comment' import { Bet } from 'common/bet' import { useBets } from 'web/hooks/use-bets' import { getSpecificContractActivityItems } from './activity-items' @@ -12,7 +12,7 @@ import { LiquidityProvision } from 'common/liquidity-provision' export function ContractActivity(props: { contract: Contract bets: Bet[] - comments: Comment[] + comments: ContractComment[] liquidityProvisions: LiquidityProvision[] tips: CommentTipMap user: User | null | undefined diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index edaf1fe5..86686f1f 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -1,6 +1,6 @@ import { Answer } from 'common/answer' import { Bet } from 'common/bet' -import { Comment } from 'common/comment' +import { ContractComment } from 'common/comment' import React, { useEffect, useState } from 'react' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' @@ -24,7 +24,7 @@ export function FeedAnswerCommentGroup(props: { contract: any user: User | undefined | null answer: Answer - comments: Comment[] + comments: ContractComment[] tips: CommentTipMap bets: Bet[] }) { @@ -69,7 +69,7 @@ export function FeedAnswerCommentGroup(props: { ]) const scrollAndOpenReplyInput = useEvent( - (comment?: Comment, answer?: Answer) => { + (comment?: ContractComment, answer?: Answer) => { setReplyToUser( comment ? { id: comment.userId, username: comment.userUsername } diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index d4ba98b6..0541a7ba 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -1,5 +1,5 @@ import { Bet } from 'common/bet' -import { Comment } from 'common/comment' +import { ContractComment } from 'common/comment' import { User } from 'common/user' import { Contract } from 'common/contract' import React, { useEffect, useState } from 'react' @@ -32,9 +32,9 @@ import { Editor } from '@tiptap/react' export function FeedCommentThread(props: { contract: Contract - comments: Comment[] + comments: ContractComment[] tips: CommentTipMap - parentComment: Comment + parentComment: ContractComment bets: Bet[] smallAvatar?: boolean }) { @@ -50,7 +50,7 @@ export function FeedCommentThread(props: { ) commentsList.unshift(parentComment) - function scrollAndOpenReplyInput(comment: Comment) { + function scrollAndOpenReplyInput(comment: ContractComment) { setReplyToUser({ id: comment.userId, username: comment.userUsername }) setShowReply(true) } @@ -95,10 +95,10 @@ export function FeedCommentThread(props: { export function CommentRepliesList(props: { contract: Contract - commentsList: Comment[] + commentsList: ContractComment[] betsByUserId: Dictionary<Bet[]> tips: CommentTipMap - scrollAndOpenReplyInput: (comment: Comment) => void + scrollAndOpenReplyInput: (comment: ContractComment) => void bets: Bet[] treatFirstIndexEqually?: boolean smallAvatar?: boolean @@ -156,12 +156,12 @@ export function CommentRepliesList(props: { export function FeedComment(props: { contract: Contract - comment: Comment + comment: ContractComment tips: CommentTips betsBySameUser: Bet[] probAtCreatedTime?: number smallAvatar?: boolean - onReplyClick?: (comment: Comment) => void + onReplyClick?: (comment: ContractComment) => void }) { const { contract, @@ -274,7 +274,7 @@ export function FeedComment(props: { export function getMostRecentCommentableBet( betsByCurrentUser: Bet[], - commentsByCurrentUser: Comment[], + commentsByCurrentUser: ContractComment[], user?: User | null, answerOutcome?: string ) { @@ -319,7 +319,7 @@ function CommentStatus(props: { export function CommentInput(props: { contract: Contract betsByCurrentUser: Bet[] - commentsByCurrentUser: Comment[] + commentsByCurrentUser: ContractComment[] replyToUser?: { id: string; username: string } // Reply to a free response answer parentAnswerOutcome?: string diff --git a/web/components/feed/find-active-contracts.ts b/web/components/feed/find-active-contracts.ts index c53b3622..ad2af970 100644 --- a/web/components/feed/find-active-contracts.ts +++ b/web/components/feed/find-active-contracts.ts @@ -1,6 +1,6 @@ import { groupBy, mapValues, maxBy, sortBy } from 'lodash' import { Contract } from 'web/lib/firebase/contracts' -import { Comment } from 'web/lib/firebase/comments' +import { ContractComment } from 'common/comment' import { Bet } from 'common/bet' const MAX_ACTIVE_CONTRACTS = 75 @@ -19,7 +19,7 @@ function lastActivityTime(contract: Contract) { // - Bet on market export function findActiveContracts( allContracts: Contract[], - recentComments: Comment[], + recentComments: ContractComment[], recentBets: Bet[], seenContracts: { [contractId: string]: number } ) { @@ -73,7 +73,7 @@ export function findActiveContracts( ) const contractMostRecentComment = mapValues( contractComments, - (comments) => maxBy(comments, (c) => c.createdTime) as Comment + (comments) => maxBy(comments, (c) => c.createdTime) as ContractComment ) const prioritizedContracts = sortBy(activeContracts, (c) => { diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index a37377a5..781705c2 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -4,7 +4,8 @@ import { PrivateUser, User } from 'common/user' 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' +import { Comment, GroupComment } from 'common/comment' +import { createCommentOnGroup } from 'web/lib/firebase/comments' import { CommentInputTextArea } from 'web/components/feed/feed-comments' import { track } from 'web/lib/service/analytics' import { firebaseLogin } from 'web/lib/firebase/users' @@ -24,7 +25,7 @@ import { setNotificationsAsSeen } from 'web/pages/notifications' import { usePrivateUser } from 'web/hooks/use-user' export function GroupChat(props: { - messages: Comment[] + messages: GroupComment[] user: User | null | undefined group: Group tips: CommentTipMap @@ -58,7 +59,7 @@ export function GroupChat(props: { // array of groups, where each group is an array of messages that are displayed as one const groupedMessages = useMemo(() => { // Group messages with createdTime within 2 minutes of each other. - const tempGrouped: Comment[][] = [] + const tempGrouped: GroupComment[][] = [] for (let i = 0; i < messages.length; i++) { const message = messages[i] if (i === 0) tempGrouped.push([message]) @@ -193,7 +194,7 @@ export function GroupChat(props: { } export function GroupChatInBubble(props: { - messages: Comment[] + messages: GroupComment[] user: User | null | undefined privateUser: PrivateUser | null | undefined group: Group @@ -309,7 +310,7 @@ function GroupChatNotificationsIcon(props: { const GroupMessage = memo(function GroupMessage_(props: { user: User | null | undefined - comments: Comment[] + comments: GroupComment[] group: Group onReplyClick?: (comment: Comment) => void setRef?: (ref: HTMLDivElement) => void diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index dbacbee9..b9ebdefc 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -42,6 +42,10 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { return } + const contractId = + comment.commentType === 'contract' ? comment.contractId : undefined + const groupId = + comment.commentType === 'group' ? comment.groupId : undefined await transact({ amount: change, fromId: user.id, @@ -50,18 +54,14 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { toType: 'USER', token: 'M$', category: 'TIP', - data: { - contractId: comment.contractId, - commentId: comment.id, - groupId: comment.groupId, - }, + data: { commentId: comment.id, contractId, groupId }, description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`, }) track('send comment tip', { - contractId: comment.contractId, commentId: comment.id, - groupId: comment.groupId, + contractId, + groupId, amount: change, fromId: user.id, toId: comment.userId, diff --git a/web/hooks/use-comments.ts b/web/hooks/use-comments.ts index 7d644444..172d2cee 100644 --- a/web/hooks/use-comments.ts +++ b/web/hooks/use-comments.ts @@ -1,13 +1,13 @@ import { useEffect, useState } from 'react' +import { Comment, ContractComment, GroupComment } from 'common/comment' import { - Comment, listenForCommentsOnContract, listenForCommentsOnGroup, listenForRecentComments, } from 'web/lib/firebase/comments' export const useComments = (contractId: string) => { - const [comments, setComments] = useState<Comment[] | undefined>() + const [comments, setComments] = useState<ContractComment[] | undefined>() useEffect(() => { if (contractId) return listenForCommentsOnContract(contractId, setComments) @@ -16,7 +16,7 @@ export const useComments = (contractId: string) => { return comments } export const useCommentsOnGroup = (groupId: string | undefined) => { - const [comments, setComments] = useState<Comment[] | undefined>() + const [comments, setComments] = useState<GroupComment[] | undefined>() useEffect(() => { if (groupId) return listenForCommentsOnGroup(groupId, setComments) diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index f0678367..f7c947fe 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -11,7 +11,7 @@ import { import { getValues, listenForValues } from './utils' import { db } from './init' import { User } from 'common/user' -import { Comment } from 'common/comment' +import { Comment, ContractComment, GroupComment } from 'common/comment' import { removeUndefinedProps } from 'common/util/object' import { track } from '@amplitude/analytics-browser' import { JSONContent } from '@tiptap/react' @@ -31,7 +31,8 @@ export async function createCommentOnContract( const ref = betId ? doc(getCommentsCollection(contractId), betId) : doc(getCommentsCollection(contractId)) - const comment: Comment = removeUndefinedProps({ + // contract slug and question are set via trigger + const comment = removeUndefinedProps({ id: ref.id, commentType: 'contract', contractId, @@ -60,7 +61,7 @@ export async function createCommentOnGroup( replyToCommentId?: string ) { const ref = doc(getCommentsOnGroupCollection(groupId)) - const comment: Comment = removeUndefinedProps({ + const comment = removeUndefinedProps({ id: ref.id, commentType: 'group', groupId, @@ -96,7 +97,7 @@ export async function listAllComments(contractId: string) { } export async function listAllCommentsOnGroup(groupId: string) { - const comments = await getValues<Comment>( + const comments = await getValues<GroupComment>( getCommentsOnGroupCollection(groupId) ) comments.sort((c1, c2) => c1.createdTime - c2.createdTime) @@ -105,9 +106,9 @@ export async function listAllCommentsOnGroup(groupId: string) { export function listenForCommentsOnContract( contractId: string, - setComments: (comments: Comment[]) => void + setComments: (comments: ContractComment[]) => void ) { - return listenForValues<Comment>( + return listenForValues<ContractComment>( getCommentsCollection(contractId), (comments) => { comments.sort((c1, c2) => c1.createdTime - c2.createdTime) @@ -117,9 +118,9 @@ export function listenForCommentsOnContract( } export function listenForCommentsOnGroup( groupId: string, - setComments: (comments: Comment[]) => void + setComments: (comments: GroupComment[]) => void ) { - return listenForValues<Comment>( + return listenForValues<GroupComment>( getCommentsOnGroupCollection(groupId), (comments) => { comments.sort((c1, c2) => c1.createdTime - c2.createdTime) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 94773b6d..41ad5957 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -17,7 +17,7 @@ import { import { SEO } from 'web/components/SEO' import { Page } from 'web/components/page' import { Bet, listAllBets } from 'web/lib/firebase/bets' -import { Comment, listAllComments } from 'web/lib/firebase/comments' +import { listAllComments } from 'web/lib/firebase/comments' import Custom404 from '../404' import { AnswersPanel } from 'web/components/answers/answers-panel' import { fromPropz, usePropz } from 'web/hooks/use-propz' @@ -38,6 +38,7 @@ import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { useSaveReferral } from 'web/hooks/use-save-referral' import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' import { User } from 'common/user' +import { ContractComment } from 'common/comment' import { listUsers } from 'web/lib/firebase/users' import { FeedComment } from 'web/components/feed/feed-comments' import { Title } from 'web/components/title' @@ -78,7 +79,7 @@ export default function ContractPage(props: { contract: Contract | null username: string bets: Bet[] - comments: Comment[] + comments: ContractComment[] slug: string backToHome?: () => void }) { @@ -314,7 +315,7 @@ function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) { function ContractTopTrades(props: { contract: Contract bets: Bet[] - comments: Comment[] + comments: ContractComment[] tips: CommentTipMap }) { const { contract, bets, comments, tips } = props diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index c5255974..2ee9fa49 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -46,7 +46,7 @@ import { ENV_CONFIG } from 'common/envs/constants' import { useSaveReferral } from 'web/hooks/use-save-referral' import { Button } from 'web/components/button' import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' -import { Comment } from 'common/comment' +import { GroupComment } from 'common/comment' import { GroupChat } from 'web/components/groups/group-chat' export const getStaticProps = fromPropz(getStaticPropz) @@ -123,7 +123,7 @@ export default function GroupPage(props: { topTraders: User[] creatorScores: { [userId: string]: number } topCreators: User[] - messages: Comment[] + messages: GroupComment[] }) { props = usePropz(props, getStaticPropz) ?? { group: null, From f51ad2224bcfbd345e8cdac42883f3c3381570b2 Mon Sep 17 00:00:00 2001 From: Barak Gila <bgila@users.noreply.github.com> Date: Fri, 19 Aug 2022 08:52:01 -0700 Subject: [PATCH 18/48] add YIMBY Law and CaRLA as charities (#781) --- common/charity.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/common/charity.ts b/common/charity.ts index c18c6ba1..fd5abc36 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -565,6 +565,30 @@ Improve the lives of the world's most vulnerable people. Reduce the number of easily preventable deaths worldwide. Work towards sustainable, systemic change.`, }, + { + name: 'YIMBY Law', + website: 'https://www.yimbylaw.org/', + photo: 'https://i.imgur.com/zlzp21Z.png', + preview: + 'YIMBY Law works to make housing in California more accessible and affordable, by enforcing state housing laws.', + description: ` + YIMBY Law works to make housing in California more accessible and affordable. Our method is to enforce state housing laws, and some examples are outlined below. We send letters to cities considering zoning or general plan compliant housing developments informing them of their duties under state law, and sue them when they do not comply. + +If you would like to support our work, you can do so by getting involved or by donating.`, + }, + { + name: 'CaRLA', + website: 'https://carlaef.org/', + photo: 'https://i.imgur.com/IsNVTOY.png', + preview: + 'The California Renters Legal Advocacy and Education Fund’s core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color.', + description: ` + The California Renters Legal Advocacy and Education Fund’s core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color. + +CaRLA uses legal advocacy and education to ensure all cities comply with their own zoning and state housing laws and do their part to help solve the state’s housing shortage. + +In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`, + }, ].map((charity) => { const slug = charity.name.toLowerCase().replace(/\s/g, '-') return { From 4d7df00a68c52cc91cf9784b984ded6c06b57a8e Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 19 Aug 2022 09:47:00 -0700 Subject: [PATCH 19/48] Make Avatar component update when avatarUrl updates (#780) --- web/components/avatar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index 6ca06cbb..55cf3169 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -1,6 +1,6 @@ import Router from 'next/router' import clsx from 'clsx' -import { MouseEvent, useState } from 'react' +import { MouseEvent, useEffect, useState } from 'react' import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid' export function Avatar(props: { @@ -12,6 +12,7 @@ export function Avatar(props: { }) { const { username, noLink, size, className } = props const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) + useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl]) const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const onClick = From 98a0ed99c97fe7503b4480d14aa6ef33577f22cf Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 19 Aug 2022 09:53:16 -0700 Subject: [PATCH 20/48] Fix (i) alignment --- web/components/contract/contract-info-dialog.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 168ada50..be24d0b5 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -66,17 +66,17 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { <tr> <td>Payout</td> - <td> + <td className="flex gap-1"> {mechanism === 'cpmm-1' ? ( <> Fixed{' '} <InfoTooltip text="Each YES share is worth M$1 if YES wins." /> </> ) : ( - <div> + <> Parimutuel{' '} <InfoTooltip text="Each share is a fraction of the pool. " /> - </div> + </> )} </td> </tr> From 4f3202f90bbc5aaa1a3e5eae7a06c9d5cdf0406e Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 19 Aug 2022 10:07:48 -0700 Subject: [PATCH 21/48] Simple bet interface in embeds (#775) * rename BetRow -> BetButton * Replace bet modal in embed with inline betting - Also simplifies graph height calculation * Move bet row above graph, in "mini modal" * Show signup button if not signed up * Show probability change * Show error after modal - Show balance if insufficient funds - Clear error from amount input if amount deleted entirely * Fix error state conditions - Reset amount input on success - Reset success state on user input * Make input smaller (80px) --- web/components/amount-input.tsx | 7 +- .../{bet-row.tsx => bet-button.tsx} | 4 +- web/components/bet-inline.tsx | 127 ++++++++++++++++++ web/components/button.tsx | 23 ++-- web/components/contract/contract-card.tsx | 16 ++- web/components/contract/contract-overview.tsx | 8 +- web/components/feed/feed-items.tsx | 4 +- web/components/sign-up-prompt.tsx | 12 +- web/components/yes-no-selector.tsx | 4 +- web/pages/embed/[username]/[contractSlug].tsx | 62 +++++---- 10 files changed, 209 insertions(+), 58 deletions(-) rename web/components/{bet-row.tsx => bet-button.tsx} (94%) create mode 100644 web/components/bet-inline.tsx diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 426a9371..cb071850 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -3,7 +3,6 @@ import React from 'react' import { useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { Col } from './layout/col' -import { Spacer } from './layout/spacer' import { SiteLink } from './site-link' import { ENV_CONFIG } from 'common/envs/constants' @@ -37,7 +36,7 @@ export function AmountInput(props: { return ( <Col className={className}> - <label className="input-group"> + <label className="input-group mb-4"> <span className="bg-gray-200 text-sm">{label}</span> <input className={clsx( @@ -57,8 +56,6 @@ export function AmountInput(props: { /> </label> - <Spacer h={4} /> - {error && ( <div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> {error === 'Insufficient balance' ? ( @@ -115,6 +112,8 @@ export function BuyAmountInput(props: { } else { setError(undefined) } + } else { + setError(undefined) } } diff --git a/web/components/bet-row.tsx b/web/components/bet-button.tsx similarity index 94% rename from web/components/bet-row.tsx rename to web/components/bet-button.tsx index 56fff9bd..d7d62e7d 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-button.tsx @@ -9,8 +9,8 @@ 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: { +/** Button that opens BetPanel in a new modal */ +export default function BetButton(props: { contract: CPMMBinaryContract | PseudoNumericContract className?: string btnClassName?: string diff --git a/web/components/bet-inline.tsx b/web/components/bet-inline.tsx new file mode 100644 index 00000000..7eda7198 --- /dev/null +++ b/web/components/bet-inline.tsx @@ -0,0 +1,127 @@ +import { track } from '@amplitude/analytics-browser' +import clsx from 'clsx' +import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' +import { getBinaryCpmmBetInfo } from 'common/new-bet' +import { APIError } from 'web/lib/firebase/api' +import { useEffect, useState } from 'react' +import { useMutation } from 'react-query' +import { placeBet } from 'web/lib/firebase/api' +import { BuyAmountInput } from './amount-input' +import { Button } from './button' +import { Row } from './layout/row' +import { YesNoSelector } from './yes-no-selector' +import { useUnfilledBets } from 'web/hooks/use-bets' +import { useUser } from 'web/hooks/use-user' +import { SignUpPrompt } from './sign-up-prompt' +import { getCpmmProbability } from 'common/calculate-cpmm' +import { Col } from './layout/col' +import { XIcon } from '@heroicons/react/solid' +import { formatMoney } from 'common/util/format' + +// adapted from bet-panel.ts +export function BetInline(props: { + contract: CPMMBinaryContract | PseudoNumericContract + className?: string + setProbAfter: (probAfter: number) => void + onClose: () => void +}) { + const { contract, className, setProbAfter, onClose } = props + + const user = useUser() + + const [outcome, setOutcome] = useState<'YES' | 'NO'>('YES') + const [amount, setAmount] = useState<number>() + const [error, setError] = useState<string>() + + const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' + const unfilledBets = useUnfilledBets(contract.id) ?? [] + + const { newPool, newP } = getBinaryCpmmBetInfo( + outcome ?? 'YES', + amount ?? 0, + contract, + undefined, + unfilledBets + ) + const resultProb = getCpmmProbability(newPool, newP) + useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb]) + + const submitBet = useMutation( + () => placeBet({ outcome, amount, contractId: contract.id }), + { + onError: (e) => + setError(e instanceof APIError ? e.toString() : 'Error placing bet'), + onSuccess: () => { + track('bet', { + location: 'embed', + outcomeType: contract.outcomeType, + slug: contract.slug, + contractId: contract.id, + amount, + outcome, + isLimitOrder: false, + }) + setAmount(undefined) + }, + } + ) + + // reset error / success state on user change + useEffect(() => { + amount && submitBet.reset() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [outcome, amount]) + + const tooFewFunds = error === 'Insufficient balance' + + const betDisabled = submitBet.isLoading || tooFewFunds || !amount + + return ( + <Col className={clsx('items-center', className)}> + <Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3"> + <div className="text-xl">Bet</div> + <YesNoSelector + className="space-x-0" + btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl" + selected={outcome} + onSelect={setOutcome} + isPseudoNumeric={isPseudoNumeric} + /> + <BuyAmountInput + className="-mb-4" + inputClassName={clsx( + 'input-sm w-20 !text-base', + error && 'input-error' + )} + amount={amount} + onChange={setAmount} + error="" // handle error ourselves + setError={setError} + /> + {user && ( + <Button + color={({ YES: 'green', NO: 'red' } as const)[outcome]} + size="xs" + disabled={betDisabled} + onClick={() => submitBet.mutate()} + > + {submitBet.isLoading + ? 'Submitting' + : submitBet.isSuccess + ? 'Success!' + : 'Submit'} + </Button> + )} + <SignUpPrompt size="xs" /> + <button onClick={onClose}> + <XIcon className="ml-1 h-6 w-6" /> + </button> + </Row> + {error && ( + <div className="text-error my-1 text-sm"> + {error} {tooFewFunds && `(${formatMoney(user?.balance ?? 0)})`} + </div> + )} + </Col> + ) +} diff --git a/web/components/button.tsx b/web/components/button.tsx index 57b2add9..843f74ca 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -1,20 +1,23 @@ import { ReactNode } from 'react' import clsx from 'clsx' +export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' +export type ColorType = + | 'green' + | 'red' + | 'blue' + | 'indigo' + | 'yellow' + | 'gray' + | 'gradient' + | 'gray-white' + export function Button(props: { className?: string onClick?: () => void children?: ReactNode - size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' - color?: - | 'green' - | 'red' - | 'blue' - | 'indigo' - | 'yellow' - | 'gray' - | 'gradient' - | 'gray-white' + size?: SizeType + color?: ColorType type?: 'button' | 'reset' | 'submit' disabled?: boolean }) { diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 3d4fa8d1..c3bf1a31 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -185,11 +185,16 @@ export function BinaryResolutionOrChance(props: { contract: BinaryContract large?: boolean className?: string + probAfter?: number // 0 to 1 }) { - const { contract, large, className } = props + const { contract, large, className, probAfter } = props const { resolution } = contract const textColor = `text-${getColor(contract)}` + const before = getBinaryProbPercent(contract) + const after = probAfter && formatPercent(probAfter) + const probChanged = before !== after + return ( <Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}> {resolution ? ( @@ -206,7 +211,14 @@ export function BinaryResolutionOrChance(props: { </> ) : ( <> - <div className={textColor}>{getBinaryProbPercent(contract)}</div> + {probAfter && probChanged ? ( + <div> + <span className="text-gray-500 line-through">{before}</span> + <span className={textColor}>{after}</span> + </div> + ) : ( + <div className={textColor}>{before}</div> + )} <div className={clsx(textColor, large ? 'text-xl' : 'text-base')}> chance </div> diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index b95bb02b..bba30776 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -15,7 +15,7 @@ import { PseudoNumericResolutionOrExpectation, } from './contract-card' import { Bet } from 'common/bet' -import BetRow from '../bet-row' +import BetButton from '../bet-button' import { AnswersGraph } from '../answers/answers-graph' import { Contract, CPMMBinaryContract } from 'common/contract' import { ContractDescription } from './contract-description' @@ -73,18 +73,18 @@ export const ContractOverview = (props: { <BinaryResolutionOrChance contract={contract} /> {tradingAllowed(contract) && ( - <BetRow contract={contract as CPMMBinaryContract} /> + <BetButton contract={contract as CPMMBinaryContract} /> )} </Row> ) : isPseudoNumeric ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - {tradingAllowed(contract) && <BetRow contract={contract} />} + {tradingAllowed(contract) && <BetButton contract={contract} />} </Row> ) : isPseudoNumeric ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - {tradingAllowed(contract) && <BetRow contract={contract} />} + {tradingAllowed(contract) && <BetButton contract={contract} />} </Row> ) : ( (outcomeType === 'FREE_RESPONSE' || diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index d60fb8da..dcd5743b 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -19,7 +19,7 @@ import { BinaryResolutionOrChance } from '../contract/contract-card' import { SiteLink } from '../site-link' import { Col } from '../layout/col' import { UserLink } from '../user-page' -import BetRow from '../bet-row' +import BetButton from '../bet-button' import { Avatar } from '../avatar' import { ActivityItem } from './activity-items' import { useUser } from 'web/hooks/use-user' @@ -76,7 +76,7 @@ export function FeedItems(props: { ) : ( outcomeType === 'BINARY' && tradingAllowed(contract) && ( - <BetRow + <BetButton contract={contract as CPMMBinaryContract} className={clsx('mb-2', betRowClassName)} /> diff --git a/web/components/sign-up-prompt.tsx b/web/components/sign-up-prompt.tsx index 8882ccfd..6a55fc28 100644 --- a/web/components/sign-up-prompt.tsx +++ b/web/components/sign-up-prompt.tsx @@ -2,17 +2,21 @@ import React from 'react' import { useUser } from 'web/hooks/use-user' import { firebaseLogin } from 'web/lib/firebase/users' import { withTracking } from 'web/lib/service/analytics' -import { Button } from './button' +import { Button, SizeType } from './button' -export function SignUpPrompt(props: { label?: string; className?: string }) { - const { label, className } = props +export function SignUpPrompt(props: { + label?: string + className?: string + size?: SizeType +}) { + const { label, className, size = 'lg' } = props const user = useUser() return user === null ? ( <Button onClick={withTracking(firebaseLogin, 'sign up to bet')} className={className} - size="lg" + size={size} color="gradient" > {label ?? 'Sign up to bet!'} diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index 3b3cc21d..aaf1764e 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -38,7 +38,7 @@ export function YesNoSelector(props: { 'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white', selected == 'YES' ? 'bg-primary text-white' - : 'text-primary bg-transparent', + : 'text-primary bg-white', btnClassName )} onClick={() => onSelect('YES')} @@ -55,7 +55,7 @@ export function YesNoSelector(props: { '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', + : 'bg-white text-red-400', btnClassName )} onClick={() => onSelect('NO')} diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index d38c6e5b..83d83871 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -1,8 +1,10 @@ import { Bet } from 'common/bet' -import { Contract, CPMMBinaryContract } from 'common/contract' +import { Contract } from 'common/contract' import { DOMAIN } from 'common/envs/constants' +import { useState } from 'react' import { AnswersGraph } from 'web/components/answers/answers-graph' -import BetRow from 'web/components/bet-row' +import { BetInline } from 'web/components/bet-inline' +import { Button } from 'web/components/button' import { BinaryResolutionOrChance, FreeResponseResolutionOrChance, @@ -19,7 +21,6 @@ import { SiteLink } from 'web/components/site-link' import { useContractWithPreload } from 'web/hooks/use-contract' import { useMeasureSize } from 'web/hooks/use-measure-size' 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, @@ -88,18 +89,15 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const href = `https://${DOMAIN}${contractPath(contract)}` - const { height: windowHeight } = useWindowSize() - const { setElem, height: topSectionHeight } = useMeasureSize() - const paddingBottom = 8 + const { setElem, height: graphHeight } = useMeasureSize() - const graphHeight = - windowHeight && topSectionHeight - ? windowHeight - topSectionHeight - paddingBottom - : 0 + const [betPanelOpen, setBetPanelOpen] = useState(false) + + const [probAfter, setProbAfter] = useState<number>() return ( - <Col className="w-full flex-1 bg-white"> - <div className="relative flex flex-col pt-2" ref={setElem}> + <Col className="h-[100vh] w-full bg-white"> + <div className="relative flex flex-col pt-2"> <div className="px-3 text-xl text-indigo-700 md:text-2xl"> <SiteLink href={href}>{question}</SiteLink> </div> @@ -114,25 +112,24 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { disabled /> + {(isBinary || isPseudoNumeric) && + tradingAllowed(contract) && + !betPanelOpen && ( + <Button color="gradient" onClick={() => setBetPanelOpen(true)}> + Bet + </Button> + )} + {isBinary && ( - <Row className="items-center gap-4"> - {tradingAllowed(contract) && ( - <BetRow - contract={contract as CPMMBinaryContract} - betPanelClassName="scale-75" - /> - )} - <BinaryResolutionOrChance contract={contract} /> - </Row> + <BinaryResolutionOrChance + contract={contract} + probAfter={probAfter} + className="items-center" + /> )} {isPseudoNumeric && ( - <Row className="items-center gap-4"> - {tradingAllowed(contract) && ( - <BetRow contract={contract} betPanelClassName="scale-75" /> - )} - <PseudoNumericResolutionOrExpectation contract={contract} /> - </Row> + <PseudoNumericResolutionOrExpectation contract={contract} /> )} {outcomeType === 'FREE_RESPONSE' && ( @@ -150,7 +147,16 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { <Spacer h={2} /> </div> - <div className="mx-1" style={{ paddingBottom }}> + {(isBinary || isPseudoNumeric) && betPanelOpen && ( + <BetInline + contract={contract as any} + setProbAfter={setProbAfter} + onClose={() => setBetPanelOpen(false)} + className="self-center" + /> + )} + + <div className="mx-1 mb-2 min-h-0 flex-1" ref={setElem}> {(isBinary || isPseudoNumeric) && ( <ContractProbGraph contract={contract} From 00c9fa61c3ea854ec68fb0934874cf1bc146de1e Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 19 Aug 2022 11:10:32 -0600 Subject: [PATCH 22/48] betting streaks (#777) * Parse notif, show betting streaks modal, schedule function * Ignore streaks of 0 * Pass notifyFills the contract * Turn 9am into a constant * Lint * Up streak reward, fix timing logic * Change wording --- common/notification.ts | 2 + common/numeric-constants.ts | 2 + common/txn.ts | 10 +- common/user.ts | 2 + functions/src/create-notification.ts | 35 +++++ functions/src/index.ts | 1 + functions/src/on-create-bet.ts | 127 ++++++++++++++---- functions/src/reset-betting-streaks.ts | 38 ++++++ .../profile/betting-streak-modal.tsx | 32 +++++ web/components/user-page.tsx | 57 +++++--- web/hooks/use-notifications.ts | 8 +- web/pages/notifications.tsx | 125 +++++++++++------ 12 files changed, 345 insertions(+), 94 deletions(-) create mode 100644 functions/src/reset-betting-streaks.ts create mode 100644 web/components/profile/betting-streak-modal.tsx diff --git a/common/notification.ts b/common/notification.ts index fa4cd90a..99f9d852 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -38,6 +38,7 @@ export type notification_source_types = | 'user' | 'bonus' | 'challenge' + | 'betting_streak_bonus' export type notification_source_update_types = | 'created' @@ -66,3 +67,4 @@ export type notification_reason_types = | 'bet_fill' | 'user_joined_from_your_group_invite' | 'challenge_accepted' + | 'betting_streak_incremented' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index f399aa5a..9d41d54f 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -4,3 +4,5 @@ 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 = 10 +export const BETTING_STREAK_BONUS_AMOUNT = 5 +export const BETTING_STREAK_RESET_HOUR = 9 diff --git a/common/txn.ts b/common/txn.ts index 701b67fe..00b19570 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -16,7 +16,13 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' + category: + | 'CHARITY' + | 'MANALINK' + | 'TIP' + | 'REFERRAL' + | 'UNIQUE_BETTOR_BONUS' + | 'BETTING_STREAK_BONUS' // Any extra data data?: { [key: string]: any } @@ -57,7 +63,7 @@ type Referral = { type Bonus = { fromType: 'BANK' toType: 'USER' - category: 'UNIQUE_BETTOR_BONUS' + category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' } export type DonationTxn = Txn & Donation diff --git a/common/user.ts b/common/user.ts index 2aeb7122..8ad4c91b 100644 --- a/common/user.ts +++ b/common/user.ts @@ -41,6 +41,8 @@ export type User = { referredByGroupId?: string lastPingTime?: number shouldShowWelcome?: boolean + lastBetTime?: number + currentBettingStreak?: number } export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 51b884ad..90250e73 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -504,3 +504,38 @@ export const createChallengeAcceptedNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } + +export const createBettingStreakBonusNotification = async ( + user: User, + txnId: string, + bet: Bet, + contract: Contract, + amount: number, + idempotencyKey: string +) => { + const notificationRef = firestore + .collection(`/users/${user.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: user.id, + reason: 'betting_streak_incremented', + createdTime: Date.now(), + isSeen: false, + sourceId: txnId, + sourceType: 'betting_streak_bonus', + sourceUpdateType: 'created', + sourceUserName: user.name, + sourceUserUsername: user.username, + sourceUserAvatarUrl: user.avatarUrl, + sourceText: amount.toString(), + sourceSlug: `/${contract.creatorUsername}/${contract.slug}/bets/${bet.id}`, + sourceTitle: 'Betting Streak Bonus', + // Perhaps not necessary, but just in case + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 5cfa27db..c9f62484 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -25,6 +25,7 @@ export * from './on-create-comment-on-group' export * from './on-create-txn' export * from './on-delete-group' export * from './score-contracts' +export * from './reset-betting-streaks' // v2 export * from './health' diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index d33e71dd..c5648293 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -3,15 +3,20 @@ import * as admin from 'firebase-admin' import { keyBy, uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' -import { getContract, getUser, getValues, isProd, log } from './utils' +import { getUser, getValues, isProd, log } from './utils' import { createBetFillNotification, + createBettingStreakBonusNotification, 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 { + BETTING_STREAK_BONUS_AMOUNT, + BETTING_STREAK_RESET_HOUR, + UNIQUE_BETTOR_BONUS_AMOUNT, +} from '../../common/numeric-constants' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, @@ -38,37 +43,99 @@ export const onCreateBet = functions.firestore .doc(contractId) .update({ lastBetTime, lastUpdatedTime: Date.now() }) - await notifyFills(bet, contractId, eventId) - await updateUniqueBettorsAndGiveCreatorBonus( - contractId, - eventId, - bet.userId - ) + const userContractSnap = await firestore + .collection(`contracts`) + .doc(contractId) + .get() + const contract = userContractSnap.data() as Contract + + if (!contract) { + log(`Could not find contract ${contractId}`) + return + } + await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId) + + const bettor = await getUser(bet.userId) + if (!bettor) return + + await notifyFills(bet, contract, eventId, bettor) + await updateBettingStreak(bettor, bet, contract, eventId) + + await firestore.collection('users').doc(bettor.id).update({ lastBetTime }) }) +const updateBettingStreak = async ( + user: User, + bet: Bet, + contract: Contract, + eventId: string +) => { + const betStreakResetTime = getTodaysBettingStreakResetTime() + const lastBetTime = user?.lastBetTime ?? 0 + + // If they've already bet after the reset time, or if we haven't hit the reset time yet + if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime) + return + + const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1 + // Otherwise, add 1 to their betting streak + await firestore.collection('users').doc(user.id).update({ + currentBettingStreak: newBettingStreak, + }) + + // Send them the bonus times their streak + const bonusAmount = Math.min( + BETTING_STREAK_BONUS_AMOUNT * newBettingStreak, + 100 + ) + const fromUserId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + const bonusTxnDetails = { + currentBettingStreak: newBettingStreak, + } + const result = await firestore.runTransaction(async (trans) => { + const bonusTxn: TxnData = { + fromId: fromUserId, + fromType: 'BANK', + toId: user.id, + toType: 'USER', + amount: bonusAmount, + token: 'M$', + category: 'BETTING_STREAK_BONUS', + description: JSON.stringify(bonusTxnDetails), + } + return await runTxn(trans, bonusTxn) + }) + if (!result.txn) { + log("betting streak bonus txn couldn't be made") + return + } + + await createBettingStreakBonusNotification( + user, + result.txn.id, + bet, + contract, + bonusAmount, + eventId + ) +} + const updateUniqueBettorsAndGiveCreatorBonus = async ( - contractId: string, + contract: Contract, 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`).get() + await firestore.collection(`contracts/${contract.id}/bets`).get() ).docs.map((doc) => doc.data() as Bet) if (contractBets.length === 0) { - log(`No bets for contract ${contractId}`) + log(`No bets for contract ${contract.id}`) return } @@ -86,7 +153,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( if (!contract.uniqueBettorIds || isNewUniqueBettor) { log(`Got ${previousUniqueBettorIds} unique bettors`) isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) - await firestore.collection(`contracts`).doc(contractId).update({ + await firestore.collection(`contracts`).doc(contract.id).update({ uniqueBettorIds: newUniqueBettorIds, uniqueBettorCount: newUniqueBettorIds.length, }) @@ -97,7 +164,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( // Create combined txn for all new unique bettors const bonusTxnDetails = { - contractId: contractId, + contractId: contract.id, uniqueBettorIds: newUniqueBettorIds, } const fromUserId = isProd() @@ -140,14 +207,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( } } -const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { +const notifyFills = async ( + bet: Bet, + contract: Contract, + eventId: string, + user: User +) => { 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( @@ -180,3 +247,7 @@ const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { }) ) } + +const getTodaysBettingStreakResetTime = () => { + return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) +} diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts new file mode 100644 index 00000000..0600fa56 --- /dev/null +++ b/functions/src/reset-betting-streaks.ts @@ -0,0 +1,38 @@ +// check every day if the user has created a bet since 4pm UTC, and if not, reset their streak + +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { User } from '../../common/user' +import { DAY_MS } from '../../common/util/time' +import { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants' +const firestore = admin.firestore() + +export const resetBettingStreaksForUsers = functions.pubsub + .schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`) + .onRun(async () => { + await resetBettingStreaksInternal() + }) + +const resetBettingStreaksInternal = async () => { + const usersSnap = await firestore.collection('users').get() + + const users = usersSnap.docs.map((doc) => doc.data() as User) + + for (const user of users) { + await resetBettingStreakForUser(user) + } +} + +const resetBettingStreakForUser = async (user: User) => { + const betStreakResetTime = Date.now() - DAY_MS + // if they made a bet within the last day, don't reset their streak + if ( + (user.lastBetTime ?? 0 > betStreakResetTime) || + !user.currentBettingStreak || + user.currentBettingStreak === 0 + ) + return + await firestore.collection('users').doc(user.id).update({ + currentBettingStreak: 0, + }) +} diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx new file mode 100644 index 00000000..345d38b1 --- /dev/null +++ b/web/components/profile/betting-streak-modal.tsx @@ -0,0 +1,32 @@ +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' + +export function BettingStreakModal(props: { + isOpen: boolean + setOpen: (open: boolean) => void +}) { + const { isOpen, setOpen } = props + + return ( + <Modal open={isOpen} setOpen={setOpen}> + <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> + <span className={'text-8xl'}>🔥</span> + <span>Betting streaks are here!</span> + <Col className={'gap-2'}> + <span className={'text-indigo-700'}>• What are they?</span> + <span className={'ml-2'}> + You get a reward for every consecutive day that you place a bet. The + more days you bet in a row, the more you earn! + </span> + <span className={'text-indigo-700'}> + • Where can I check my streak? + </span> + <span className={'ml-2'}> + You can see your current streak on the top right of your profile + page. + </span> + </Col> + </Col> + </Modal> + ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index c8a697c3..d3737eea 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -28,6 +28,7 @@ import { ReferralsButton } from 'web/components/referrals-button' import { formatMoney } from 'common/util/format' import { ShareIconButton } from 'web/components/share-icon-button' import { ENV_CONFIG } from 'common/envs/constants' +import { BettingStreakModal } from 'web/components/profile/betting-streak-modal' export function UserLink(props: { name: string @@ -65,10 +66,13 @@ export function UserPage(props: { user: User }) { const isCurrentUser = user.id === currentUser?.id const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) const [showConfetti, setShowConfetti] = useState(false) + const [showBettingStreakModal, setShowBettingStreakModal] = useState(false) useEffect(() => { const claimedMana = router.query['claimed-mana'] === 'yes' setShowConfetti(claimedMana) + const showBettingStreak = router.query['show'] === 'betting-streak' + setShowBettingStreakModal(showBettingStreak) }, [router]) const profit = user.profitCached.allTime @@ -80,9 +84,14 @@ export function UserPage(props: { user: User }) { description={user.bio ?? ''} url={`/${user.username}`} /> - {showConfetti && ( - <FullscreenConfetti recycle={false} numberOfPieces={300} /> - )} + {showConfetti || + (showBettingStreakModal && ( + <FullscreenConfetti recycle={false} numberOfPieces={300} /> + ))} + <BettingStreakModal + isOpen={showBettingStreakModal} + setOpen={setShowBettingStreakModal} + /> {/* Banner image up top, with an circle avatar overlaid */} <div className="h-32 w-full bg-cover bg-center sm:h-40" @@ -114,22 +123,34 @@ export function UserPage(props: { user: User }) { {/* Profile details: name, username, bio, and link to twitter/discord */} <Col className="mx-4 -mt-6"> - <Row className={'items-center gap-2'}> - <span className="text-2xl font-bold">{user.name}</span> - <span className="mt-1 text-gray-500"> - <span - className={clsx( - 'text-md', - profit >= 0 ? 'text-green-600' : 'text-red-400' - )} - > - {formatMoney(profit)} - </span>{' '} - profit - </span> + <Row className={'justify-between'}> + <Col> + <span className="text-2xl font-bold">{user.name}</span> + <span className="text-gray-500">@{user.username}</span> + </Col> + <Col className={'justify-center gap-4'}> + <Row> + <Col className={'items-center text-gray-500'}> + <span + className={clsx( + 'text-md', + profit >= 0 ? 'text-green-600' : 'text-red-400' + )} + > + {formatMoney(profit)} + </span> + <span>profit</span> + </Col> + <Col + className={'cursor-pointer items-center text-gray-500'} + onClick={() => setShowBettingStreakModal(true)} + > + <span>🔥{user.currentBettingStreak ?? 0}</span> + <span>streak</span> + </Col> + </Row> + </Col> </Row> - <span className="text-gray-500">@{user.username}</span> - <Spacer h={4} /> {user.bio && ( <> diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index a3ddeb29..9df162bd 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -71,11 +71,15 @@ export function groupNotifications(notifications: Notification[]) { const notificationsGroupedByDay = notificationGroupsByDay[day] const incomeNotifications = notificationsGroupedByDay.filter( (notification) => - notification.sourceType === 'bonus' || notification.sourceType === 'tip' + notification.sourceType === 'bonus' || + notification.sourceType === 'tip' || + notification.sourceType === 'betting_streak_bonus' ) const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter( (notification) => - notification.sourceType !== 'bonus' && notification.sourceType !== 'tip' + notification.sourceType !== 'bonus' && + notification.sourceType !== 'tip' && + notification.sourceType !== 'betting_streak_bonus' ) if (incomeNotifications.length > 0) { notificationGroups = notificationGroups.concat({ diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 7d06c481..c99b226a 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -31,7 +31,10 @@ import { import { TrendingUpIcon } from '@heroicons/react/outline' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' -import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' +import { + BETTING_STREAK_BONUS_AMOUNT, + UNIQUE_BETTOR_BONUS_AMOUNT, +} from 'common/numeric-constants' import { groupBy, sum, uniq } from 'lodash' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' @@ -229,39 +232,39 @@ function IncomeNotificationGroupItem(props: { (n) => n.sourceType ) for (const sourceType in groupedNotificationsBySourceType) { - // Source title splits by contracts and groups + // Source title splits by contracts, groups, betting streak bonus const groupedNotificationsBySourceTitle = groupBy( groupedNotificationsBySourceType[sourceType], (notification) => { return notification.sourceTitle ?? notification.sourceContractTitle } ) - for (const contractId in groupedNotificationsBySourceTitle) { - const notificationsForContractId = - groupedNotificationsBySourceTitle[contractId] - if (notificationsForContractId.length === 1) { - newNotifications.push(notificationsForContractId[0]) + for (const sourceTitle in groupedNotificationsBySourceTitle) { + const notificationsForSourceTitle = + groupedNotificationsBySourceTitle[sourceTitle] + if (notificationsForSourceTitle.length === 1) { + newNotifications.push(notificationsForSourceTitle[0]) continue } let sum = 0 - notificationsForContractId.forEach( + notificationsForSourceTitle.forEach( (notification) => notification.sourceText && (sum = parseInt(notification.sourceText) + sum) ) const uniqueUsers = uniq( - notificationsForContractId.map((notification) => { + notificationsForSourceTitle.map((notification) => { return notification.sourceUserUsername }) ) const newNotification = { - ...notificationsForContractId[0], + ...notificationsForSourceTitle[0], sourceText: sum.toString(), sourceUserUsername: uniqueUsers.length > 1 ? MULTIPLE_USERS_KEY - : notificationsForContractId[0].sourceType, + : notificationsForSourceTitle[0].sourceType, } newNotifications.push(newNotification) } @@ -362,7 +365,8 @@ function IncomeNotificationItem(props: { justSummary?: boolean }) { const { notification, justSummary } = props - const { sourceType, sourceUserName, sourceUserUsername } = notification + const { sourceType, sourceUserName, sourceUserUsername, sourceText } = + notification const [highlighted] = useState(!notification.isSeen) const { width } = useWindowSize() const isMobile = (width && width < 768) || false @@ -370,19 +374,74 @@ function IncomeNotificationItem(props: { setNotificationsAsSeen([notification]) }, [notification]) - function getReasonForShowingIncomeNotification(simple: boolean) { + function reasonAndLink(simple: boolean) { const { sourceText } = notification let reasonText = '' if (sourceType === 'bonus' && sourceText) { reasonText = !simple ? `Bonus for ${ parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT - } unique traders` + } unique traders on` : 'bonus on' } else if (sourceType === 'tip') { - reasonText = !simple ? `tipped you` : `in tips on` + reasonText = !simple ? `tipped you on` : `in tips on` + } else if (sourceType === 'betting_streak_bonus' && sourceText) { + reasonText = `for your ${ + parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT + }-day` } - return reasonText + return ( + <> + {reasonText} + {sourceType === 'betting_streak_bonus' ? ( + simple ? ( + <span className={'ml-1 font-bold'}>Betting Streak</span> + ) : ( + <SiteLink + className={'ml-1 font-bold'} + href={'/betting-streak-bonus'} + > + Betting Streak + </SiteLink> + ) + ) : ( + <QuestionOrGroupLink + notification={notification} + ignoreClick={isMobile} + /> + )} + </> + ) + } + + const incomeNotificationLabel = () => { + return sourceText ? ( + <span className="text-primary"> + {'+' + formatMoney(parseInt(sourceText))} + </span> + ) : ( + <div /> + ) + } + + const getIncomeSourceUrl = () => { + const { + sourceId, + sourceContractCreatorUsername, + sourceContractSlug, + sourceSlug, + } = notification + if (sourceType === 'tip' && sourceContractSlug) + return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` + if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}` + if (sourceType === 'challenge') return `${sourceSlug}` + if (sourceType === 'betting_streak_bonus') + return `/${sourceUserUsername}/?show=betting-streak` + if (sourceContractCreatorUsername && sourceContractSlug) + return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( + sourceId ?? '', + sourceType + )}` } if (justSummary) { @@ -392,19 +451,9 @@ function IncomeNotificationItem(props: { <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 - className={'line-clamp-1'} - notification={notification} - justSummary={true} - /> + {incomeNotificationLabel()} </div> - <span className={'flex truncate'}> - {getReasonForShowingIncomeNotification(true)} - <QuestionOrGroupLink - notification={notification} - ignoreClick={isMobile} - /> - </span> + <span className={'flex truncate'}>{reasonAndLink(true)}</span> </div> </div> </div> @@ -421,18 +470,16 @@ function IncomeNotificationItem(props: { > <div className={'relative'}> <SiteLink - href={getSourceUrl(notification) ?? ''} + href={getIncomeSourceUrl() ?? ''} className={'absolute left-0 right-0 top-0 bottom-0 z-0'} /> <Row className={'items-center text-gray-500 sm:justify-start'}> <div className={'line-clamp-2 flex max-w-xl shrink '}> <div className={'inline'}> - <span className={'mr-1'}> - <NotificationTextLabel notification={notification} /> - </span> + <span className={'mr-1'}>{incomeNotificationLabel()}</span> </div> <span> - {sourceType != 'bonus' && + {sourceType === 'tip' && (sourceUserUsername === MULTIPLE_USERS_KEY ? ( <span className={'mr-1 truncate'}>Multiple users</span> ) : ( @@ -443,8 +490,7 @@ function IncomeNotificationItem(props: { short={true} /> ))} - {getReasonForShowingIncomeNotification(false)} {' on'} - <QuestionOrGroupLink notification={notification} /> + {reasonAndLink(false)} </span> </div> </Row> @@ -794,9 +840,6 @@ function getSourceUrl(notification: Notification) { // User referral: if (sourceType === 'user' && !sourceContractSlug) return `/${sourceUserUsername}` - if (sourceType === 'tip' && sourceContractSlug) - return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` - if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}` if (sourceType === 'challenge') return `${sourceSlug}` if (sourceContractCreatorUsername && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( @@ -885,12 +928,6 @@ function NotificationTextLabel(props: { 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> - ) } else if (sourceType === 'bet' && sourceText) { return ( <> From ba5dabd613272ac03a7d202deb0e4bb06906948d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 19 Aug 2022 11:24:28 -0600 Subject: [PATCH 23/48] Increase gap between profit and streak --- web/components/user-page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index d3737eea..62f73bf8 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -128,8 +128,8 @@ export function UserPage(props: { user: User }) { <span className="text-2xl font-bold">{user.name}</span> <span className="text-gray-500">@{user.username}</span> </Col> - <Col className={'justify-center gap-4'}> - <Row> + <Col className={'justify-center'}> + <Row className={'gap-3'}> <Col className={'items-center text-gray-500'}> <span className={clsx( From a0f62ba1729ebf226f4779fe96f32921b4515f50 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 19 Aug 2022 11:43:57 -0600 Subject: [PATCH 24/48] Markets emails (#764) * Send out email template for 3 trending markets * Rich text to plaintext descriptions, other ui changes * Lint * Filter for closed markets * Change sign * First order must be closeTime * Send 6 emails, check flag twice * Exclude contracts with trump and president in the name * interesting markets email * sendInterestingMarketsEmail * Change subject line back Co-authored-by: mantikoros <sgrugett@gmail.com> --- common/contract-details.ts | 151 ++++++ common/user.ts | 1 + firestore.rules | 2 +- .../email-templates/interesting-markets.html | 476 ++++++++++++++++++ functions/src/emails.ts | 59 +++ functions/src/index.ts | 2 + functions/src/unsubscribe.ts | 4 + functions/src/utils.ts | 6 + functions/src/weekly-markets-emails.ts | 82 +++ web/components/SEO.tsx | 56 +-- .../contract/contract-card-preview.tsx | 44 -- web/components/contract/contract-details.tsx | 26 +- web/components/contract/quick-bet.tsx | 3 +- web/components/feed/feed-items.tsx | 2 +- web/lib/firebase/contracts.ts | 29 +- web/pages/[username]/[contractSlug].tsx | 2 +- .../[contractSlug]/[challengeSlug].tsx | 2 +- 17 files changed, 791 insertions(+), 156 deletions(-) create mode 100644 common/contract-details.ts create mode 100644 functions/src/email-templates/interesting-markets.html create mode 100644 functions/src/weekly-markets-emails.ts delete mode 100644 web/components/contract/contract-card-preview.tsx diff --git a/common/contract-details.ts b/common/contract-details.ts new file mode 100644 index 00000000..02af6359 --- /dev/null +++ b/common/contract-details.ts @@ -0,0 +1,151 @@ +import { Challenge } from './challenge' +import { BinaryContract, Contract } from './contract' +import { getFormattedMappedValue } from './pseudo-numeric' +import { getProbability } from './calculate' +import { richTextToString } from './util/parse' +import { getCpmmProbability } from './calculate-cpmm' +import { getDpmProbability } from './calculate-dpm' +import { formatMoney, formatPercent } from './util/format' + +export function contractMetrics(contract: Contract) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dayjs = require('dayjs') + const { createdTime, resolutionTime, isResolved } = contract + + const createdDate = dayjs(createdTime).format('MMM D') + + const resolvedDate = isResolved + ? dayjs(resolutionTime).format('MMM D') + : undefined + + const volumeLabel = `${formatMoney(contract.volume)} bet` + + return { volumeLabel, createdDate, resolvedDate } +} + +// String version of the above, to send to the OpenGraph image generator +export function contractTextDetails(contract: Contract) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dayjs = require('dayjs') + const { closeTime, tags } = contract + const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) + + const hashtags = tags.map((tag) => `#${tag}`) + + return ( + `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + + (closeTime + ? ` • ${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs( + closeTime + ).format('MMM D, h:mma')}` + : '') + + ` • ${volumeLabel}` + + (hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '') + ) +} + +export function getBinaryProb(contract: BinaryContract) { + const { pool, resolutionProbability, mechanism } = contract + + return ( + resolutionProbability ?? + (mechanism === 'cpmm-1' + ? getCpmmProbability(pool, contract.p) + : getDpmProbability(contract.totalShares)) + ) +} + +export const getOpenGraphProps = (contract: Contract) => { + const { + resolution, + question, + creatorName, + creatorUsername, + outcomeType, + creatorAvatarUrl, + description: desc, + } = contract + const probPercent = + outcomeType === 'BINARY' + ? formatPercent(getBinaryProb(contract)) + : undefined + + const numericValue = + outcomeType === 'PSEUDO_NUMERIC' + ? getFormattedMappedValue(contract)(getProbability(contract)) + : undefined + + const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) + + const description = resolution + ? `Resolved ${resolution}. ${stringDesc}` + : probPercent + ? `${probPercent} chance. ${stringDesc}` + : stringDesc + + return { + question, + probability: probPercent, + metadata: contractTextDetails(contract), + creatorName, + creatorUsername, + creatorAvatarUrl, + description, + numericValue, + } +} + +export type OgCardProps = { + question: string + probability?: string + metadata: string + creatorName: string + creatorUsername: string + creatorAvatarUrl?: string + numericValue?: string +} + +export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { + const { + creatorAmount, + acceptances, + acceptorAmount, + creatorOutcome, + acceptorOutcome, + } = challenge || {} + const { userName, userAvatarUrl } = acceptances?.[0] ?? {} + + const probabilityParam = + props.probability === undefined + ? '' + : `&probability=${encodeURIComponent(props.probability ?? '')}` + + const numericValueParam = + props.numericValue === undefined + ? '' + : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` + + const creatorAvatarUrlParam = + props.creatorAvatarUrl === undefined + ? '' + : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` + + const challengeUrlParams = challenge + ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + + `&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` + + `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` + : '' + + // URL encode each of the props, then add them as query params + return ( + `https://manifold-og-image.vercel.app/m.png` + + `?question=${encodeURIComponent(props.question)}` + + probabilityParam + + numericValueParam + + `&metadata=${encodeURIComponent(props.metadata)}` + + `&creatorName=${encodeURIComponent(props.creatorName)}` + + creatorAvatarUrlParam + + `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + + challengeUrlParams + ) +} diff --git a/common/user.ts b/common/user.ts index 8ad4c91b..2910c54e 100644 --- a/common/user.ts +++ b/common/user.ts @@ -59,6 +59,7 @@ export type PrivateUser = { unsubscribedFromCommentEmails?: boolean unsubscribedFromAnswerEmails?: boolean unsubscribedFromGenericEmails?: boolean + unsubscribedFromWeeklyTrendingEmails?: boolean manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string diff --git a/firestore.rules b/firestore.rules index 81ab4eed..c0d17dac 100644 --- a/firestore.rules +++ b/firestore.rules @@ -63,7 +63,7 @@ service cloud.firestore { allow read: if userId == request.auth.uid || isAdmin(); allow update: if (userId == request.auth.uid || isAdmin()) && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences' ]); + .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]); } match /private-users/{userId}/views/{viewId} { diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html new file mode 100644 index 00000000..fc067643 --- /dev/null +++ b/functions/src/email-templates/interesting-markets.html @@ -0,0 +1,476 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" + xmlns:o="urn:schemas-microsoft-com:office:office"> + +<head> + <title>Interesting markets on Manifold + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ Here is a selection of markets on Manifold you might find + interesting!

+
+
+
+ + {{question1Title}} + +
+ + + + + +
+ + View market + +
+ +
+
+ + {{question2Title}} + +
+ + + + + +
+ + View market + +
+ +
+
+ + {{question3Title}} + +
+ + + + + +
+ + View market + +
+ +
+
+ + {{question4Title}} + +
+ + + + + +
+ + View market + +
+ +
+
+ + {{question5Title}} + +
+ + + + + +
+ + View market + +
+ +
+
+ + {{question6Title}} + +
+ + + + + +
+ + View market + +
+ +
+
+ +
+
+ + +
+ + + + • What are they? - You get a reward for every consecutive day that you place a bet. The - more days you bet in a row, the more you earn! + You get {formatMoney(BETTING_STREAK_BONUS_AMOUNT)} more for each day + of consecutive betting up to {formatMoney(BETTING_STREAK_BONUS_MAX)} + . The more days you bet in a row, the more you earn! • Where can I check my streak? From 634196d8f1e7f7ab069e5dc1469b6b9e2fc20fb3 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 19 Aug 2022 14:45:04 -0600 Subject: [PATCH 26/48] Slice the popular emails to the top 20 --- functions/src/weekly-markets-emails.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index 62a06a7f..c75d6617 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -43,13 +43,15 @@ async function sendTrendingMarketsEmailsToAllUsers() { const privateUsersToSendEmailsTo = privateUsers.filter((user) => { return !user.unsubscribedFromWeeklyTrendingEmails }) - const trendingContracts = (await getTrendingContracts()).filter( - (contract) => - !( - contract.question.toLowerCase().includes('trump') && - contract.question.toLowerCase().includes('president') - ) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS - ) + const trendingContracts = (await getTrendingContracts()) + .filter( + (contract) => + !( + contract.question.toLowerCase().includes('trump') && + contract.question.toLowerCase().includes('president') + ) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS + ) + .slice(0, 20) for (const privateUser of privateUsersToSendEmailsTo) { if (!privateUser.email) { log(`No email for ${privateUser.username}`) From 1196ec4375f76266d6f5d8a3ba76a15eb5401864 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 19 Aug 2022 15:01:53 -0600 Subject: [PATCH 27/48] Send 6 trending emails to all users monday 12pm PT --- functions/src/weekly-markets-emails.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index c75d6617..19f38be7 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -2,15 +2,15 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' -import { getPrivateUser, getUser, getValues, isProd, log } from './utils' -import { filterDefined } from '../../common/util/array' +import { getAllPrivateUsers, getUser, getValues, log } from './utils' import { sendInterestingMarketsEmail } from './emails' import { createRNG, shuffle } from '../../common/util/random' import { DAY_MS } from '../../common/util/time' export const weeklyMarketsEmails = functions .runWith({ secrets: ['MAILGUN_KEY'] }) - .pubsub.schedule('every 1 minutes') + // every Monday at 12pm PT (UTC -07:00) + .pubsub.schedule('0 19 * * 1') .onRun(async () => { await sendTrendingMarketsEmailsToAllUsers() }) @@ -32,13 +32,8 @@ export async function getTrendingContracts() { } async function sendTrendingMarketsEmailsToAllUsers() { - const numEmailsToSend = 6 - // const privateUsers = await getAllPrivateUsers() - // uses dev ian's private user for testing - const privateUser = await getPrivateUser( - isProd() ? 'AJwLWoo3xue32XIiAVrL5SyR1WB2' : '6hHpzvRG0pMq8PNJs7RZj2qlZGn2' - ) - const privateUsers = filterDefined([privateUser]) + const numContractsToSend = 6 + const privateUsers = await getAllPrivateUsers() // get all users that haven't unsubscribed from weekly emails const privateUsersToSendEmailsTo = privateUsers.filter((user) => { return !user.unsubscribedFromWeeklyTrendingEmails @@ -60,14 +55,14 @@ async function sendTrendingMarketsEmailsToAllUsers() { const contractsAvailableToSend = trendingContracts.filter((contract) => { return !contract.uniqueBettorIds?.includes(privateUser.id) }) - if (contractsAvailableToSend.length < numEmailsToSend) { + if (contractsAvailableToSend.length < numContractsToSend) { log('not enough new, unbet-on contracts to send to user', privateUser.id) continue } // choose random subset of contracts to send to user const contractsToSend = chooseRandomSubset( contractsAvailableToSend, - numEmailsToSend + numContractsToSend ) const user = await getUser(privateUser.id) From 39c312cf9f2c96b52765a78d684a1001efe0beb3 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 19 Aug 2022 15:19:52 -0600 Subject: [PATCH 28/48] Explicitly pass utc timezone --- functions/src/reset-betting-streaks.ts | 1 + functions/src/weekly-markets-emails.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts index 0600fa56..e1c3af8f 100644 --- a/functions/src/reset-betting-streaks.ts +++ b/functions/src/reset-betting-streaks.ts @@ -9,6 +9,7 @@ const firestore = admin.firestore() export const resetBettingStreaksForUsers = functions.pubsub .schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`) + .timeZone('utc') .onRun(async () => { await resetBettingStreaksInternal() }) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index 19f38be7..1e43b7dc 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -11,6 +11,7 @@ export const weeklyMarketsEmails = functions .runWith({ secrets: ['MAILGUN_KEY'] }) // every Monday at 12pm PT (UTC -07:00) .pubsub.schedule('0 19 * * 1') + .timeZone('utc') .onRun(async () => { await sendTrendingMarketsEmailsToAllUsers() }) From b67a26ad61cde4c7a104d2ba0e7fb5c468510d9e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 19 Aug 2022 16:51:52 -0500 Subject: [PATCH 29/48] Don't show bets streak modal on navigate each tab --- web/components/user-page.tsx | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 62f73bf8..407983fc 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -70,10 +70,25 @@ export function UserPage(props: { user: User }) { useEffect(() => { const claimedMana = router.query['claimed-mana'] === 'yes' - setShowConfetti(claimedMana) const showBettingStreak = router.query['show'] === 'betting-streak' setShowBettingStreakModal(showBettingStreak) - }, [router]) + setShowConfetti(claimedMana || showBettingStreak) + + const query = { ...router.query } + if (query.claimedMana || query.show) { + delete query['claimed-mana'] + delete query['show'] + router.replace( + { + pathname: router.pathname, + query, + }, + undefined, + { shallow: true } + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const profit = user.profitCached.allTime @@ -84,10 +99,9 @@ export function UserPage(props: { user: User }) { description={user.bio ?? ''} url={`/${user.username}`} /> - {showConfetti || - (showBettingStreakModal && ( - - ))} + {showConfetti && ( + + )} Date: Fri, 19 Aug 2022 16:00:40 -0600 Subject: [PATCH 30/48] =?UTF-8?q?=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5?= =?UTF-8?q?=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/components/nav/nav-bar.tsx | 22 +++++++++++++++------- web/components/nav/profile-menu.tsx | 10 +++++++++- web/components/user-page.tsx | 2 +- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 680b8946..23d2f3c0 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -19,6 +19,7 @@ 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' +import { Row } from 'web/components/layout/row' function getNavigation() { return [ @@ -69,13 +70,20 @@ export function BottomNavBar() { trackingEventName: 'profile', href: `/${user.username}?tab=bets`, icon: () => ( - + + + {user.currentBettingStreak && user.currentBettingStreak > 0 && ( +
+ 🔥{user.currentBettingStreak} +
+ )} +
), }} /> diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index 9e869c40..8eeac832 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -4,6 +4,7 @@ import { User } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' import { Avatar } from '../avatar' import { trackCallback } from 'web/lib/service/analytics' +import { Row } from 'web/components/layout/row' export function ProfileSummary(props: { user: User }) { const { user } = props @@ -17,7 +18,14 @@ export function ProfileSummary(props: { user: User }) {
{user.name}
-
{formatMoney(Math.floor(user.balance))}
+ + {formatMoney(Math.floor(user.balance))} + {user.currentBettingStreak && user.currentBettingStreak > 0 && ( +
+ 🔥{user.currentBettingStreak} +
+ )} +
diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 407983fc..b06b1066 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -124,7 +124,7 @@ export function UserPage(props: { user: User }) { {/* Top right buttons (e.g. edit, follow) */} -
+
{!isCurrentUser && } {isCurrentUser && ( From 0cbc0010c194e745abf0ffe2c803e7741ae4a3d3 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Fri, 19 Aug 2022 17:02:52 -0500 Subject: [PATCH 31/48] schedule emails from onCreateUser; send interesting markets on D1 --- functions/src/create-user.ts | 5 +--- functions/src/emails.ts | 5 ++-- functions/src/index.ts | 2 +- functions/src/on-create-user.ts | 41 +++++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 functions/src/on-create-user.ts diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index c0b03e23..7156855e 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -16,7 +16,6 @@ import { cleanDisplayName, cleanUsername, } from '../../common/util/clean-username' -import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails' import { isWhitelisted } from '../../common/envs/constants' import { CATEGORIES_GROUP_SLUG_POSTFIX, @@ -93,10 +92,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => { } await firestore.collection('private-users').doc(auth.uid).create(privateUser) - await addUserToDefaultGroups(user) - await sendWelcomeEmail(user, privateUser) - await sendPersonalFollowupEmail(user, privateUser) + await track(auth.uid, 'create user', { username }, { ip: req.ip }) return { user, privateUser } diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 97ffce10..6768e8ea 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,4 +1,3 @@ -import * as dayjs from 'dayjs' import { DOMAIN } from '../../common/envs/constants' import { Answer } from '../../common/answer' @@ -170,7 +169,8 @@ export const sendWelcomeEmail = async ( export const sendPersonalFollowupEmail = async ( user: User, - privateUser: PrivateUser + privateUser: PrivateUser, + sendTime: string ) => { if (!privateUser || !privateUser.email) return @@ -192,7 +192,6 @@ Cofounder of Manifold Markets https://manifold.markets ` - const sendTime = dayjs().add(4, 'hours').toString() await sendTextEmail( privateUser.email, diff --git a/functions/src/index.ts b/functions/src/index.ts index ec1947f1..4d7cf42b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -5,6 +5,7 @@ import { EndpointDefinition } from './api' admin.initializeApp() // v1 +export * from './on-create-user' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' @@ -28,7 +29,6 @@ export * from './score-contracts' export * from './weekly-markets-emails' export * from './reset-betting-streaks' - // v2 export * from './health' export * from './transact' diff --git a/functions/src/on-create-user.ts b/functions/src/on-create-user.ts new file mode 100644 index 00000000..fd951ab4 --- /dev/null +++ b/functions/src/on-create-user.ts @@ -0,0 +1,41 @@ +import * as functions from 'firebase-functions' +import * as dayjs from 'dayjs' +import * as utc from 'dayjs/plugin/utc' +dayjs.extend(utc) + +import { getPrivateUser } from './utils' +import { User } from 'common/user' +import { + sendInterestingMarketsEmail, + sendPersonalFollowupEmail, + sendWelcomeEmail, +} from './emails' +import { getTrendingContracts } from './weekly-markets-emails' + +export const onCreateUser = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .firestore.document('users/{userId}') + .onCreate(async (snapshot) => { + const user = snapshot.data() as User + const privateUser = await getPrivateUser(user.id) + if (!privateUser) return + + await sendWelcomeEmail(user, privateUser) + + const followupSendTime = dayjs().add(4, 'hours').toString() + await sendPersonalFollowupEmail(user, privateUser, followupSendTime) + + // skip email if weekly email is about to go out + const day = dayjs().utc().day() + if (day === 0 || (day === 1 && dayjs().utc().hour() <= 19)) return + + const contracts = await getTrendingContracts() + const marketsSendTime = dayjs().add(24, 'hours').toString() + + await sendInterestingMarketsEmail( + user, + privateUser, + contracts, + marketsSendTime + ) + }) From 03d98a7ad768b87b57a3b26501f21f4759ec0fef Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 19 Aug 2022 17:16:17 -0600 Subject: [PATCH 32/48] Reset hour to 12am utc --- common/numeric-constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index 4d04a2c7..3e5af0d3 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -6,4 +6,4 @@ export const NUMERIC_TEXT_COLOR = 'text-blue-500' export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 export const BETTING_STREAK_BONUS_AMOUNT = 5 export const BETTING_STREAK_BONUS_MAX = 100 -export const BETTING_STREAK_RESET_HOUR = 9 +export const BETTING_STREAK_RESET_HOUR = 0 From 51c843d765a0559e26bae6c620a3c4902fdc8bf2 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Fri, 19 Aug 2022 16:57:23 -0700 Subject: [PATCH 33/48] Use masonry on contract cards, sorted correctly (#773) * Revert "Revert "Tile contract cards in masonry layout (#761)"" This reverts commit 62728e52b72f7cd4e1a3442022ba1f441052e6ca. * Sort the contracts in the correct masonry order * Fix ordering on single columns * Use react-masonry-css to accomplish masonry view * Improve comment * Remove gridClassName Everything is spaced with m-4, too bad --- functions/package.json | 1 + web/components/contract-search.tsx | 3 --- web/components/contract/contracts-grid.tsx | 25 ++++++++++------------ web/components/editor/market-modal.tsx | 3 --- web/pages/group/[...slugs]/index.tsx | 3 --- yarn.lock | 5 +++++ 6 files changed, 17 insertions(+), 23 deletions(-) diff --git a/functions/package.json b/functions/package.json index 5839b5eb..d6278c25 100644 --- a/functions/package.json +++ b/functions/package.json @@ -39,6 +39,7 @@ "lodash": "4.17.21", "mailgun-js": "0.22.0", "module-alias": "2.2.2", + "react-masonry-css": "1.0.16", "stripe": "8.194.0", "zod": "3.17.2" }, diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index ebcba985..feb0de3b 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -83,7 +83,6 @@ export function ContractSearch(props: { highlightOptions?: ContractHighlightOptions onContractClick?: (contract: Contract) => void hideOrderSelector?: boolean - overrideGridClassName?: string cardHideOptions?: { hideGroupLink?: boolean hideQuickBet?: boolean @@ -99,7 +98,6 @@ export function ContractSearch(props: { defaultFilter, additionalFilter, onContractClick, - overrideGridClassName, hideOrderSelector, cardHideOptions, highlightOptions, @@ -183,7 +181,6 @@ export function ContractSearch(props: { loadMore={performQuery} showTime={showTime} onContractClick={onContractClick} - overrideGridClassName={overrideGridClassName} highlightOptions={highlightOptions} cardHideOptions={cardHideOptions} /> diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 05c66d56..0839777c 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -9,6 +9,7 @@ import { useCallback } from 'react' import clsx from 'clsx' import { LoadingIndicator } from '../loading-indicator' import { VisibilityObserver } from '../visibility-observer' +import Masonry from 'react-masonry-css' export type ContractHighlightOptions = { contractIds?: string[] @@ -20,7 +21,6 @@ export function ContractsGrid(props: { loadMore?: () => void showTime?: ShowTime onContractClick?: (contract: Contract) => void - overrideGridClassName?: string cardHideOptions?: { hideQuickBet?: boolean hideGroupLink?: boolean @@ -32,7 +32,6 @@ export function ContractsGrid(props: { showTime, loadMore, onContractClick, - overrideGridClassName, cardHideOptions, highlightOptions, } = props @@ -64,12 +63,11 @@ export function ContractsGrid(props: { return (
-
    {contracts.map((contract) => ( ))} -
+ c.id), diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 2ee9fa49..4e42a0bd 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -607,9 +607,6 @@ function AddContractButton(props: { group: Group; user: User }) { user={user} hideOrderSelector={true} onContractClick={addContractToCurrentGroup} - overrideGridClassName={ - 'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1' - } cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} additionalFilter={{ excludeContractIds: group.contractIds }} highlightOptions={{ diff --git a/yarn.lock b/yarn.lock index b28b373b..bbc13091 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9947,6 +9947,11 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1: dependencies: "@babel/runtime" "^7.10.3" +react-masonry-css@1.0.16: + version "1.0.16" + resolved "https://registry.yarnpkg.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz#72b28b4ae3484e250534700860597553a10f1a2c" + integrity sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ== + react-motion@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316" From c850cfe97f2891390f6f9c79840eb74cfdfa9dbc Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Fri, 19 Aug 2022 16:59:42 -0700 Subject: [PATCH 34/48] Revert "Revert "fix firefox visual glitch - single card wrapping"" This reverts commit 63a5241b2ecd153fd80bcca9ac964fc2c05492a1. --- web/components/contract/contracts-grid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 0839777c..f7b7eeac 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -80,7 +80,7 @@ export function ContractsGrid(props: { hideQuickBet={hideQuickBet} hideGroupLink={hideGroupLink} className={clsx( - 'mb-4 break-inside-avoid-column', + 'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox) contractIds?.includes(contract.id) && highlightClassName )} /> From 6791da0fc860d5a8f110d3b723391809f5d4a961 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Fri, 19 Aug 2022 17:28:06 -0700 Subject: [PATCH 35/48] Fix "Most Recent Donor" on /charity --- web/pages/charity/index.tsx | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 80003c81..e9014bfb 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -26,7 +26,9 @@ import { User } from 'common/user' import { SEO } from 'web/components/SEO' export async function getStaticProps() { - const txns = await getAllCharityTxns() + let txns = await getAllCharityTxns() + // Sort by newest txns first + txns = sortBy(txns, 'createdTime').reverse() const totals = mapValues(groupBy(txns, 'toId'), (txns) => sumBy(txns, (txn) => txn.amount) ) @@ -37,7 +39,8 @@ export async function getStaticProps() { ]) const matches = quadraticMatches(txns, totalRaised) const numDonors = uniqBy(txns, (txn) => txn.fromId).length - const mostRecentDonor = await getUser(txns[txns.length - 1].fromId) + const mostRecentDonor = await getUser(txns[0].fromId) + const mostRecentCharity = txns[0].toId return { props: { @@ -47,6 +50,7 @@ export async function getStaticProps() { txns, numDonors, mostRecentDonor, + mostRecentCharity, }, revalidate: 60, } @@ -71,7 +75,7 @@ function DonatedStats(props: { stats: Stat[] }) { {stat.name} -
+
{stat.url ? ( {stat.stat} ) : ( @@ -91,11 +95,21 @@ export default function Charity(props: { txns: Txn[] numDonors: number mostRecentDonor: User + mostRecentCharity: string }) { - const { totalRaised, charities, matches, numDonors, mostRecentDonor } = props + const { + totalRaised, + charities, + matches, + mostRecentCharity, + mostRecentDonor, + } = props const [query, setQuery] = useState('') const debouncedQuery = debounce(setQuery, 50) + const recentCharityName = + charities.find((charity) => charity.id === mostRecentCharity)?.name ?? + 'Nobody' const filterCharities = useMemo( () => @@ -143,15 +157,16 @@ export default function Charity(props: { name: 'Raised by Manifold users', stat: manaToUSD(totalRaised), }, - { - name: 'Number of donors', - stat: `${numDonors}`, - }, { name: 'Most recent donor', stat: mostRecentDonor.name ?? 'Nobody', url: `/${mostRecentDonor.username}`, }, + { + name: 'Most recent donation', + stat: recentCharityName, + url: `/charity/${mostRecentCharity}`, + }, ]} /> From 474304d2842066cf390d755f994f35252ce56557 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 20 Aug 2022 11:45:13 -0500 Subject: [PATCH 36/48] =?UTF-8?q?Revert=20"=F0=9F=94=A5=F0=9F=94=A5?= =?UTF-8?q?=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5?= =?UTF-8?q?=F0=9F=94=A5"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit fc8487dca07e2c0e2f209f6b7c0aa661fc9271a6. --- web/components/nav/nav-bar.tsx | 22 +++++++--------------- web/components/nav/profile-menu.tsx | 10 +--------- web/components/user-page.tsx | 2 +- 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 23d2f3c0..680b8946 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -19,7 +19,6 @@ 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' -import { Row } from 'web/components/layout/row' function getNavigation() { return [ @@ -70,20 +69,13 @@ export function BottomNavBar() { trackingEventName: 'profile', href: `/${user.username}?tab=bets`, icon: () => ( - - - {user.currentBettingStreak && user.currentBettingStreak > 0 && ( -
- 🔥{user.currentBettingStreak} -
- )} -
+ ), }} /> diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index 8eeac832..9e869c40 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -4,7 +4,6 @@ import { User } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' import { Avatar } from '../avatar' import { trackCallback } from 'web/lib/service/analytics' -import { Row } from 'web/components/layout/row' export function ProfileSummary(props: { user: User }) { const { user } = props @@ -18,14 +17,7 @@ export function ProfileSummary(props: { user: User }) {
{user.name}
- - {formatMoney(Math.floor(user.balance))} - {user.currentBettingStreak && user.currentBettingStreak > 0 && ( -
- 🔥{user.currentBettingStreak} -
- )} -
+
{formatMoney(Math.floor(user.balance))}
diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index b06b1066..407983fc 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -124,7 +124,7 @@ export function UserPage(props: { user: User }) { {/* Top right buttons (e.g. edit, follow) */} -
+
{!isCurrentUser && } {isCurrentUser && ( From 2fef413d88a715b949224908205464eb76112b50 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 20 Aug 2022 13:46:14 -0500 Subject: [PATCH 37/48] Don't show fantasy football in newest sort --- functions/src/scripts/unlist-contracts.ts | 29 +++++++++++++++++++++++ web/components/contract-search.tsx | 4 ++++ 2 files changed, 33 insertions(+) create mode 100644 functions/src/scripts/unlist-contracts.ts diff --git a/functions/src/scripts/unlist-contracts.ts b/functions/src/scripts/unlist-contracts.ts new file mode 100644 index 00000000..12adcedd --- /dev/null +++ b/functions/src/scripts/unlist-contracts.ts @@ -0,0 +1,29 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +initAdmin() + +import { Contract } from '../../../common/contract' + +const firestore = admin.firestore() + +async function unlistContracts() { + console.log('Updating some contracts to be unlisted') + + const snapshot = await firestore + .collection('contracts') + .where('groupSlugs', 'array-contains', 'fantasy-football-stock-exchange') + .get() + const contracts = snapshot.docs.map((doc) => doc.data() as Contract) + + console.log('Loaded', contracts.length, 'contracts') + + for (const contract of contracts) { + const contractRef = firestore.doc(`contracts/${contract.id}`) + + console.log('Updating', contract.question) + await contractRef.update({ visibility: 'soft-unlisted' }) + } +} + +if (require.main === module) unlistContracts().then(() => process.exit()) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index feb0de3b..5cb819a9 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -258,6 +258,10 @@ function ContractSearchControls(props: { filter === 'open' ? 'isResolved:false' : '', filter === 'closed' ? 'isResolved:false' : '', filter === 'resolved' ? 'isResolved:true' : '', + + // Newest sort requires public visibility. + sort === 'newest' ? 'visibility:public' : '', + pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' ? `groupLinks.slug:${pillFilter}` : '', From dd6c5dc97afea26cf9cbfb802efa4b7b72276c4f Mon Sep 17 00:00:00 2001 From: mantikoros Date: Sat, 20 Aug 2022 13:47:23 -0500 Subject: [PATCH 38/48] betting streaks copy --- .../profile/betting-streak-modal.tsx | 2 +- web/pages/notifications.tsx | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index 8404b89b..eb90f6d9 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -16,7 +16,7 @@ export function BettingStreakModal(props: {
🔥 - Betting streaks are here! + Daily betting streaks• What are they? diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index c99b226a..9541ee5b 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -377,6 +377,7 @@ function IncomeNotificationItem(props: { function reasonAndLink(simple: boolean) { const { sourceText } = notification let reasonText = '' + if (sourceType === 'bonus' && sourceText) { reasonText = !simple ? `Bonus for ${ @@ -385,23 +386,30 @@ function IncomeNotificationItem(props: { : 'bonus on' } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you on` : `in tips on` - } else if (sourceType === 'betting_streak_bonus' && sourceText) { - reasonText = `for your ${ - parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT - }-day` + } else if (sourceType === 'betting_streak_bonus') { + reasonText = 'for your' } + + const bettingStreakText = + sourceType === 'betting_streak_bonus' && + (sourceText + ? `🔥 ${ + parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT + } day Betting Streak` + : 'Betting Streak') + return ( <> {reasonText} {sourceType === 'betting_streak_bonus' ? ( simple ? ( - Betting Streak + {bettingStreakText} ) : ( - Betting Streak + {bettingStreakText} ) ) : ( From 09e8993cd4dd9ebd228444c98ab2b21f624a0156 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 20 Aug 2022 14:31:32 -0500 Subject: [PATCH 39/48] Implement visibility option for new markets --- common/contract.ts | 5 +++- common/new-contract.ts | 6 +++-- functions/src/create-market.ts | 16 +++++++++--- functions/src/scripts/unlist-contracts.ts | 2 +- web/components/contract-search.tsx | 5 ++-- web/pages/create.tsx | 30 ++++++++++++++++++----- 6 files changed, 48 insertions(+), 16 deletions(-) diff --git a/common/contract.ts b/common/contract.ts index c414a332..2a8f897a 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -31,7 +31,7 @@ export type Contract = { description: string | JSONContent // More info about what the contract is about tags: string[] lowercaseTags: string[] - visibility: 'public' | 'unlisted' + visibility: visibility createdTime: number // Milliseconds since epoch lastUpdatedTime?: number // Updated on new bet or comment @@ -143,3 +143,6 @@ export const MAX_DESCRIPTION_LENGTH = 16000 export const MAX_TAG_LENGTH = 60 export const CPMM_MIN_POOL_QTY = 0.01 + +export type visibility = 'public' | 'unlisted' +export const VISIBILITIES = ['public', 'unlisted'] as const diff --git a/common/new-contract.ts b/common/new-contract.ts index ad7dc5a2..17b872ab 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -9,6 +9,7 @@ import { Numeric, outcomeType, PseudoNumeric, + visibility, } from './contract' import { User } from './user' import { parseTags, richTextToString } from './util/parse' @@ -34,7 +35,8 @@ export function getNewContract( isLogScale: boolean, // for multiple choice - answers: string[] + answers: string[], + visibility: visibility ) { const tags = parseTags( [ @@ -70,7 +72,7 @@ export function getNewContract( description, tags, lowercaseTags, - visibility: 'public', + visibility, isResolved: false, createdTime: Date.now(), closeTime, diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index 3e9998ed..eb3a19eb 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -10,6 +10,7 @@ import { MultipleChoiceContract, NumericContract, OUTCOME_TYPES, + VISIBILITIES, } from '../../common/contract' import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' @@ -69,6 +70,7 @@ const bodySchema = z.object({ ), outcomeType: z.enum(OUTCOME_TYPES), groupId: z.string().min(1).max(MAX_ID_LENGTH).optional(), + visibility: z.enum(VISIBILITIES).optional(), }) const binarySchema = z.object({ @@ -90,8 +92,15 @@ const multipleChoiceSchema = z.object({ }) export const createmarket = newEndpoint({}, async (req, auth) => { - const { question, description, tags, closeTime, outcomeType, groupId } = - validate(bodySchema, req.body) + const { + question, + description, + tags, + closeTime, + outcomeType, + groupId, + visibility = 'public', + } = validate(bodySchema, req.body) let min, max, initialProb, isLogScale, answers @@ -196,7 +205,8 @@ export const createmarket = newEndpoint({}, async (req, auth) => { min ?? 0, max ?? 0, isLogScale ?? false, - answers ?? [] + answers ?? [], + visibility ) if (ante) await chargeUser(user.id, ante, true) diff --git a/functions/src/scripts/unlist-contracts.ts b/functions/src/scripts/unlist-contracts.ts index 12adcedd..63307653 100644 --- a/functions/src/scripts/unlist-contracts.ts +++ b/functions/src/scripts/unlist-contracts.ts @@ -22,7 +22,7 @@ async function unlistContracts() { const contractRef = firestore.doc(`contracts/${contract.id}`) console.log('Updating', contract.question) - await contractRef.update({ visibility: 'soft-unlisted' }) + await contractRef.update({ visibility: 'unlisted' }) } } diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 5cb819a9..1b55fe97 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -255,13 +255,12 @@ function ContractSearchControls(props: { ? additionalFilters : [ ...additionalFilters, + 'visibility:public', + filter === 'open' ? 'isResolved:false' : '', filter === 'closed' ? 'isResolved:false' : '', filter === 'resolved' ? 'isResolved:true' : '', - // Newest sort requires public visibility. - sort === 'newest' ? 'visibility:public' : '', - pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' ? `groupLinks.slug:${pillFilter}` : '', diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ab566c9e..8a9e363b 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -15,6 +15,7 @@ import { MAX_DESCRIPTION_LENGTH, MAX_QUESTION_LENGTH, outcomeType, + visibility, } from 'common/contract' import { formatMoney } from 'common/util/format' import { removeUndefinedProps } from 'common/util/object' @@ -150,6 +151,7 @@ export function NewContract(props: { undefined ) const [showGroupSelector, setShowGroupSelector] = useState(true) + const [visibility, setVisibility] = useState('public') const closeTime = closeDate ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf() @@ -234,6 +236,7 @@ export function NewContract(props: { isLogScale, answers, groupId: selectedGroup?.id, + visibility, }) ) track('create market', { @@ -367,17 +370,32 @@ export function NewContract(props: { )} -
- + + setVisibility(choice as visibility)} + choicesMap={{ + Public: 'public', + Unlisted: 'unlisted', + }} + isSubmitting={isSubmitting} />
+ + + +
- @@ -175,9 +168,9 @@
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ This e-mail has been sent to + {{name}}, + click here to unsubscribe. +

+
+
+
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index acab22d8..97ffce10 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -20,6 +20,7 @@ import { sendTemplateEmail, sendTextEmail } from './send-email' import { getPrivateUser, getUser } from './utils' import { getFunctionUrl } from '../../common/api' import { richTextToString } from '../../common/util/parse' +import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') @@ -460,3 +461,61 @@ export const sendNewAnswerEmail = async ( { from } ) } + +export const sendInterestingMarketsEmail = async ( + user: User, + privateUser: PrivateUser, + contractsToSend: Contract[], + deliveryTime?: string +) => { + if ( + !privateUser || + !privateUser.email || + privateUser?.unsubscribedFromWeeklyTrendingEmails + ) + return + + const emailType = 'weekly-trending' + const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}` + + const { name } = user + const firstName = name.split(' ')[0] + + await sendTemplateEmail( + privateUser.email, + `${contractsToSend[0].question} & 5 more interesting markets on Manifold`, + 'interesting-markets', + { + name: firstName, + unsubscribeLink: unsubscribeUrl, + + question1Title: contractsToSend[0].question, + question1Link: contractUrl(contractsToSend[0]), + question1ImgSrc: imageSourceUrl(contractsToSend[0]), + question2Title: contractsToSend[1].question, + question2Link: contractUrl(contractsToSend[1]), + question2ImgSrc: imageSourceUrl(contractsToSend[1]), + question3Title: contractsToSend[2].question, + question3Link: contractUrl(contractsToSend[2]), + question3ImgSrc: imageSourceUrl(contractsToSend[2]), + question4Title: contractsToSend[3].question, + question4Link: contractUrl(contractsToSend[3]), + question4ImgSrc: imageSourceUrl(contractsToSend[3]), + question5Title: contractsToSend[4].question, + question5Link: contractUrl(contractsToSend[4]), + question5ImgSrc: imageSourceUrl(contractsToSend[4]), + question6Title: contractsToSend[5].question, + question6Link: contractUrl(contractsToSend[5]), + question6ImgSrc: imageSourceUrl(contractsToSend[5]), + }, + deliveryTime ? { 'o:deliverytime': deliveryTime } : undefined + ) +} + +function contractUrl(contract: Contract) { + return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}` +} + +function imageSourceUrl(contract: Contract) { + return buildCardUrl(getOpenGraphProps(contract)) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index c9f62484..ec1947f1 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -25,8 +25,10 @@ export * from './on-create-comment-on-group' export * from './on-create-txn' export * from './on-delete-group' export * from './score-contracts' +export * from './weekly-markets-emails' export * from './reset-betting-streaks' + // v2 export * from './health' export * from './transact' diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index fda20e16..4db91539 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -21,6 +21,7 @@ export const unsubscribe: EndpointDefinition = { 'market-comment', 'market-answer', 'generic', + 'weekly-trending', ].includes(type) ) { res.status(400).send('Invalid type parameter.') @@ -49,6 +50,9 @@ export const unsubscribe: EndpointDefinition = { ...(type === 'generic' && { unsubscribedFromGenericEmails: true, }), + ...(type === 'weekly-trending' && { + unsubscribedFromWeeklyTrendingEmails: true, + }), } await firestore.collection('private-users').doc(id).update(update) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 721f33d0..2d620728 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -88,6 +88,12 @@ export const getPrivateUser = (userId: string) => { return getDoc('private-users', userId) } +export const getAllPrivateUsers = async () => { + const firestore = admin.firestore() + const users = await firestore.collection('private-users').get() + return users.docs.map((doc) => doc.data() as PrivateUser) +} + export const getUserByUsername = async (username: string) => { const firestore = admin.firestore() const snap = await firestore diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts new file mode 100644 index 00000000..c5805d0b --- /dev/null +++ b/functions/src/weekly-markets-emails.ts @@ -0,0 +1,82 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { Contract } from '../../common/contract' +import { getPrivateUser, getUser, getValues, isProd, log } from './utils' +import { filterDefined } from '../../common/util/array' +import { sendInterestingMarketsEmail } from './emails' +import { createRNG, shuffle } from '../../common/util/random' +import { DAY_MS } from '../../common/util/time' + +export const weeklyMarketsEmails = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .pubsub.schedule('every 1 minutes') + .onRun(async () => { + await sendTrendingMarketsEmailsToAllUsers() + }) + +const firestore = admin.firestore() + +export async function getTrendingContracts() { + return await getValues( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('closeTime', '>', Date.now() + DAY_MS) + .where('visibility', '==', 'public') + .orderBy('closeTime', 'asc') + .orderBy('popularityScore', 'desc') + .limit(15) + ) +} + +async function sendTrendingMarketsEmailsToAllUsers() { + const numEmailsToSend = 6 + // const privateUsers = await getAllPrivateUsers() + // uses dev ian's private user for testing + const privateUser = await getPrivateUser( + isProd() ? 'AJwLWoo3xue32XIiAVrL5SyR1WB2' : '6hHpzvRG0pMq8PNJs7RZj2qlZGn2' + ) + const privateUsers = filterDefined([privateUser]) + // get all users that haven't unsubscribed from weekly emails + const privateUsersToSendEmailsTo = privateUsers.filter((user) => { + return !user.unsubscribedFromWeeklyTrendingEmails + }) + const trendingContracts = (await getTrendingContracts()).filter( + (contract) => + !( + contract.question.toLowerCase().includes('trump') && + contract.question.toLowerCase().includes('president') + ) + ) + for (const privateUser of privateUsersToSendEmailsTo) { + if (!privateUser.email) { + log(`No email for ${privateUser.username}`) + continue + } + const contractsAvailableToSend = trendingContracts.filter((contract) => { + return !contract.uniqueBettorIds?.includes(privateUser.id) + }) + if (contractsAvailableToSend.length < numEmailsToSend) { + log('not enough new, unbet-on contracts to send to user', privateUser.id) + continue + } + // choose random subset of contracts to send to user + const contractsToSend = chooseRandomSubset( + contractsAvailableToSend, + numEmailsToSend + ) + + const user = await getUser(privateUser.id) + if (!user) continue + + await sendInterestingMarketsEmail(user, privateUser, contractsToSend) + } +} + +function chooseRandomSubset(contracts: Contract[], count: number) { + const fiveMinutes = 5 * 60 * 1000 + const seed = Math.round(Date.now() / fiveMinutes).toString() + shuffle(contracts, createRNG(seed)) + return contracts.slice(0, count) +} diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 08dee31e..2c9327ec 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -1,61 +1,7 @@ import { ReactNode } from 'react' import Head from 'next/head' import { Challenge } from 'common/challenge' - -export type OgCardProps = { - question: string - probability?: string - metadata: string - creatorName: string - creatorUsername: string - creatorAvatarUrl?: string - numericValue?: string -} - -function buildCardUrl(props: OgCardProps, challenge?: Challenge) { - const { - creatorAmount, - acceptances, - acceptorAmount, - creatorOutcome, - acceptorOutcome, - } = challenge || {} - const { userName, userAvatarUrl } = acceptances?.[0] ?? {} - - const probabilityParam = - props.probability === undefined - ? '' - : `&probability=${encodeURIComponent(props.probability ?? '')}` - - const numericValueParam = - props.numericValue === undefined - ? '' - : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` - - const creatorAvatarUrlParam = - props.creatorAvatarUrl === undefined - ? '' - : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` - - const challengeUrlParams = challenge - ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + - `&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` + - `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` - : '' - - // URL encode each of the props, then add them as query params - return ( - `https://manifold-og-image.vercel.app/m.png` + - `?question=${encodeURIComponent(props.question)}` + - probabilityParam + - numericValueParam + - `&metadata=${encodeURIComponent(props.metadata)}` + - `&creatorName=${encodeURIComponent(props.creatorName)}` + - creatorAvatarUrlParam + - `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + - challengeUrlParams - ) -} +import { buildCardUrl, OgCardProps } from 'common/contract-details' export function SEO(props: { title: string diff --git a/web/components/contract/contract-card-preview.tsx b/web/components/contract/contract-card-preview.tsx deleted file mode 100644 index 354fe308..00000000 --- a/web/components/contract/contract-card-preview.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Contract } from 'common/contract' -import { getBinaryProbPercent } from 'web/lib/firebase/contracts' -import { richTextToString } from 'common/util/parse' -import { contractTextDetails } from 'web/components/contract/contract-details' -import { getFormattedMappedValue } from 'common/pseudo-numeric' -import { getProbability } from 'common/calculate' - -export const getOpenGraphProps = (contract: Contract) => { - const { - resolution, - question, - creatorName, - creatorUsername, - outcomeType, - creatorAvatarUrl, - description: desc, - } = contract - const probPercent = - outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined - - const numericValue = - outcomeType === 'PSEUDO_NUMERIC' - ? getFormattedMappedValue(contract)(getProbability(contract)) - : undefined - - const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) - - const description = resolution - ? `Resolved ${resolution}. ${stringDesc}` - : probPercent - ? `${probPercent} chance. ${stringDesc}` - : stringDesc - - return { - question, - probability: probPercent, - metadata: contractTextDetails(contract), - creatorName, - creatorUsername, - creatorAvatarUrl, - description, - numericValue, - } -} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 5a62313f..833b37eb 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -9,11 +9,7 @@ import { import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' import { UserLink } from '../user-page' -import { - Contract, - contractMetrics, - updateContract, -} from 'web/lib/firebase/contracts' +import { Contract, updateContract } from 'web/lib/firebase/contracts' import dayjs from 'dayjs' import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' @@ -35,6 +31,7 @@ import { SiteLink } from 'web/components/site-link' import { groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import clsx from 'clsx' +import { contractMetrics } from 'common/contract-details' export type ShowTime = 'resolve-date' | 'close-date' @@ -245,25 +242,6 @@ export function ContractDetails(props: { ) } -// String version of the above, to send to the OpenGraph image generator -export function contractTextDetails(contract: Contract) { - const { closeTime, tags } = contract - const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) - - const hashtags = tags.map((tag) => `#${tag}`) - - return ( - `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + - (closeTime - ? ` • ${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs( - closeTime - ).format('MMM D, h:mma')}` - : '') + - ` • ${volumeLabel}` + - (hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '') - ) -} - function EditableCloseDate(props: { closeTime: number contract: Contract diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 92cee018..7ef371f0 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -23,7 +23,7 @@ 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' -import { getBinaryProb, getBinaryProbPercent } from 'web/lib/firebase/contracts' +import { 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' @@ -34,6 +34,7 @@ import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { track } from 'web/lib/service/analytics' import { formatNumericProbability } from 'common/pseudo-numeric' import { useUnfilledBets } from 'web/hooks/use-bets' +import { getBinaryProb } from 'common/contract-details' const BET_SIZE = 10 diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index dcd5743b..62673428 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -11,7 +11,6 @@ import clsx from 'clsx' import { OutcomeLabel } from '../outcome-label' import { Contract, - contractMetrics, contractPath, tradingAllowed, } from 'web/lib/firebase/contracts' @@ -38,6 +37,7 @@ import { FeedLiquidity } from './feed-liquidity' import { SignUpPrompt } from '../sign-up-prompt' import { User } from 'common/user' import { PlayMoneyDisclaimer } from '../play-money-disclaimer' +import { contractMetrics } from 'common/contract-details' export function FeedItems(props: { contract: Contract diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 243a453a..1f83372e 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -1,4 +1,3 @@ -import dayjs from 'dayjs' import { collection, deleteDoc, @@ -17,14 +16,13 @@ import { sortBy, sum } from 'lodash' 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' -import { getCpmmProbability } from 'common/calculate-cpmm' import { formatMoney, formatPercent } from 'common/util/format' import { DAY_MS } from 'common/util/time' import { Bet } from 'common/bet' import { Comment } from 'common/comment' import { ENV_CONFIG } from 'common/envs/constants' +import { getBinaryProb } from 'common/contract-details' export const contracts = coll('contracts') @@ -49,20 +47,6 @@ export function contractUrl(contract: Contract) { return `https://${ENV_CONFIG.domain}${contractPath(contract)}` } -export function contractMetrics(contract: Contract) { - const { createdTime, resolutionTime, isResolved } = contract - - const createdDate = dayjs(createdTime).format('MMM D') - - const resolvedDate = isResolved - ? dayjs(resolutionTime).format('MMM D') - : undefined - - const volumeLabel = `${formatMoney(contract.volume)} bet` - - return { volumeLabel, createdDate, resolvedDate } -} - export function contractPool(contract: Contract) { return contract.mechanism === 'cpmm-1' ? formatMoney(contract.totalLiquidity) @@ -71,17 +55,6 @@ export function contractPool(contract: Contract) { : 'Empty pool' } -export function getBinaryProb(contract: BinaryContract) { - const { pool, resolutionProbability, mechanism } = contract - - return ( - resolutionProbability ?? - (mechanism === 'cpmm-1' - ? getCpmmProbability(pool, contract.p) - : getDpmProbability(contract.totalShares)) - ) -} - export function getBinaryProbPercent(contract: BinaryContract) { return formatPercent(getBinaryProb(contract)) } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 41ad5957..c86f9c55 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -36,13 +36,13 @@ import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { useSaveReferral } from 'web/hooks/use-save-referral' -import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' import { User } from 'common/user' import { ContractComment } from 'common/comment' import { listUsers } from 'web/lib/firebase/users' import { FeedComment } from 'web/components/feed/feed-comments' import { Title } from 'web/components/title' import { FeedBet } from 'web/components/feed/feed-bets' +import { getOpenGraphProps } from 'common/contract-details' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { diff --git a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx index 55e78616..f15c5809 100644 --- a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -28,11 +28,11 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { useWindowSize } from 'web/hooks/use-window-size' import { Bet, listAllBets } from 'web/lib/firebase/bets' import { SEO } from 'web/components/SEO' -import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' import Custom404 from 'web/pages/404' import { useSaveReferral } from 'web/hooks/use-save-referral' import { BinaryContract } from 'common/contract' import { Title } from 'web/components/title' +import { getOpenGraphProps } from 'common/contract-details' export const getStaticProps = fromPropz(getStaticPropz) From 36bfbe8f4240714fc023c7980f39ff1628a1712a Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 19 Aug 2022 14:37:16 -0600 Subject: [PATCH 25/48] Change betting streak modal, tweak trending email query --- common/numeric-constants.ts | 1 + functions/src/on-create-bet.ts | 3 ++- functions/src/weekly-markets-emails.ts | 9 +++++---- web/components/profile/betting-streak-modal.tsx | 10 ++++++++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index 9d41d54f..4d04a2c7 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -5,4 +5,5 @@ export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_TEXT_COLOR = 'text-blue-500' export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 export const BETTING_STREAK_BONUS_AMOUNT = 5 +export const BETTING_STREAK_BONUS_MAX = 100 export const BETTING_STREAK_RESET_HOUR = 9 diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index c5648293..45adade5 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -14,6 +14,7 @@ import { Contract } from '../../common/contract' import { runTxn, TxnData } from './transact' import { BETTING_STREAK_BONUS_AMOUNT, + BETTING_STREAK_BONUS_MAX, BETTING_STREAK_RESET_HOUR, UNIQUE_BETTOR_BONUS_AMOUNT, } from '../../common/numeric-constants' @@ -86,7 +87,7 @@ const updateBettingStreak = async ( // Send them the bonus times their streak const bonusAmount = Math.min( BETTING_STREAK_BONUS_AMOUNT * newBettingStreak, - 100 + BETTING_STREAK_BONUS_MAX ) const fromUserId = isProd() ? HOUSE_LIQUIDITY_PROVIDER_ID diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index c5805d0b..62a06a7f 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -22,11 +22,12 @@ export async function getTrendingContracts() { firestore .collection('contracts') .where('isResolved', '==', false) - .where('closeTime', '>', Date.now() + DAY_MS) .where('visibility', '==', 'public') - .orderBy('closeTime', 'asc') + // can't use multiple inequality (/orderBy) operators on different fields, + // so have to filter for closed contracts separately .orderBy('popularityScore', 'desc') - .limit(15) + // might as well go big and do a quick filter for closed ones later + .limit(500) ) } @@ -47,7 +48,7 @@ async function sendTrendingMarketsEmailsToAllUsers() { !( contract.question.toLowerCase().includes('trump') && contract.question.toLowerCase().includes('president') - ) + ) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS ) for (const privateUser of privateUsersToSendEmailsTo) { if (!privateUser.email) { diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index 345d38b1..8404b89b 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -1,5 +1,10 @@ import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' +import { + BETTING_STREAK_BONUS_AMOUNT, + BETTING_STREAK_BONUS_MAX, +} from 'common/numeric-constants' +import { formatMoney } from 'common/util/format' export function BettingStreakModal(props: { isOpen: boolean @@ -15,8 +20,9 @@ export function BettingStreakModal(props: {
- - - - - - -
+
+ + banner logo +
- @@ -225,22 +218,12 @@ style="color:#55575d;font-family:Arial;font-size:18px;">Join our Discord chat - -

 

-

Cheers, -

-

David - from Manifold

-

 

+ + style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;"> Explore markets
+ style="font-size:0px;padding:15px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">

Date: Sat, 20 Aug 2022 15:34:22 -0500 Subject: [PATCH 46/48] send creator guide on D1 --- .../src/email-templates/creating-market.html | 452 +++++++++--------- functions/src/emails.ts | 8 +- functions/src/on-create-contract.ts | 24 +- functions/src/on-create-user.ts | 4 + 4 files changed, 230 insertions(+), 258 deletions(-) diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html index 674a30ed..a61e8d65 100644 --- a/functions/src/email-templates/creating-market.html +++ b/functions/src/email-templates/creating-market.html @@ -103,94 +103,28 @@ -

- -
+
- - - - - - -
- -
- - - - - - -
- - - - - - -
- -
-
-
- -
-
- -
- - - - + + + + + + + +
+
+ + banner logo + +
- -
+
- - - - - - - + + + + + + + +
-
-

- Hi {{name}},

-
-
+
+
+

+ Hi {{name}},

+
+
-
-

- Congrats on creating your first market on Manifold! -

+ ">Did you know you create your own prediction market on Manifold for + any question you care about? +

-

- + The following is a short guide to creating markets. -

-

-   -

-

- Whether it's current events like Musk buying + Twitter or 2024 + elections or personal matters + like book + recommendations or losing + weight, + Manifold can help you find the answer. +

+

+ The following is a + short guide to creating markets. +

+ + + + + +
+ + Create a market + +
+ +

+   +

+

+ What makes a good market? -

-
    -
  • - Interesting - topic. Manifold gives - creators M$10 for - each unique trader that bets on your - market, so it pays to ask a question people are interested in! -
  • +

    +
      +
    • + Interesting + topic. Manifold gives + creators M$10 for + each unique trader that bets on your + market, so it pays to ask a question people are interested in! +
    • -
    • - + Clear resolution criteria. Any ambiguities or edge cases in your description - will drive traders away from your markets. -
    • + will drive traders away from your markets. + -
    • - + Detailed description. Include images/videos/tweets and any context or - background - information that could be useful to people who - are interested in learning more that are - uneducated on the subject. -
    • -
    • - + Add it to a group. Groups are the - primary way users filter for relevant markets. - Also, consider making your own groups and - inviting friends/interested communities to - them from other sites! -
    • -
    • - Part of a group. Groups are the + primary way users filter for relevant markets. + Also, consider making your own groups and + inviting friends/interested communities to + them from other sites! +
    • +
    • + Share it on social media. You'll earn the Sharing it on social media. You'll earn the M$500 - referral bonus if you get new users to sign up! -
    • -
    -

    -   -

    -

    - Examples of markets you should - emulate!  -

    - -

    -   -

    -

    - +   +

    + +

    + Why not - - - - create another marketcreate a market - while it is still fresh on your mind? -

    -

    - +   +

    +

    + Thanks for reading! -

    -

    - David from Manifold -

    -
+

+ +
+
+ +
+
+ +
+ +
+ + + +
+ +
+ + + + diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 6768e8ea..f90366fa 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,4 +1,3 @@ - import { DOMAIN } from '../../common/envs/constants' import { Answer } from '../../common/answer' import { Bet } from '../../common/bet' @@ -192,7 +191,6 @@ Cofounder of Manifold Markets https://manifold.markets ` - await sendTextEmail( privateUser.email, 'How are you finding Manifold?', @@ -238,7 +236,8 @@ export const sendOneWeekBonusEmail = async ( export const sendCreatorGuideEmail = async ( user: User, - privateUser: PrivateUser + privateUser: PrivateUser, + sendTime: string ) => { if ( !privateUser || @@ -255,7 +254,7 @@ export const sendCreatorGuideEmail = async ( return await sendTemplateEmail( privateUser.email, - 'Market creation guide', + 'Create your own prediction market', 'creating-market', { name: firstName, @@ -263,6 +262,7 @@ export const sendCreatorGuideEmail = async ( }, { from: 'David from Manifold ', + 'o:deliverytime': sendTime, } ) } diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index 73076b7f..3785ecc9 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,13 +1,10 @@ import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' -import { getPrivateUser, getUser } from './utils' +import { getUser } from './utils' import { createNotification } from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' -import { User } from 'common/user' -import { sendCreatorGuideEmail } from './emails' export const onCreateContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) @@ -31,23 +28,4 @@ export const onCreateContract = functions richTextToString(desc), { contract, recipients: mentioned } ) - - await sendGuideEmail(contractCreator) }) - -const firestore = admin.firestore() - -const sendGuideEmail = async (contractCreator: User) => { - const query = await firestore - .collection(`contracts`) - .where('creatorId', '==', contractCreator.id) - .limit(2) - .get() - - if (query.size >= 2) return - - const privateUser = await getPrivateUser(contractCreator.id) - if (!privateUser) return - - await sendCreatorGuideEmail(contractCreator, privateUser) -} diff --git a/functions/src/on-create-user.ts b/functions/src/on-create-user.ts index dfb6edaa..844f75fc 100644 --- a/functions/src/on-create-user.ts +++ b/functions/src/on-create-user.ts @@ -6,6 +6,7 @@ dayjs.extend(utc) import { getPrivateUser } from './utils' import { User } from 'common/user' import { + sendCreatorGuideEmail, sendInterestingMarketsEmail, sendPersonalFollowupEmail, sendWelcomeEmail, @@ -22,6 +23,9 @@ export const onCreateUser = functions await sendWelcomeEmail(user, privateUser) + const guideSendTime = dayjs().add(28, 'hours').toString() + await sendCreatorGuideEmail(user, privateUser, guideSendTime) + const followupSendTime = dayjs().add(48, 'hours').toString() await sendPersonalFollowupEmail(user, privateUser, followupSendTime) From 97b38c156f7b4125f297b4402fb3709d2ffdbb48 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Sat, 20 Aug 2022 15:34:52 -0500 Subject: [PATCH 47/48] Revert "create contract: ante no longer user liquidity provision" This reverts commit 56e9b5fa2f6850b83a39915b81bb5f68b950f65c. --- functions/src/create-market.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index eb3a19eb..5b0d1daf 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -15,17 +15,15 @@ import { import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' -import { chargeUser, getContract, isProd } from './utils' +import { chargeUser, getContract } from './utils' import { APIError, newEndpoint, validate, zTimestamp } from './api' import { - DEV_HOUSE_LIQUIDITY_PROVIDER_ID, FIXED_ANTE, getCpmmInitialLiquidity, getFreeAnswerAnte, getMultipleChoiceAntes, getNumericAnte, - HOUSE_LIQUIDITY_PROVIDER_ID, } from '../../common/antes' import { Answer, getNoneAnswer } from '../../common/answer' import { getNewContract } from '../../common/new-contract' @@ -223,9 +221,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } } - const providerId = isProd() - ? HOUSE_LIQUIDITY_PROVIDER_ID - : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + const providerId = user.id if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { const liquidityDoc = firestore From 645cfc65f4e425864f987ff9778e96c57b54f76b Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 21 Aug 2022 12:57:00 -0500 Subject: [PATCH 48/48] Update sort order of limit orders (older bets first b/c they are filled first) --- web/components/limit-bets.tsx | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx index 8c9f4e6b..a3cd7973 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -22,20 +22,20 @@ export function LimitBets(props: { className?: string }) { 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) + + const yourBets = sortBy( + bets.filter((bet) => bet.userId === user?.id), + (bet) => -1 * bet.limitProb, + (bet) => bet.createdTime + ) return ( {yourBets.length === 0 && ( )} @@ -49,7 +49,7 @@ export function LimitBets(props: { @@ -163,8 +163,16 @@ export function OrderBookButton(props: { const { limitBets, contract, className } = props const [open, setOpen] = useState(false) - const yesBets = limitBets.filter((bet) => bet.outcome === 'YES') - const noBets = limitBets.filter((bet) => bet.outcome === 'NO').reverse() + const yesBets = sortBy( + limitBets.filter((bet) => bet.outcome === 'YES'), + (bet) => -1 * bet.limitProb, + (bet) => bet.createdTime + ) + const noBets = sortBy( + limitBets.filter((bet) => bet.outcome === 'NO'), + (bet) => bet.limitProb, + (bet) => bet.createdTime + ) return ( <>
+ + + + + + +
+ +