From d83e103fab171215512df8df0bebf6db5a898929 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 3 Aug 2022 18:42:40 -0600 Subject: [PATCH 1/7] Ignore clicks when hidden --- web/components/groups/group-chat.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 2b5bd6e1..91de63c6 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -214,8 +214,8 @@ export function GroupChatInBubble(props: { return ( {shouldShowChat && ( From 7e46188107518aeb6e83a2e6e77bf8ec3965971c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 3 Aug 2022 22:21:22 -0700 Subject: [PATCH 2/7] Add lite market endpoint --- web/pages/api/v0/market/[id]/lite.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 web/pages/api/v0/market/[id]/lite.ts diff --git a/web/pages/api/v0/market/[id]/lite.ts b/web/pages/api/v0/market/[id]/lite.ts new file mode 100644 index 00000000..7688caa8 --- /dev/null +++ b/web/pages/api/v0/market/[id]/lite.ts @@ -0,0 +1,23 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { getContractFromId } from 'web/lib/firebase/contracts' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { ApiError, toLiteMarket, LiteMarket } from '../../_types' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + const { id } = req.query + const contractId = id as string + + const contract = await getContractFromId(contractId) + + if (!contract) { + res.status(404).json({ error: 'Contract not found' }) + return + } + + res.setHeader('Cache-Control', 'max-age=0') + return res.status(200).json(toLiteMarket(contract)) +} From 2d3ca47b52001ea8b668060a533351357e50d37d Mon Sep 17 00:00:00 2001 From: SirSaltyy <104849031+SirSaltyy@users.noreply.github.com> Date: Fri, 5 Aug 2022 03:03:02 +0900 Subject: [PATCH 3/7] 500 mana email (#687) * Create 500-mana.html * Update 500-mana.html Fixed typos and links not working * Added "create a good market" guide added page creating-market.html For Stephen to set up condition (email 3 days after signing up) * Update 500-mana.html updated 500 Mana email (still need to make changes to create market guide) * email changes * sendOneWeekBonusEmail logic * add dayjs as dependency * don't use mailgun scheduling Co-authored-by: mantikoros --- common/user.ts | 2 + functions/package.json | 1 + functions/src/create-user.ts | 5 +- functions/src/email-templates/500-mana.html | 267 ++++++- .../src/email-templates/creating-market.html | 738 ++++++++++++++++++ functions/src/emails.ts | 5 +- functions/src/index.ts | 19 + functions/src/mana-bonus-email.ts | 42 + functions/src/send-email.ts | 6 +- yarn.lock | 5 + 10 files changed, 1065 insertions(+), 25 deletions(-) create mode 100644 functions/src/email-templates/creating-market.html create mode 100644 functions/src/mana-bonus-email.ts diff --git a/common/user.ts b/common/user.ts index 78b76511..2aeb7122 100644 --- a/common/user.ts +++ b/common/user.ts @@ -47,6 +47,7 @@ export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 // for sus users, i.e. multiple sign ups for same person export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10 export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500 + export type PrivateUser = { id: string // same as User.id username: string // denormalized from User @@ -56,6 +57,7 @@ export type PrivateUser = { unsubscribedFromCommentEmails?: boolean unsubscribedFromAnswerEmails?: boolean unsubscribedFromGenericEmails?: boolean + manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string apiKey?: string diff --git a/functions/package.json b/functions/package.json index b20a8fd0..b0d8e458 100644 --- a/functions/package.json +++ b/functions/package.json @@ -31,6 +31,7 @@ "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/starter-kit": "2.0.0-beta.190", + "dayjs": "1.11.4", "cors": "2.8.5", "express": "4.18.1", "firebase-admin": "10.0.0", diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 70e81055..c30e78c3 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,5 +1,7 @@ import * as admin from 'firebase-admin' import { z } from 'zod' +import { uniq } from 'lodash' + import { MANIFOLD_AVATAR_URL, MANIFOLD_USERNAME, @@ -24,7 +26,6 @@ import { import { track } from './analytics' import { APIError, newEndpoint, validate } from './api' import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' -import { uniq } from 'lodash' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, @@ -93,8 +94,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => { await firestore.collection('private-users').doc(auth.uid).create(privateUser) - await sendWelcomeEmail(user, privateUser) await addUserToDefaultGroups(user) + await sendWelcomeEmail(user, privateUser) await track(auth.uid, 'create user', { username }, { ip: req.ip }) return user diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html index 5f0c450e..1ef9dbb7 100644 --- a/functions/src/email-templates/500-mana.html +++ b/functions/src/email-templates/500-mana.html @@ -1,12 +1,48 @@ - + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+
+

Thanks for + using Manifold Markets. Running low + on mana (M$)? Click the link below to receive a one time gift of M$500!

+
+
+

+
+ + + + +
+ + + + +
+ + Claim M$500 + +
+
+
+
+

+ +

 

+

Cheers,

+

David from Manifold

+

 

+
+
+
+ +
+
+ +
+ + + +
+ +
+ + + +
+ + + +
+
+ + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

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

+
+
+
+
+
+
+
+ +
+ + + + + + \ No newline at end of file diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html new file mode 100644 index 00000000..64273e7c --- /dev/null +++ b/functions/src/email-templates/creating-market.html @@ -0,0 +1,738 @@ + + + + (no subject) + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+

+ On Manifold Markets, several important factors + go into making a good question. These lead to + more people betting on them and allowing a more + accurate prediction to be formed! +

+

+   +

+

+ Manifold also gives its creators 10 Mana for + each unique trader that bets on your + market! +

+

+   +

+

+ What makes a good question? +

+
    +
  • + Clear resolution criteria. This is + needed so users know how you are going to + decide on what the correct answer is. +
  • +
  • + Clear resolution date. This is + sometimes slightly different from the closing + date. We recommend leaving the market open up + until you resolve it, but if it is different + make sure you say what day you intend to + resolve it in the description! +
  • +
  • + Detailed description. Use the rich + text editor to create an easy to read + description. Include 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! +
  • +
  • + Bonus: Add a comment on your + prediction and explain (with links and + sources) supporting it. +
  • +
+

+   +

+

+ Examples of markets you should + emulate!  +

+ +

+   +

+

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

+

+ Thanks for reading! +

+

+ David from Manifold +

+
+
+
+ +
+
+ +
+ + + + + +
+ + </div> + <Col className="w-full items-center justify-start gap-2"> + <Row className={'w-full justify-start gap-20'}> + <span className={'min-w-[4rem] font-bold'}>Cost to you:</span>{' '} + <span className={'text-red-500'}> + {formatMoney(acceptorAmount)} + </span> + </Row> + <Col className={'w-full items-center justify-start'}> + <Row className={'w-full justify-start gap-10'}> + <span className={'min-w-[4rem] font-bold'}> + Potential payout: + </span>{' '} + <Row className={'items-center justify-center'}> + <span className={'text-primary'}> + {formatMoney(creatorAmount + acceptorAmount)} + </span> + </Row> + </Row> + </Col> + </Col> + <Row className={'mt-4 justify-end gap-4'}> + <Button + color={'gray'} + disabled={loading} + onClick={() => setOpen(false)} + className={clsx('whitespace-nowrap')} + > + I'm out + </Button> + <Button + color={'indigo'} + disabled={loading} + onClick={() => iAcceptChallenge()} + className={clsx('min-w-[6rem] whitespace-nowrap')} + > + I'm in + </Button> + </Row> + <Row> + <span className={'text-error'}>{errorText}</span> + </Row> + </Col> + </Col> + </Modal> + + {challenge.creatorId != user.id && ( + <Button + color="gradient" + size="2xl" + onClick={() => setOpen(true)} + className={clsx('whitespace-nowrap')} + > + Accept bet + </Button> + )} + </> + ) +} diff --git a/web/components/challenges/create-challenge-button.tsx b/web/components/challenges/create-challenge-button.tsx new file mode 100644 index 00000000..6eab9bc5 --- /dev/null +++ b/web/components/challenges/create-challenge-button.tsx @@ -0,0 +1,255 @@ +import clsx from 'clsx' +import dayjs from 'dayjs' +import React, { useEffect, useState } from 'react' +import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline' + +import { Col } from '../layout/col' +import { Row } from '../layout/row' +import { Title } from '../title' +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 { SiteLink } from 'web/components/site-link' +import { formatMoney } from 'common/util/format' +import { NoLabel, YesLabel } from '../outcome-label' +import { QRCode } from '../qr-code' +import { copyToClipboard } from 'web/lib/util/copy' +import toast from 'react-hot-toast' + +type challengeInfo = { + amount: number + expiresTime: number | null + message: string + outcome: 'YES' | 'NO' | number + acceptorAmount: number +} +export function CreateChallengeButton(props: { + user: User | null | undefined + contract: BinaryContract +}) { + const { user, contract } = props + const [open, setOpen] = useState(false) + const [challengeSlug, setChallengeSlug] = useState('') + + return ( + <> + <Modal open={open} setOpen={(newOpen) => setOpen(newOpen)} size={'sm'}> + <Col className="gap-4 rounded-md bg-white px-8 py-6"> + {/*// add a sign up to challenge button?*/} + {user && ( + <CreateChallengeForm + user={user} + contract={contract} + 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, + }) + challenge && setChallengeSlug(getChallengeUrl(challenge)) + }} + challengeSlug={challengeSlug} + /> + )} + </Col> + </Modal> + + <button + onClick={() => setOpen(true)} + className="btn btn-outline mb-4 max-w-xs whitespace-nowrap normal-case" + > + Challenge a friend + </button> + </> + ) +} + +function CreateChallengeForm(props: { + user: User + contract: BinaryContract + onCreate: (m: challengeInfo) => Promise<void> + challengeSlug: string +}) { + const { user, onCreate, contract, challengeSlug } = 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, + }) + useEffect(() => { + setError('') + }, [challengeInfo]) + + return ( + <> + {!finishedCreating && ( + <form + onSubmit={(e) => { + e.preventDefault() + if (user.balance < challengeInfo.amount) { + setError('You do not have enough mana to create this challenge') + return + } + setIsCreating(true) + onCreate(challengeInfo).finally(() => setIsCreating(false)) + setFinishedCreating(true) + }} + > + <Title className="!mt-2" text="Challenge a friend to bet " /> + <div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> + <div>You'll bet:</div> + <Row + className={ + 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' + } + > + <Col> + <div className="relative"> + <span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> + M$ + </span> + <input + className="input input-bordered w-32 pl-10" + type="number" + min={1} + value={challengeInfo.amount} + onChange={(e) => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + amount: parseInt(e.target.value), + acceptorAmount: editingAcceptorAmount + ? m.acceptorAmount + : parseInt(e.target.value), + } + }) + } + /> + </div> + </Col> + <span className={''}>on</span> + {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} + </Row> + <Row className={'mt-3 max-w-xs justify-end'}> + <Button + color={'gradient'} + className={'opacity-80'} + onClick={() => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + outcome: m.outcome === 'YES' ? 'NO' : 'YES', + } + }) + } + > + <SwitchVerticalIcon className={'h-4 w-4'} /> + </Button> + </Row> + <Row className={'items-center'}>If they bet:</Row> + <Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> + <div className={'w-32 sm:mr-1'}> + {editingAcceptorAmount ? ( + <Col> + <div className="relative"> + <span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> + M$ + </span> + <input + className="input input-bordered w-32 pl-10" + type="number" + min={1} + value={challengeInfo.acceptorAmount} + onChange={(e) => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + acceptorAmount: parseInt(e.target.value), + } + }) + } + /> + </div> + </Col> + ) : ( + <span className="ml-1 font-bold"> + {formatMoney(challengeInfo.acceptorAmount)} + </span> + )} + </div> + <span>on</span> + {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} + </Row> + </div> + <Row + className={clsx( + 'mt-8', + !editingAcceptorAmount ? 'justify-between' : 'justify-end' + )} + > + {!editingAcceptorAmount && ( + <Button + color={'gray-white'} + onClick={() => setEditingAcceptorAmount(!editingAcceptorAmount)} + > + Edit + </Button> + )} + <Button + type="submit" + color={'indigo'} + className={clsx( + 'whitespace-nowrap drop-shadow-md', + isCreating ? 'disabled' : '' + )} + > + Continue + </Button> + </Row> + <Row className={'text-error'}>{error} </Row> + </form> + )} + {finishedCreating && ( + <> + <Title className="!my-0" text="Challenge Created!" /> + + <div>Share the challenge using the link.</div> + <button + onClick={() => { + copyToClipboard(challengeSlug) + toast('Link copied to clipboard!') + }} + className={'btn btn-outline mb-4 whitespace-nowrap normal-case'} + > + <LinkIcon className={'mr-2 h-5 w-5'} /> + Copy link + </button> + + <QRCode url={challengeSlug} className="self-center" /> + <Row className={'gap-1 text-gray-500'}> + See your other + <SiteLink className={'underline'} href={'/challenges'}> + challenges + </SiteLink> + </Row> + </> + )} + </> + ) +} diff --git a/web/components/contract/contract-card-preview.tsx b/web/components/contract/contract-card-preview.tsx new file mode 100644 index 00000000..06a7f7f6 --- /dev/null +++ b/web/components/contract/contract-card-preview.tsx @@ -0,0 +1,36 @@ +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' + +export const getOpenGraphProps = (contract: Contract) => { + const { + resolution, + question, + creatorName, + creatorUsername, + outcomeType, + creatorAvatarUrl, + description: desc, + } = contract + const probPercent = + outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined + + const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) + + const description = resolution + ? `Resolved ${resolution}. ${stringDesc}` + : probPercent + ? `${probPercent} chance. ${stringDesc}` + : stringDesc + + return { + question, + probability: probPercent, + metadata: contractTextDetails(contract), + creatorName, + creatorUsername, + creatorAvatarUrl, + description, + } +} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 50c5a7e6..28eabb04 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -1,4 +1,4 @@ -import { tradingAllowed } from 'web/lib/firebase/contracts' +import { contractUrl, tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' import { Spacer } from '../layout/spacer' import { ContractProbGraph } from './contract-prob-graph' @@ -8,8 +8,8 @@ import { Linkify } from '../linkify' import clsx from 'clsx' import { - FreeResponseResolutionOrChance, BinaryResolutionOrChance, + FreeResponseResolutionOrChance, NumericResolutionOrExpectation, PseudoNumericResolutionOrExpectation, } from './contract-card' @@ -19,8 +19,13 @@ import { AnswersGraph } from '../answers/answers-graph' import { Contract, CPMMBinaryContract } from 'common/contract' import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' -import { ShareMarket } from '../share-market' import { NumericGraph } from './numeric-graph' +import { CreateChallengeButton } from 'web/components/challenges/create-challenge-button' +import React from 'react' +import { copyToClipboard } from 'web/lib/util/copy' +import toast from 'react-hot-toast' +import { LinkIcon } from '@heroicons/react/outline' +import { CHALLENGES_ENABLED } from 'common/challenge' export const ContractOverview = (props: { contract: Contract @@ -32,8 +37,10 @@ export const ContractOverview = (props: { const user = useUser() const isCreator = user?.id === creatorId + const isBinary = outcomeType === 'BINARY' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + const showChallenge = user && isBinary && !resolution && CHALLENGES_ENABLED return ( <Col className={clsx('mb-6', className)}> @@ -116,13 +123,47 @@ export const ContractOverview = (props: { <AnswersGraph contract={contract} bets={bets} /> )} {outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />} - {(contract.description || isCreator) && <Spacer h={6} />} - {isCreator && <ShareMarket className="px-2" contract={contract} />} + {/* {(contract.description || isCreator) && <Spacer h={6} />} */} <ContractDescription className="px-2" contract={contract} isCreator={isCreator} /> + {/*<Row className="mx-4 mt-4 hidden justify-around sm:block">*/} + {/* {showChallenge && (*/} + {/* <Col className="gap-3">*/} + {/* <div className="text-lg">⚔️ Challenge a friend ⚔️</div>*/} + {/* <CreateChallengeButton user={user} contract={contract} />*/} + {/* </Col>*/} + {/* )}*/} + {/* {isCreator && (*/} + {/* <Col className="gap-3">*/} + {/* <div className="text-lg">Share your market</div>*/} + {/* <ShareMarketButton contract={contract} />*/} + {/* </Col>*/} + {/* )}*/} + {/*</Row>*/} + <Row className="mx-4 mt-6 block justify-around"> + {showChallenge && ( + <Col className="gap-3"> + <CreateChallengeButton user={user} contract={contract} /> + </Col> + )} + {isCreator && ( + <Col className="gap-3"> + <button + onClick={() => { + copyToClipboard(contractUrl(contract)) + toast('Link copied to clipboard!') + }} + className={'btn btn-outline mb-4 whitespace-nowrap normal-case'} + > + <LinkIcon className={'mr-2 h-5 w-5'} /> + Share market + </button> + </Col> + )} + </Row> </Col> ) } diff --git a/web/components/copy-link-button.tsx b/web/components/copy-link-button.tsx index 4ce4140d..f3489f3d 100644 --- a/web/components/copy-link-button.tsx +++ b/web/components/copy-link-button.tsx @@ -2,7 +2,6 @@ import React, { Fragment } from 'react' import { LinkIcon } from '@heroicons/react/outline' import { Menu, Transition } from '@headlessui/react' import clsx from 'clsx' - import { copyToClipboard } from 'web/lib/util/copy' import { ToastClipboard } from 'web/components/toast-clipboard' import { track } from 'web/lib/service/analytics' @@ -14,6 +13,8 @@ export function CopyLinkButton(props: { tracking?: string buttonClassName?: string toastClassName?: string + icon?: React.ComponentType<{ className?: string }> + label?: string }) { const { url, displayUrl, tracking, buttonClassName, toastClassName } = props diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index b1c8f6ee..cd490701 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -26,7 +26,10 @@ export function ContractActivity(props: { const contract = useContractWithPreload(props.contract) ?? props.contract const comments = props.comments - const updatedBets = useBets(contract.id) + const updatedBets = useBets(contract.id, { + filterChallenges: false, + filterRedemptions: true, + }) const bets = (updatedBets ?? props.bets).filter( (bet) => !bet.isRedemption && bet.amount !== 0 ) diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index 408404ba..29645136 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -10,11 +10,14 @@ import { UsersIcon } from '@heroicons/react/solid' import { formatMoney, formatPercent } from 'common/util/format' import { OutcomeLabel } from 'web/components/outcome-label' import { RelativeTimestamp } from 'web/components/relative-timestamp' -import React, { Fragment } from 'react' +import React, { Fragment, useEffect } from 'react' import { uniqBy, partition, sumBy, groupBy } from 'lodash' import { JoinSpans } from 'web/components/join-spans' import { UserLink } from '../user-page' import { formatNumericProbability } from 'common/pseudo-numeric' +import { SiteLink } from 'web/components/site-link' +import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' +import { Challenge } from 'common/challenge' export function FeedBet(props: { contract: Contract @@ -79,7 +82,15 @@ export function BetStatusText(props: { const { outcomeType } = contract const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isFreeResponse = outcomeType === 'FREE_RESPONSE' - const { amount, outcome, createdTime } = bet + const { amount, outcome, createdTime, challengeSlug } = bet + const [challenge, setChallenge] = React.useState<Challenge>() + useEffect(() => { + if (challengeSlug) { + getChallenge(challengeSlug, contract.id).then((c) => { + setChallenge(c) + }) + } + }, [challengeSlug, contract.id]) const bought = amount >= 0 ? 'bought' : 'sold' const outOfTotalAmount = @@ -133,6 +144,14 @@ export function BetStatusText(props: { {fromProb === toProb ? `at ${fromProb}` : `from ${fromProb} to ${toProb}`} + {challengeSlug && ( + <SiteLink + href={challenge ? getChallengeUrl(challenge) : ''} + className={'mx-1'} + > + [challenge] + </SiteLink> + )} </> )} <RelativeTimestamp time={createdTime} /> diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index a051faed..713bc575 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -29,6 +29,7 @@ import { Spacer } from '../layout/spacer' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { PrivateUser } from 'common/user' import { useWindowSize } from 'web/hooks/use-window-size' +import { CHALLENGES_ENABLED } from 'common/challenge' const logout = async () => { // log out, and then reload the page, in case SSR wants to boot them out @@ -60,26 +61,50 @@ function getMoreNavigation(user?: User | null) { } if (!user) { - return [ - { name: 'Charity', href: '/charity' }, - { name: 'Blog', href: 'https://news.manifold.markets' }, - { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, - { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }, - ] + if (CHALLENGES_ENABLED) + return [ + { name: 'Challenges', href: '/challenges' }, + { name: 'Charity', href: '/charity' }, + { name: 'Blog', href: 'https://news.manifold.markets' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }, + ] + else + return [ + { name: 'Charity', href: '/charity' }, + { name: 'Blog', href: 'https://news.manifold.markets' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }, + ] } - return [ - { name: 'Referrals', href: '/referrals' }, - { name: 'Charity', href: '/charity' }, - { name: 'Send M$', href: '/links' }, - { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, - { name: 'About', href: 'https://docs.manifold.markets/$how-to' }, - { - name: 'Sign out', - href: '#', - onClick: logout, - }, - ] + if (CHALLENGES_ENABLED) + return [ + { name: 'Challenges', href: '/challenges' }, + { name: 'Referrals', href: '/referrals' }, + { name: 'Charity', href: '/charity' }, + { name: 'Send M$', href: '/links' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'About', href: 'https://docs.manifold.markets/$how-to' }, + { + name: 'Sign out', + href: '#', + onClick: logout, + }, + ] + else + return [ + { name: 'Referrals', href: '/referrals' }, + { name: 'Charity', href: '/charity' }, + { name: 'Send M$', href: '/links' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'About', href: 'https://docs.manifold.markets/$how-to' }, + { + name: 'Sign out', + href: '#', + onClick: logout, + }, + ] } const signedOutNavigation = [ @@ -119,6 +144,14 @@ function getMoreMobileNav() { return [ ...(IS_PRIVATE_MANIFOLD ? [] + : CHALLENGES_ENABLED + ? [ + { name: 'Challenges', href: '/challenges' }, + { name: 'Referrals', href: '/referrals' }, + { name: 'Charity', href: '/charity' }, + { name: 'Send M$', href: '/links' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + ] : [ { name: 'Referrals', href: '/referrals' }, { name: 'Charity', href: '/charity' }, diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index fa50365b..611a19d1 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -10,8 +10,9 @@ import { PortfolioValueGraph } from './portfolio-value-graph' export const PortfolioValueSection = memo( function PortfolioValueSection(props: { portfolioHistory: PortfolioMetrics[] + disableSelector?: boolean }) { - const { portfolioHistory } = props + const { portfolioHistory, disableSelector } = props const lastPortfolioMetrics = last(portfolioHistory) const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime') @@ -30,7 +31,9 @@ export const PortfolioValueSection = memo( <div> <Row className="gap-8"> <div className="mb-4 w-full"> - <Col> + <Col + className={disableSelector ? 'items-center justify-center' : ''} + > <div className="text-sm text-gray-500">Portfolio value</div> <div className="text-lg"> {formatMoney( @@ -40,16 +43,18 @@ export const PortfolioValueSection = memo( </div> </Col> </div> - <select - className="select select-bordered self-start" - onChange={(e) => { - setPortfolioPeriod(e.target.value as Period) - }} - > - <option value="allTime">{allTimeLabel}</option> - <option value="weekly">7 days</option> - <option value="daily">24 hours</option> - </select> + {!disableSelector && ( + <select + className="select select-bordered self-start" + onChange={(e) => { + setPortfolioPeriod(e.target.value as Period) + }} + > + <option value="allTime">{allTimeLabel}</option> + <option value="weekly">7 days</option> + <option value="daily">24 hours</option> + </select> + )} </Row> <PortfolioValueGraph portfolioHistory={portfolioHistory} diff --git a/web/components/share-market-button.tsx b/web/components/share-market-button.tsx new file mode 100644 index 00000000..ef7b688d --- /dev/null +++ b/web/components/share-market-button.tsx @@ -0,0 +1,18 @@ +import { ENV_CONFIG } from 'common/envs/constants' +import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts' +import { CopyLinkButton } from './copy-link-button' + +export function ShareMarketButton(props: { contract: Contract }) { + const { contract } = props + + const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}` + + return ( + <CopyLinkButton + url={url} + displayUrl={contractUrl(contract)} + buttonClassName="btn-md rounded-l-none" + toastClassName={'-left-28 mt-1'} + /> + ) +} diff --git a/web/components/share-market.tsx b/web/components/share-market.tsx deleted file mode 100644 index be943a34..00000000 --- a/web/components/share-market.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import clsx from 'clsx' - -import { ENV_CONFIG } from 'common/envs/constants' - -import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts' -import { CopyLinkButton } from './copy-link-button' -import { Col } from './layout/col' -import { Row } from './layout/row' - -export function ShareMarket(props: { contract: Contract; className?: string }) { - const { contract, className } = props - - const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}` - - return ( - <Col className={clsx(className, 'gap-3')}> - <div>Share your market</div> - <Row className="mb-6 items-center"> - <CopyLinkButton - url={url} - displayUrl={contractUrl(contract)} - buttonClassName="btn-md rounded-l-none" - toastClassName={'-left-28 mt-1'} - /> - </Row> - </Col> - ) -} diff --git a/web/components/sign-up-prompt.tsx b/web/components/sign-up-prompt.tsx index 0edce22c..8882ccfd 100644 --- a/web/components/sign-up-prompt.tsx +++ b/web/components/sign-up-prompt.tsx @@ -2,16 +2,20 @@ 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' -export function SignUpPrompt() { +export function SignUpPrompt(props: { label?: string; className?: string }) { + const { label, className } = props const user = useUser() return user === null ? ( - <button - className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-indigo-500 to-blue-500 px-10 text-lg font-medium normal-case hover:from-indigo-600 hover:to-blue-600" + <Button onClick={withTracking(firebaseLogin, 'sign up to bet')} + className={className} + size="lg" + color="gradient" > - Sign up to bet! - </button> + {label ?? 'Sign up to bet!'} + </Button> ) : null } diff --git a/web/hooks/use-bets.ts b/web/hooks/use-bets.ts index 68b296cd..38b73dd1 100644 --- a/web/hooks/use-bets.ts +++ b/web/hooks/use-bets.ts @@ -9,12 +9,26 @@ import { } from 'web/lib/firebase/bets' import { LimitBet } from 'common/bet' -export const useBets = (contractId: string) => { +export const useBets = ( + contractId: string, + options?: { filterChallenges: boolean; filterRedemptions: boolean } +) => { const [bets, setBets] = useState<Bet[] | undefined>() useEffect(() => { - if (contractId) return listenForBets(contractId, setBets) - }, [contractId]) + if (contractId) + return listenForBets(contractId, (bets) => { + if (options) + setBets( + bets.filter( + (bet) => + (options.filterChallenges ? !bet.challengeSlug : true) && + (options.filterRedemptions ? !bet.isRedemption : true) + ) + ) + else setBets(bets) + }) + }, [contractId, options]) return bets } diff --git a/web/hooks/use-save-referral.ts b/web/hooks/use-save-referral.ts index 7772f9d2..cc96ec72 100644 --- a/web/hooks/use-save-referral.ts +++ b/web/hooks/use-save-referral.ts @@ -6,7 +6,7 @@ import { User, writeReferralInfo } from 'web/lib/firebase/users' export const useSaveReferral = ( user?: User | null, options?: { - defaultReferrer?: string + defaultReferrerUsername?: string contractId?: string groupId?: string } @@ -18,7 +18,7 @@ export const useSaveReferral = ( referrer?: string } - const referrerOrDefault = referrer || options?.defaultReferrer + const referrerOrDefault = referrer || options?.defaultReferrerUsername if (!user && router.isReady && referrerOrDefault) { writeReferralInfo(referrerOrDefault, { diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index 4c492d6c..d84c7d03 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -2,7 +2,7 @@ import { useContext, useEffect, useState } from 'react' import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { QueryClient } from 'react-query' -import { doc, DocumentData } from 'firebase/firestore' +import { doc, DocumentData, where } from 'firebase/firestore' import { PrivateUser } from 'common/user' import { getUser, diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index 87d94dce..5f250ce7 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -81,6 +81,10 @@ export function createGroup(params: any) { return call(getFunctionUrl('creategroup'), 'POST', params) } +export function acceptChallenge(params: any) { + return call(getFunctionUrl('acceptchallenge'), 'POST', params) +} + export function getCurrentUser(params: any) { return call(getFunctionUrl('getcurrentuser'), 'GET', params) } diff --git a/web/lib/firebase/challenges.ts b/web/lib/firebase/challenges.ts new file mode 100644 index 00000000..d62d5aac --- /dev/null +++ b/web/lib/firebase/challenges.ts @@ -0,0 +1,150 @@ +import { + collectionGroup, + doc, + getDoc, + orderBy, + query, + setDoc, + where, +} from 'firebase/firestore' +import { Challenge } from 'common/challenge' +import { customAlphabet } from 'nanoid' +import { coll, listenForValue, listenForValues } from './utils' +import { useEffect, useState } from 'react' +import { User } from 'common/user' +import { db } from './init' +import { Contract } from 'common/contract' +import { ENV_CONFIG } from 'common/envs/constants' + +export const challenges = (contractId: string) => + coll<Challenge>(`contracts/${contractId}/challenges`) + +export function getChallengeUrl(challenge: Challenge) { + return `https://${ENV_CONFIG.domain}/challenges/${challenge.creatorUsername}/${challenge.contractSlug}/${challenge.slug}` +} +export async function createChallenge(data: { + creator: User + outcome: 'YES' | 'NO' | number + contract: Contract + creatorAmount: number + acceptorAmount: number + expiresTime: number | null + message: string +}) { + const { + creator, + creatorAmount, + expiresTime, + message, + contract, + outcome, + acceptorAmount, + } = data + + // At 100 IDs per hour, using this alphabet and 8 chars, there's a 1% chance of collision in 2 years + // See https://zelark.github.io/nano-id-cc/ + const nanoid = customAlphabet( + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', + 8 + ) + const slug = nanoid() + + if (creatorAmount <= 0 || isNaN(creatorAmount) || !isFinite(creatorAmount)) + return null + + const challenge: Challenge = { + slug, + creatorId: creator.id, + creatorUsername: creator.username, + creatorName: creator.name, + creatorAvatarUrl: creator.avatarUrl, + creatorAmount, + creatorOutcome: outcome.toString(), + creatorOutcomeProb: creatorAmount / (creatorAmount + acceptorAmount), + acceptorOutcome: outcome === 'YES' ? 'NO' : 'YES', + acceptorAmount, + contractSlug: contract.slug, + contractId: contract.id, + contractQuestion: contract.question, + contractCreatorUsername: contract.creatorUsername, + createdTime: Date.now(), + expiresTime, + maxUses: 1, + acceptedByUserIds: [], + acceptances: [], + isResolved: false, + message, + } + + await setDoc(doc(challenges(contract.id), slug), challenge) + return challenge +} + +// TODO: This required an index, make sure to also set up in prod +function listUserChallenges(fromId?: string) { + return query( + collectionGroup(db, 'challenges'), + where('creatorId', '==', fromId), + orderBy('createdTime', 'desc') + ) +} + +function listChallenges() { + return query(collectionGroup(db, 'challenges')) +} + +export const useAcceptedChallenges = () => { + const [links, setLinks] = useState<Challenge[]>([]) + + useEffect(() => { + listenForValues(listChallenges(), (challenges: Challenge[]) => { + setLinks( + challenges + .sort((a: Challenge, b: Challenge) => b.createdTime - a.createdTime) + .filter((challenge) => challenge.acceptedByUserIds.length > 0) + ) + }) + }, []) + + return links +} + +export function listenForChallenge( + slug: string, + contractId: string, + setLinks: (challenge: Challenge | null) => void +) { + return listenForValue<Challenge>(doc(challenges(contractId), slug), setLinks) +} + +export function useChallenge(slug: string, contractId: string | undefined) { + const [challenge, setChallenge] = useState<Challenge | null>() + useEffect(() => { + if (slug && contractId) { + listenForChallenge(slug, contractId, setChallenge) + } + }, [contractId, slug]) + return challenge +} + +export function listenForUserChallenges( + fromId: string | undefined, + setLinks: (links: Challenge[]) => void +) { + return listenForValues<Challenge>(listUserChallenges(fromId), setLinks) +} + +export const useUserChallenges = (fromId: string) => { + const [links, setLinks] = useState<Challenge[]>([]) + + useEffect(() => { + return listenForUserChallenges(fromId, setLinks) + }, [fromId]) + + return links +} + +export const getChallenge = async (slug: string, contractId: string) => { + const challenge = await getDoc(doc(challenges(contractId), slug)) + return challenge.data() as Challenge +} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 9e5de871..3a751c18 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -35,6 +35,13 @@ export function contractPath(contract: Contract) { return `/${contract.creatorUsername}/${contract.slug}` } +export function contractPathWithoutContract( + creatorUsername: string, + slug: string +) { + return `/${creatorUsername}/${slug}` +} + export function homeContractPath(contract: Contract) { return `/home?c=${contract.slug}` } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 58e7c2e8..0da6c994 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -1,18 +1,18 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { ArrowLeftIcon } from '@heroicons/react/outline' +import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash' import { useContractWithPreload } from 'web/hooks/use-contract' import { ContractOverview } from 'web/components/contract/contract-overview' import { BetPanel } from 'web/components/bet-panel' import { Col } from 'web/components/layout/col' -import { useUser } from 'web/hooks/use-user' +import { useUser, useUserById } from 'web/hooks/use-user' import { ResolutionPanel } from 'web/components/resolution-panel' import { Spacer } from 'web/components/layout/spacer' import { Contract, getContractFromSlug, tradingAllowed, - getBinaryProbPercent, } from 'web/lib/firebase/contracts' import { SEO } from 'web/components/SEO' import { Page } from 'web/components/page' @@ -21,26 +21,29 @@ import { Comment, 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' +import { Leaderboard } from 'web/components/leaderboard' +import { resolvedPayout } from 'common/calculate' +import { formatMoney } from 'common/util/format' import { ContractTabs } from 'web/components/contract/contract-tabs' -import { contractTextDetails } from 'web/components/contract/contract-details' import { useWindowSize } from 'web/hooks/use-window-size' import Confetti from 'react-confetti' -import { NumericBetPanel } from '../../components/numeric-bet-panel' -import { NumericResolutionPanel } from '../../components/numeric-resolution-panel' +import { NumericBetPanel } from 'web/components/numeric-bet-panel' +import { NumericResolutionPanel } from 'web/components/numeric-resolution-panel' import { useIsIframe } from 'web/hooks/use-is-iframe' import ContractEmbedPage from '../embed/[username]/[contractSlug]' import { useBets } from 'web/hooks/use-bets' import { CPMMBinaryContract } from 'common/contract' import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' -import { useTipTxns } from 'web/hooks/use-tip-txns' +import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { useLiquidity } from 'web/hooks/use-liquidity' -import { richTextToString } from 'common/util/parse' import { useSaveReferral } from 'web/hooks/use-save-referral' -import { - ContractLeaderboard, - ContractTopTrades, -} from 'web/components/contract/contract-leaderboard' +import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' +import { User } from 'common/user' +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' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -153,7 +156,7 @@ export function ContractPageContent( const ogCardProps = getOpenGraphProps(contract) useSaveReferral(user, { - defaultReferrer: contract.creatorUsername, + defaultReferrerUsername: contract.creatorUsername, contractId: contract.id, }) @@ -208,7 +211,10 @@ export function ContractPageContent( </button> )} - <ContractOverview contract={contract} bets={bets} /> + <ContractOverview + contract={contract} + bets={bets.filter((b) => !b.challengeSlug)} + /> {isNumeric && ( <AlertBox @@ -258,34 +264,125 @@ export function ContractPageContent( ) } -const getOpenGraphProps = (contract: Contract) => { - const { - resolution, - question, - creatorName, - creatorUsername, - outcomeType, - creatorAvatarUrl, - description: desc, - } = contract - const probPercent = - outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined +function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) { + const { contract, bets } = props + const [users, setUsers] = useState<User[]>() - const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) + const { userProfits, top5Ids } = useMemo(() => { + // Create a map of userIds to total profits (including sales) + const openBets = bets.filter((bet) => !bet.isSold && !bet.sale) + const betsByUser = groupBy(openBets, 'userId') - const description = resolution - ? `Resolved ${resolution}. ${stringDesc}` - : probPercent - ? `${probPercent} chance. ${stringDesc}` - : stringDesc + const userProfits = mapValues(betsByUser, (bets) => + sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount) + ) + // Find the 5 users with the most profits + const top5Ids = Object.entries(userProfits) + .sort(([_i1, p1], [_i2, p2]) => p2 - p1) + .filter(([, p]) => p > 0) + .slice(0, 5) + .map(([id]) => id) + return { userProfits, top5Ids } + }, [contract, bets]) - return { - question, - probability: probPercent, - metadata: contractTextDetails(contract), - creatorName, - creatorUsername, - creatorAvatarUrl, - description, - } + useEffect(() => { + if (top5Ids.length > 0) { + listUsers(top5Ids).then((users) => { + const sortedUsers = sortBy(users, (user) => -userProfits[user.id]) + setUsers(sortedUsers) + }) + } + }, [userProfits, top5Ids]) + + return users && users.length > 0 ? ( + <Leaderboard + title="🏅 Top bettors" + users={users || []} + columns={[ + { + header: 'Total profit', + renderCell: (user) => formatMoney(userProfits[user.id] || 0), + }, + ]} + className="mt-12 max-w-sm" + /> + ) : null +} + +function ContractTopTrades(props: { + contract: Contract + bets: Bet[] + comments: Comment[] + tips: CommentTipMap +}) { + const { contract, bets, comments, tips } = props + const commentsById = keyBy(comments, 'id') + const betsById = keyBy(bets, 'id') + + // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit + // Otherwise, we record the profit at resolution time + const profitById: Record<string, number> = {} + for (const bet of bets) { + if (bet.sale) { + const originalBet = betsById[bet.sale.betId] + const profit = bet.sale.amount - originalBet.amount + profitById[bet.id] = profit + profitById[originalBet.id] = profit + } else { + profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount + } + } + + // Now find the betId with the highest profit + const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id + const topBettor = useUserById(betsById[topBetId]?.userId) + + // And also the commentId of the comment with the highest profit + const topCommentId = sortBy( + comments, + (c) => c.betId && -profitById[c.betId] + )[0]?.id + + return ( + <div className="mt-12 max-w-sm"> + {topCommentId && profitById[topCommentId] > 0 && ( + <> + <Title text="💬 Proven correct" className="!mt-0" /> + <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> + <FeedComment + contract={contract} + comment={commentsById[topCommentId]} + tips={tips[topCommentId]} + betsBySameUser={[betsById[topCommentId]]} + truncate={false} + smallAvatar={false} + /> + </div> + <div className="mt-2 text-sm text-gray-500"> + {commentsById[topCommentId].userName} made{' '} + {formatMoney(profitById[topCommentId] || 0)}! + </div> + <Spacer h={16} /> + </> + )} + + {/* If they're the same, only show the comment; otherwise show both */} + {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( + <> + <Title text="💸 Smartest money" className="!mt-0" /> + <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> + <FeedBet + contract={contract} + bet={betsById[topBetId]} + hideOutcome={false} + smallAvatar={false} + /> + </div> + <div className="mt-2 text-sm text-gray-500"> + {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! + </div> + </> + )} + </div> + ) } diff --git a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx new file mode 100644 index 00000000..baf68e2a --- /dev/null +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -0,0 +1,403 @@ +import React, { useEffect, useState } from 'react' +import Confetti from 'react-confetti' + +import { fromPropz, usePropz } from 'web/hooks/use-propz' +import { contractPath, getContractFromSlug } from 'web/lib/firebase/contracts' +import { useContractWithPreload } from 'web/hooks/use-contract' +import { DOMAIN } from 'common/envs/constants' +import { Col } from 'web/components/layout/col' +import { SiteLink } from 'web/components/site-link' +import { Spacer } from 'web/components/layout/spacer' +import { Row } from 'web/components/layout/row' +import { Challenge } from 'common/challenge' +import { + getChallenge, + getChallengeUrl, + useChallenge, +} from 'web/lib/firebase/challenges' +import { getUserByUsername } from 'web/lib/firebase/users' +import { User } from 'common/user' +import { Page } from 'web/components/page' +import { useUser, useUserById } from 'web/hooks/use-user' +import { AcceptChallengeButton } from 'web/components/challenges/accept-challenge-button' +import { Avatar } from 'web/components/avatar' +import { UserLink } from 'web/components/user-page' +import { BinaryOutcomeLabel } from 'web/components/outcome-label' +import { formatMoney } from 'common/util/format' +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' + +export const getStaticProps = fromPropz(getStaticPropz) + +export async function getStaticPropz(props: { + params: { username: string; contractSlug: string; challengeSlug: string } +}) { + const { username, contractSlug, challengeSlug } = props.params + const contract = (await getContractFromSlug(contractSlug)) || null + const user = (await getUserByUsername(username)) || null + const bets = contract?.id ? await listAllBets(contract.id) : [] + const challenge = contract?.id + ? await getChallenge(challengeSlug, contract.id) + : null + + return { + props: { + contract, + user, + slug: contractSlug, + challengeSlug, + bets, + challenge, + }, + + revalidate: 60, // regenerate after a minute + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function ChallengePage(props: { + contract: BinaryContract | null + user: User + slug: string + bets: Bet[] + challenge: Challenge | null + challengeSlug: string +}) { + props = usePropz(props, getStaticPropz) ?? { + contract: null, + user: null, + challengeSlug: '', + bets: [], + challenge: null, + slug: '', + } + const contract = (useContractWithPreload(props.contract) ?? + props.contract) as BinaryContract + + const challenge = + useChallenge(props.challengeSlug, contract?.id) ?? props.challenge + + const { user, bets } = props + const currentUser = useUser() + + useSaveReferral(currentUser, { + defaultReferrerUsername: challenge?.creatorUsername, + }) + + if (!contract || !challenge) return <Custom404 /> + + const ogCardProps = getOpenGraphProps(contract) + ogCardProps.creatorUsername = challenge.creatorUsername + ogCardProps.creatorName = challenge.creatorName + ogCardProps.creatorAvatarUrl = challenge.creatorAvatarUrl + + return ( + <Page> + <SEO + title={ogCardProps.question} + description={ogCardProps.description} + url={getChallengeUrl(challenge).replace('https://', '')} + ogCardProps={ogCardProps} + challenge={challenge} + /> + {challenge.acceptances.length >= challenge.maxUses ? ( + <ClosedChallengeContent + contract={contract} + challenge={challenge} + creator={user} + /> + ) : ( + <OpenChallengeContent + user={currentUser} + contract={contract} + challenge={challenge} + creator={user} + bets={bets} + /> + )} + + <FAQ /> + </Page> + ) +} + +function FAQ() { + const [toggleWhatIsThis, setToggleWhatIsThis] = useState(false) + const [toggleWhatIsMana, setToggleWhatIsMana] = useState(false) + return ( + <Col className={'items-center gap-4 p-2 md:p-6 lg:items-start'}> + <Row className={'text-xl text-indigo-700'}>FAQ</Row> + <Row className={'text-lg text-indigo-700'}> + <span + className={'mx-2 cursor-pointer'} + onClick={() => setToggleWhatIsThis(!toggleWhatIsThis)} + > + {toggleWhatIsThis ? '-' : '+'} + What is this? + </span> + </Row> + {toggleWhatIsThis && ( + <Row className={'mx-4'}> + <span> + This is a challenge bet, or a bet offered from one person to another + that is only realized if both parties agree. You can agree to the + challenge (if it's open) or create your own from a market page. See + more markets{' '} + <SiteLink className={'font-bold'} href={'/home'}> + here. + </SiteLink> + </span> + </Row> + )} + <Row className={'text-lg text-indigo-700'}> + <span + className={'mx-2 cursor-pointer'} + onClick={() => setToggleWhatIsMana(!toggleWhatIsMana)} + > + {toggleWhatIsMana ? '-' : '+'} + What is M$? + </span> + </Row> + {toggleWhatIsMana && ( + <Row className={'mx-4'}> + Mana (M$) is the play-money used by our platform to keep track of your + bets. It's completely free for you and your friends to get started! + </Row> + )} + </Col> + ) +} + +function ClosedChallengeContent(props: { + contract: BinaryContract + challenge: Challenge + creator: User +}) { + const { contract, challenge, creator } = props + const { resolution, question } = contract + const { + acceptances, + creatorAmount, + creatorOutcome, + acceptorOutcome, + acceptorAmount, + } = challenge + + const user = useUserById(acceptances[0].userId) + + const [showConfetti, setShowConfetti] = useState(false) + const { width, height } = useWindowSize() + useEffect(() => { + if (acceptances.length === 0) return + if (acceptances[0].createdTime > Date.now() - 1000 * 60) + setShowConfetti(true) + }, [acceptances]) + + const creatorWon = resolution === creatorOutcome + + const href = `https://${DOMAIN}${contractPath(contract)}` + + if (!user) return <LoadingIndicator /> + + const winner = (creatorWon ? creator : user).name + + return ( + <> + {showConfetti && ( + <Confetti + width={width ?? 500} + height={height ?? 500} + confettiSource={{ + x: ((width ?? 500) - 200) / 2, + y: 0, + w: 200, + h: 0, + }} + recycle={false} + initialVelocityY={{ min: 1, max: 3 }} + numberOfPieces={200} + /> + )} + <Col className=" w-full items-center justify-center rounded border-0 border-gray-100 bg-white py-6 pl-1 pr-2 sm:px-2 md:px-6 md:py-8 "> + {resolution ? ( + <> + <Title className="!mt-0" text={`🥇 ${winner} wins the bet 🥇`} /> + <SiteLink href={href} className={'mb-8 text-xl'}> + {question} + </SiteLink> + </> + ) : ( + <SiteLink href={href} className={'mb-8'}> + <span className="text-3xl text-indigo-700">{question}</span> + </SiteLink> + )} + <Col + className={'w-full content-between justify-between gap-1 sm:flex-row'} + > + <UserBetColumn + challenger={creator} + outcome={creatorOutcome} + amount={creatorAmount} + isResolved={!!resolution} + /> + + <Col className="items-center justify-center py-8 text-2xl sm:text-4xl"> + VS + </Col> + + <UserBetColumn + challenger={user?.id === creator.id ? undefined : user} + outcome={acceptorOutcome} + amount={acceptorAmount} + isResolved={!!resolution} + /> + </Col> + <Spacer h={3} /> + + {/* <Row className="mt-8 items-center"> + <span className='mr-4'>Share</span> <CopyLinkButton url={window.location.href} /> + </Row> */} + </Col> + </> + ) +} + +function OpenChallengeContent(props: { + contract: BinaryContract + challenge: Challenge + creator: User + user: User | null | undefined + bets: Bet[] +}) { + const { contract, challenge, creator, user } = props + const { question } = contract + const { + creatorAmount, + creatorId, + creatorOutcome, + acceptorAmount, + acceptorOutcome, + } = challenge + + const href = `https://${DOMAIN}${contractPath(contract)}` + + return ( + <Col className="items-center"> + <Col className="h-full items-center justify-center rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md"> + <SiteLink href={href} className={'mb-8'}> + <span className="text-3xl text-indigo-700">{question}</span> + </SiteLink> + + <Col + className={ + 'h-full max-h-[50vh] w-full content-between justify-between gap-1 sm:flex-row' + } + > + <UserBetColumn + challenger={creator} + outcome={creatorOutcome} + amount={creatorAmount} + /> + + <Col className="items-center justify-center py-4 text-2xl sm:py-8 sm:text-4xl"> + VS + </Col> + + <UserBetColumn + challenger={user?.id === creatorId ? undefined : user} + outcome={acceptorOutcome} + amount={acceptorAmount} + /> + </Col> + + <Spacer h={3} /> + <Row className={'my-4 text-center text-gray-500'}> + <span> + {`${creator.name} will bet ${formatMoney( + creatorAmount + )} on ${creatorOutcome} if you bet ${formatMoney( + acceptorAmount + )} on ${acceptorOutcome}. Whoever is right will get `} + <span className="mr-1 font-bold "> + {formatMoney(creatorAmount + acceptorAmount)} + </span> + total. + </span> + </Row> + + <Row className="my-4 w-full items-center justify-center"> + <AcceptChallengeButton + user={user} + contract={contract} + challenge={challenge} + /> + </Row> + </Col> + </Col> + ) +} + +const userCol = (challenger: User) => ( + <Col className={'mb-2 w-full items-center justify-center gap-2'}> + <UserLink + className={'text-2xl'} + name={challenger.name} + username={challenger.username} + /> + <Avatar + size={24} + avatarUrl={challenger.avatarUrl} + username={challenger.username} + /> + </Col> +) + +function UserBetColumn(props: { + challenger: User | null | undefined + outcome: string + amount: number + isResolved?: boolean +}) { + const { challenger, outcome, amount, isResolved } = props + + return ( + <Col className="w-full items-start justify-center gap-1"> + {challenger ? ( + userCol(challenger) + ) : ( + <Col className={'mb-2 w-full items-center justify-center gap-2'}> + <span className={'text-2xl'}>You</span> + <Avatar + className={'h-[7.25rem] w-[7.25rem]'} + avatarUrl={undefined} + username={undefined} + /> + </Col> + )} + <Row className={'w-full items-center justify-center'}> + <span className={'text-lg'}> + {isResolved ? 'had bet' : challenger ? '' : ''} + </span> + </Row> + <Row className={'w-full items-center justify-center'}> + <span className={'text-lg'}> + <span className="bold text-2xl">{formatMoney(amount)}</span> + {' on '} + <span className="bold text-2xl"> + <BinaryOutcomeLabel outcome={outcome as any} /> + </span>{' '} + </span> + </Row> + </Col> + ) +} diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx new file mode 100644 index 00000000..40e00084 --- /dev/null +++ b/web/pages/challenges/index.tsx @@ -0,0 +1,300 @@ +import clsx from 'clsx' +import React from 'react' +import { formatMoney } from 'common/util/format' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { useUser } from 'web/hooks/use-user' +import { fromNow } from 'web/lib/util/time' + +import dayjs from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import { + getChallengeUrl, + useAcceptedChallenges, + useUserChallenges, +} from 'web/lib/firebase/challenges' +import { Challenge } from 'common/challenge' +import { Tabs } from 'web/components/layout/tabs' +import { SiteLink } from 'web/components/site-link' +import { UserLink } from 'web/components/user-page' +import { Avatar } from 'web/components/avatar' +import Router from 'next/router' +import { contractPathWithoutContract } from 'web/lib/firebase/contracts' +import { Button } from 'web/components/button' +import { ClipboardCopyIcon, QrcodeIcon } from '@heroicons/react/outline' +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' + +dayjs.extend(customParseFormat) +const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate' +const amountClass = columnClass + ' max-w-[75px] font-bold' + +export default function ChallengesListPage() { + const user = useUser() + const userChallenges = useUserChallenges(user?.id ?? '') + const challenges = useAcceptedChallenges() + + const userTab = user + ? [ + { + content: <YourChallengesTable links={userChallenges} />, + title: 'Your Challenges', + }, + ] + : [] + + const publicTab = [ + { + content: <PublicChallengesTable links={challenges} />, + title: 'Public Challenges', + }, + ] + + return ( + <Page> + <SEO + title="Challenges" + description="Challenge your friends to a bet!" + url="/send" + /> + + <Col className="w-full px-8"> + <Row className="items-center justify-between"> + <Title text="Challenges" /> + </Row> + <p>Find or create a question to challenge someone to a bet.</p> + + <Tabs tabs={[...userTab, ...publicTab]} /> + </Col> + </Page> + ) +} + +function YourChallengesTable(props: { links: Challenge[] }) { + const { links } = props + return links.length == 0 ? ( + <p>There aren't currently any challenges.</p> + ) : ( + <div className="overflow-scroll"> + <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> + <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> + <tr> + <th className={amountClass}>Amount</th> + <th + className={clsx( + columnClass, + 'text-center sm:pl-10 sm:text-start' + )} + > + Link + </th> + <th className={columnClass}>Accepted By</th> + </tr> + </thead> + <tbody className={'divide-y divide-gray-200 bg-white'}> + {links.map((link) => ( + <YourLinkSummaryRow challenge={link} /> + ))} + </tbody> + </table> + </div> + ) +} + +function YourLinkSummaryRow(props: { challenge: Challenge }) { + const { challenge } = props + const { acceptances } = challenge + const [open, setOpen] = React.useState(false) + const className = clsx( + 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white' + ) + return ( + <> + <Modal open={open} setOpen={setOpen} size={'sm'}> + <Col + className={ + 'items-center justify-center gap-4 rounded-md bg-white p-8 py-8 ' + } + > + <span className={'mb-4 text-center text-xl text-indigo-700'}> + Have your friend scan this to accept the challenge! + </span> + <QRCode url={getChallengeUrl(challenge)} /> + </Col> + </Modal> + <tr id={challenge.slug} key={challenge.slug} className={className}> + <td className={amountClass}> + <SiteLink href={getChallengeUrl(challenge)}> + {formatMoney(challenge.creatorAmount)} + </SiteLink> + </td> + <td + className={clsx( + columnClass, + 'text-center sm:max-w-[200px] sm:text-start' + )} + > + <Row className="items-center gap-2"> + <Button + color="gray-white" + size="xs" + onClick={() => { + copyToClipboard(getChallengeUrl(challenge)) + toast('Link copied to clipboard!') + }} + > + <ClipboardCopyIcon className={'h-5 w-5 sm:h-4 sm:w-4'} /> + </Button> + <Button + color="gray-white" + size="xs" + onClick={() => { + setOpen(true) + }} + > + <QrcodeIcon className="h-5 w-5 sm:h-4 sm:w-4" /> + </Button> + <SiteLink + href={getChallengeUrl(challenge)} + className={'mx-1 mb-1 hidden sm:inline-block'} + > + {`...${challenge.contractSlug}/${challenge.slug}`} + </SiteLink> + </Row> + </td> + + <td className={columnClass}> + <Row className={'items-center justify-start gap-1'}> + {acceptances.length > 0 ? ( + <> + <Avatar + username={acceptances[0].userUsername} + avatarUrl={acceptances[0].userAvatarUrl} + size={'sm'} + /> + <UserLink + name={acceptances[0].userName} + username={acceptances[0].userUsername} + /> + </> + ) : ( + <span> + No one - + {challenge.expiresTime && + ` (expires ${fromNow(challenge.expiresTime)})`} + </span> + )} + </Row> + </td> + </tr> + </> + ) +} + +function PublicChallengesTable(props: { links: Challenge[] }) { + const { links } = props + return links.length == 0 ? ( + <p>There aren't currently any challenges.</p> + ) : ( + <div className="overflow-scroll"> + <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> + <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> + <tr> + <th className={amountClass}>Amount</th> + <th className={columnClass}>Creator</th> + <th className={columnClass}>Acceptor</th> + <th className={columnClass}>Market</th> + </tr> + </thead> + <tbody className={'divide-y divide-gray-200 bg-white'}> + {links.map((link) => ( + <PublicLinkSummaryRow challenge={link} /> + ))} + </tbody> + </table> + </div> + ) +} + +function PublicLinkSummaryRow(props: { challenge: Challenge }) { + const { challenge } = props + const { + acceptances, + creatorUsername, + creatorName, + creatorAvatarUrl, + contractCreatorUsername, + contractQuestion, + contractSlug, + } = challenge + + const className = clsx( + 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white' + ) + return ( + <tr + id={challenge.slug + '-public'} + key={challenge.slug + '-public'} + className={className} + onClick={() => Router.push(getChallengeUrl(challenge))} + > + <td className={amountClass}> + <SiteLink href={getChallengeUrl(challenge)}> + {formatMoney(challenge.creatorAmount)} + </SiteLink> + </td> + + <td className={clsx(columnClass)}> + <Row className={'items-center justify-start gap-1'}> + <Avatar + username={creatorUsername} + avatarUrl={creatorAvatarUrl} + size={'sm'} + noLink={true} + /> + <UserLink name={creatorName} username={creatorUsername} /> + </Row> + </td> + + <td className={clsx(columnClass)}> + <Row className={'items-center justify-start gap-1'}> + {acceptances.length > 0 ? ( + <> + <Avatar + username={acceptances[0].userUsername} + avatarUrl={acceptances[0].userAvatarUrl} + size={'sm'} + noLink={true} + /> + <UserLink + name={acceptances[0].userName} + username={acceptances[0].userUsername} + /> + </> + ) : ( + <span> + No one - + {challenge.expiresTime && + ` (expires ${fromNow(challenge.expiresTime)})`} + </span> + )} + </Row> + </td> + <td className={clsx(columnClass, 'font-bold')}> + <SiteLink + href={contractPathWithoutContract( + contractCreatorUsername, + contractSlug + )} + > + {contractQuestion} + </SiteLink> + </td> + </tr> + ) +} diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 57189c0c..d38c6e5b 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -21,8 +21,11 @@ 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, getContractFromSlug } from 'web/lib/firebase/contracts' -import { tradingAllowed } from 'web/lib/firebase/contracts' +import { + contractPath, + getContractFromSlug, + tradingAllowed, +} from 'web/lib/firebase/contracts' import Custom404 from '../../404' export const getStaticProps = fromPropz(getStaticPropz) @@ -76,7 +79,7 @@ export default function ContractEmbedPage(props: { return <ContractEmbed contract={contract} bets={bets} /> } -function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { +export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props const { question, outcomeType } = contract diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 642a2afd..b96d6436 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -160,7 +160,7 @@ export default function GroupPage(props: { const privateUser = usePrivateUser(user?.id) useSaveReferral(user, { - defaultReferrer: creator.username, + defaultReferrerUsername: creator.username, groupId: group?.id, }) diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index c7457f27..d2b12065 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -91,5 +91,5 @@ const useReferral = (user: User | undefined | null, manalink?: Manalink) => { if (manalink?.fromId) getUser(manalink.fromId).then(setCreator) }, [manalink]) - useSaveReferral(user, { defaultReferrer: creator?.username }) + useSaveReferral(user, { defaultReferrerUsername: creator?.username }) } diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 9f076c41..625c7c17 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -811,6 +811,7 @@ function getSourceUrl(notification: Notification) { 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( sourceId ?? '', @@ -913,6 +914,15 @@ function NotificationTextLabel(props: { <span>of your limit order was filled</span> </> ) + } else if (sourceType === 'challenge' && sourceText) { + return ( + <> + <span> for </span> + <span className="text-primary"> + {formatMoney(parseInt(sourceText))} + </span> + </> + ) } return ( <div className={className ? className : 'line-clamp-4 whitespace-pre-line'}> @@ -967,6 +977,9 @@ function getReasonForShowingNotification( case 'bet': reasonText = 'bet against you' break + case 'challenge': + reasonText = 'accepted your challenge' + break default: reasonText = '' } From c93f9c54831eb7d432208f21b309a22640b0bed6 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 4 Aug 2022 15:58:48 -0600 Subject: [PATCH 5/7] See challenges you've accepted too --- web/lib/firebase/challenges.ts | 4 ++-- web/pages/challenges/index.tsx | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/web/lib/firebase/challenges.ts b/web/lib/firebase/challenges.ts index d62d5aac..89da7f80 100644 --- a/web/lib/firebase/challenges.ts +++ b/web/lib/firebase/challenges.ts @@ -134,11 +134,11 @@ export function listenForUserChallenges( return listenForValues<Challenge>(listUserChallenges(fromId), setLinks) } -export const useUserChallenges = (fromId: string) => { +export const useUserChallenges = (fromId?: string) => { const [links, setLinks] = useState<Challenge[]>([]) useEffect(() => { - return listenForUserChallenges(fromId, setLinks) + if (fromId) return listenForUserChallenges(fromId, setLinks) }, [fromId]) return links diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx index 40e00084..7c68f0bd 100644 --- a/web/pages/challenges/index.tsx +++ b/web/pages/challenges/index.tsx @@ -36,8 +36,12 @@ const amountClass = columnClass + ' max-w-[75px] font-bold' export default function ChallengesListPage() { const user = useUser() - const userChallenges = useUserChallenges(user?.id ?? '') const challenges = useAcceptedChallenges() + const userChallenges = useUserChallenges(user?.id) + .concat( + user ? challenges.filter((c) => c.acceptances[0].userId === user.id) : [] + ) + .sort((a, b) => b.createdTime - a.createdTime) const userTab = user ? [ From 912ccad53053db9647a49ab0310c42d2f874db3d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 4 Aug 2022 16:09:33 -0600 Subject: [PATCH 6/7] Remove max height --- .../challenges/[username]/[contractSlug]/[challengeSlug].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx index baf68e2a..0df5b7d7 100644 --- a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -300,7 +300,7 @@ function OpenChallengeContent(props: { <Col className={ - 'h-full max-h-[50vh] w-full content-between justify-between gap-1 sm:flex-row' + ' w-full content-between justify-between gap-1 sm:flex-row' } > <UserBetColumn From edae709f5f25c192e386dded4838182684c9e6d9 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 4 Aug 2022 15:35:55 -0700 Subject: [PATCH 7/7] Notify mentioned users on market publish (#683) * Add function to parse at mentions * Notify mentioned users on market create - refactor createNotification to accept list of recipients' ids --- common/util/parse.ts | 10 +++ functions/src/create-notification.ts | 66 ++++++++++--------- .../src/on-create-comment-on-contract.ts | 5 +- functions/src/on-create-contract.ts | 9 ++- functions/src/on-create-group.ts | 28 ++++---- functions/src/on-follow-user.ts | 2 +- 6 files changed, 68 insertions(+), 52 deletions(-) diff --git a/common/util/parse.ts b/common/util/parse.ts index cacd0862..f07e4097 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -22,6 +22,7 @@ import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' +import { uniq } from 'lodash' export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi @@ -61,6 +62,15 @@ const checkAgainstQuery = (query: string, corpus: string) => export const searchInAny = (query: string, ...fields: string[]) => fields.some((field) => checkAgainstQuery(query, field)) +/** @return user ids of all \@mentions */ +export function parseMentions(data: JSONContent): string[] { + const mentions = data.content?.flatMap(parseMentions) ?? [] //dfs + if (data.type === 'mention' && data.attrs) { + mentions.push(data.attrs.id as string) + } + return uniq(mentions) +} + // can't just do [StarterKit, Image...] because it doesn't work with cjs imports export const exhibitExts = [ Blockquote, diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 83568535..e16920f7 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -33,7 +33,7 @@ export const createNotification = async ( miscData?: { contract?: Contract relatedSourceType?: notification_source_types - relatedUserId?: string + recipients?: string[] slug?: string title?: string } @@ -41,7 +41,7 @@ export const createNotification = async ( const { contract: sourceContract, relatedSourceType, - relatedUserId, + recipients, slug, title, } = miscData ?? {} @@ -128,7 +128,7 @@ export const createNotification = async ( }) } - const notifyRepliedUsers = async ( + const notifyRepliedUser = ( userToReasonTexts: user_to_reason_texts, relatedUserId: string, relatedSourceType: notification_source_types @@ -145,7 +145,7 @@ export const createNotification = async ( } } - const notifyFollowedUser = async ( + const notifyFollowedUser = ( userToReasonTexts: user_to_reason_texts, followedUserId: string ) => { @@ -155,21 +155,24 @@ export const createNotification = async ( } } - const notifyTaggedUsers = async ( - userToReasonTexts: user_to_reason_texts, - sourceText: string - ) => { - const taggedUsers = sourceText.match(/@\w+/g) - if (!taggedUsers) return - // await all get tagged users: - const users = await Promise.all( - taggedUsers.map(async (username) => { - return await getUserByUsername(username.slice(1)) - }) + /** @deprecated parse from rich text instead */ + const parseMentions = async (source: string) => { + const mentions = source.match(/@\w+/g) + if (!mentions) return [] + return Promise.all( + mentions.map( + async (username) => (await getUserByUsername(username.slice(1)))?.id + ) ) - users.forEach((taggedUser) => { - if (taggedUser && shouldGetNotification(taggedUser.id, userToReasonTexts)) - userToReasonTexts[taggedUser.id] = { + } + + const notifyTaggedUsers = ( + userToReasonTexts: user_to_reason_texts, + userIds: (string | undefined)[] + ) => { + userIds.forEach((id) => { + if (id && shouldGetNotification(id, userToReasonTexts)) + userToReasonTexts[id] = { reason: 'tagged_user', } }) @@ -254,7 +257,7 @@ export const createNotification = async ( }) } - const notifyUserAddedToGroup = async ( + const notifyUserAddedToGroup = ( userToReasonTexts: user_to_reason_texts, relatedUserId: string ) => { @@ -276,11 +279,14 @@ export const createNotification = async ( const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. - if (sourceType === 'follow' && relatedUserId) { - await notifyFollowedUser(userToReasonTexts, relatedUserId) - } else if (sourceType === 'group' && relatedUserId) { - if (sourceUpdateType === 'created') - await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) + if (sourceType === 'follow' && recipients?.[0]) { + notifyFollowedUser(userToReasonTexts, recipients[0]) + } else if ( + sourceType === 'group' && + sourceUpdateType === 'created' && + recipients + ) { + recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) } // The following functions need sourceContract to be defined. @@ -293,13 +299,10 @@ export const createNotification = async ( (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) ) { if (sourceType === 'comment') { - if (relatedUserId && relatedSourceType) - await notifyRepliedUsers( - userToReasonTexts, - relatedUserId, - relatedSourceType - ) - if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText) + if (recipients?.[0] && relatedSourceType) + notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) + if (sourceText) + notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText)) } await notifyContractCreator(userToReasonTexts, sourceContract) await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) @@ -308,6 +311,7 @@ export const createNotification = async ( await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) } else if (sourceType === 'contract' && sourceUpdateType === 'created') { await notifyUsersFollowers(userToReasonTexts) + notifyTaggedUsers(userToReasonTexts, recipients ?? []) } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { await notifyContractCreator(userToReasonTexts, sourceContract, { force: true, diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 8d841ac0..4719fd08 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -68,9 +68,10 @@ export const onCreateCommentOnContract = functions ? 'answer' : undefined - const relatedUserId = comment.replyToCommentId + const repliedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId + const recipients = repliedUserId ? [repliedUserId] : [] await createNotification( comment.id, @@ -79,7 +80,7 @@ export const onCreateCommentOnContract = functions commentCreator, eventId, comment.text, - { contract, relatedSourceType, relatedUserId } + { contract, relatedSourceType, recipients } ) const recipientUserIds = uniq([ diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index a43beda7..6b57a9a0 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' import { createNotification } from './create-notification' import { Contract } from '../../common/contract' -import { richTextToString } from '../../common/util/parse' +import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' export const onCreateContract = functions.firestore @@ -14,13 +14,16 @@ export const onCreateContract = functions.firestore const contractCreator = await getUser(contract.creatorId) if (!contractCreator) throw new Error('Could not find contract creator') + const desc = contract.description as JSONContent + const mentioned = parseMentions(desc) + await createNotification( contract.id, 'contract', 'created', contractCreator, eventId, - richTextToString(contract.description as JSONContent), - { contract } + richTextToString(desc), + { contract, recipients: mentioned } ) }) diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts index 47618d7a..5209788d 100644 --- a/functions/src/on-create-group.ts +++ b/functions/src/on-create-group.ts @@ -12,19 +12,17 @@ export const onCreateGroup = functions.firestore const groupCreator = await getUser(group.creatorId) if (!groupCreator) throw new Error('Could not find group creator') // create notifications for all members of the group - for (const memberId of group.memberIds) { - await createNotification( - group.id, - 'group', - 'created', - groupCreator, - eventId, - group.about, - { - relatedUserId: memberId, - slug: group.slug, - title: group.name, - } - ) - } + await createNotification( + group.id, + 'group', + 'created', + groupCreator, + eventId, + group.about, + { + recipients: group.memberIds, + slug: group.slug, + title: group.name, + } + ) }) diff --git a/functions/src/on-follow-user.ts b/functions/src/on-follow-user.ts index 9a6e6dce..52042345 100644 --- a/functions/src/on-follow-user.ts +++ b/functions/src/on-follow-user.ts @@ -30,7 +30,7 @@ export const onFollowUser = functions.firestore followingUser, eventId, '', - { relatedUserId: follow.userId } + { recipients: [follow.userId] } ) })
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

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

+
+
+
+
+ +
+
+ + + + diff --git a/functions/src/emails.ts b/functions/src/emails.ts index a29f982c..b7469e9f 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -165,7 +165,6 @@ export const sendWelcomeEmail = async ( ) } -// TODO: use manalinks to give out M$500 export const sendOneWeekBonusEmail = async ( user: User, privateUser: PrivateUser @@ -185,12 +184,12 @@ export const sendOneWeekBonusEmail = async ( await sendTemplateEmail( privateUser.email, - 'Manifold one week anniversary gift', + 'Manifold Markets one week anniversary gift', 'one-week', { name: firstName, unsubscribeLink, - manalink: '', // TODO + manalink: 'https://manifold.markets/link/lj4JbBvE', }, { from: 'David from Manifold ', diff --git a/functions/src/index.ts b/functions/src/index.ts index b8f3eedb..76e54f1c 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -27,6 +27,25 @@ export * from './on-delete-group' export * from './score-contracts' // v2 +export * from './health' +export * from './transact' +export * from './change-user-info' +export * from './create-user' +export * from './create-answer' +export * from './place-bet' +export * from './cancel-bet' +export * from './sell-bet' +export * from './sell-shares' +export * from './claim-manalink' +export * from './create-contract' +export * from './add-liquidity' +export * from './withdraw-liquidity' +export * from './create-group' +export * from './resolve-market' +export * from './unsubscribe' +export * from './stripe' +export * from './mana-bonus-email' + import { health } from './health' import { transact } from './transact' import { changeuserinfo } from './change-user-info' diff --git a/functions/src/mana-bonus-email.ts b/functions/src/mana-bonus-email.ts new file mode 100644 index 00000000..29a7e6e0 --- /dev/null +++ b/functions/src/mana-bonus-email.ts @@ -0,0 +1,42 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import * as dayjs from 'dayjs' + +import { getPrivateUser } from './utils' +import { sendOneWeekBonusEmail } from './emails' +import { User } from 'common/user' + +export const manabonusemail = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .pubsub.schedule('0 9 * * 1-7') + .onRun(async () => { + await sendOneWeekEmails() + }) + +const firestore = admin.firestore() + +async function sendOneWeekEmails() { + const oneWeekAgo = dayjs().subtract(1, 'week').valueOf() + const twoWeekAgo = dayjs().subtract(2, 'weeks').valueOf() + + const userDocs = await firestore + .collection('users') + .where('createdTime', '<=', oneWeekAgo) + .get() + + for (const user of userDocs.docs.map((d) => d.data() as User)) { + if (user.createdTime < twoWeekAgo) continue + + const privateUser = await getPrivateUser(user.id) + if (!privateUser || privateUser.manaBonusEmailSent) continue + + await firestore + .collection('private-users') + .doc(user.id) + .update({ manaBonusEmailSent: true }) + + console.log('sending m$ bonus email to', user.username) + await sendOneWeekBonusEmail(user, privateUser) + return + } +} diff --git a/functions/src/send-email.ts b/functions/src/send-email.ts index f97234f6..d081997f 100644 --- a/functions/src/send-email.ts +++ b/functions/src/send-email.ts @@ -26,9 +26,10 @@ export const sendTemplateEmail = ( subject: string, templateId: string, templateData: Record, - options?: { from: string } + options?: Partial ) => { - const data = { + const data: mailgun.messages.SendTemplateData = { + ...options, from: options?.from ?? 'Manifold Markets ', to, subject, @@ -36,6 +37,7 @@ export const sendTemplateEmail = ( 'h:X-Mailgun-Variables': JSON.stringify(templateData), } const mg = initMailgun() + return mg.messages().send(data, (error) => { if (error) console.log('Error sending email', error) else console.log('Sent template email', templateId, to, subject) diff --git a/yarn.lock b/yarn.lock index 9334b737..bbf8d3ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5144,6 +5144,11 @@ dayjs@1.10.7: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468" integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig== +dayjs@1.11.4: + version "1.11.4" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.4.tgz#3b3c10ca378140d8917e06ebc13a4922af4f433e" + integrity sha512-Zj/lPM5hOvQ1Bf7uAvewDaUcsJoI6JmNqmHhHl3nyumwe0XHwt8sWdOVAPACJzCebL8gQCi+K49w7iKWnGwX9g== + debug@2, debug@2.6.9, debug@^2.6.0, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" From 798253f887fa6a946500f434ca7207d1d61ea00c Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 4 Aug 2022 15:27:02 -0600 Subject: [PATCH 4/7] Challenge Bets (#679) * Challenge bets * Store avatar url * Fix before and after probs * Check balance before creation * Calculate winning shares * pretty * Change winning value * Set shares to equal each other * Fix share challenge link * pretty * remove lib refs * Probability of bet is set to market * Remove peer pill * Cleanup * Button on contract page * don't show challenge if not binary or if resolved * challenge button (WIP) * fix accept challenge: don't change pool/probability * Opengraph preview [WIP] * elim lib * Edit og card props * Change challenge text * New card gen attempt * Get challenge on server * challenge button styling * Use env domain * Remove other window ref * Use challenge creator as avatar * Remove user name * Remove s from property, replace prob with outcome * challenge form * share text * Add in challenge parts to template and url * Challenge url params optional * Add challenge params to parse request * Parse please * Don't remove prob * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Add to readme about how to dev og-image * Add emojis * button: gradient background, 2xl size * beautify accept bet screen * update question button * Add separate challenge template * Accepted challenge sharing card, fix accept bet call * accept challenge button * challenge winner page * create challenge screen * Your outcome/cost=> acceptorOutcome/cost * New create challenge panel * Fix main merge * Add challenge slug to bet and filter by it * Center title * Add helper text * Add FAQ section * Lint * Columnize the user areas in preview link too * Absolutely position * Spacing * Orientation * Restyle challenges list, cache contract name * Make copying easy on mobile * Link spacing * Fix spacing * qr codes! * put your challenges first * eslint * Changes to contract buttons and create challenge modal * Change titles around for current bet * Add back in contract title after winning * Cleanup * Add challenge enabled flag * Spacing of switch button * Put sharing qr code in modal Co-authored-by: mantikoros --- common/bet.ts | 1 + common/challenge.ts | 63 +++ common/notification.ts | 2 + firestore.rules | 11 + functions/src/accept-challenge.ts | 164 +++++++ functions/src/create-notification.ts | 33 ++ functions/src/index.ts | 3 + og-image/README.md | 47 +- og-image/api/_lib/challenge-template.ts | 203 +++++++++ og-image/api/_lib/parser.ts | 14 + og-image/api/_lib/template.ts | 2 +- og-image/api/_lib/types.ts | 39 +- og-image/api/index.ts | 46 +- web/components/SEO.tsx | 28 +- web/components/bet-panel.tsx | 5 +- web/components/button.tsx | 18 +- .../challenges/accept-challenge-button.tsx | 125 ++++++ .../challenges/create-challenge-button.tsx | 255 +++++++++++ .../contract/contract-card-preview.tsx | 36 ++ web/components/contract/contract-overview.tsx | 51 ++- web/components/copy-link-button.tsx | 3 +- web/components/feed/contract-activity.tsx | 5 +- web/components/feed/feed-bets.tsx | 23 +- web/components/nav/sidebar.tsx | 69 ++- .../portfolio/portfolio-value-section.tsx | 29 +- web/components/share-market-button.tsx | 18 + web/components/share-market.tsx | 28 -- web/components/sign-up-prompt.tsx | 14 +- web/hooks/use-bets.ts | 20 +- web/hooks/use-save-referral.ts | 4 +- web/hooks/use-user.ts | 2 +- web/lib/firebase/api.ts | 4 + web/lib/firebase/challenges.ts | 150 +++++++ web/lib/firebase/contracts.ts | 7 + web/pages/[username]/[contractSlug].tsx | 179 ++++++-- .../[contractSlug]/[challengeSlug].tsx | 403 ++++++++++++++++++ web/pages/challenges/index.tsx | 300 +++++++++++++ web/pages/embed/[username]/[contractSlug].tsx | 9 +- web/pages/group/[...slugs]/index.tsx | 2 +- web/pages/link/[slug].tsx | 2 +- web/pages/notifications.tsx | 13 + 41 files changed, 2233 insertions(+), 197 deletions(-) create mode 100644 common/challenge.ts create mode 100644 functions/src/accept-challenge.ts create mode 100644 og-image/api/_lib/challenge-template.ts create mode 100644 web/components/challenges/accept-challenge-button.tsx create mode 100644 web/components/challenges/create-challenge-button.tsx create mode 100644 web/components/contract/contract-card-preview.tsx create mode 100644 web/components/share-market-button.tsx delete mode 100644 web/components/share-market.tsx create mode 100644 web/lib/firebase/challenges.ts create mode 100644 web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx create mode 100644 web/pages/challenges/index.tsx diff --git a/common/bet.ts b/common/bet.ts index d5072c0f..56e050a7 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -26,6 +26,7 @@ export type Bet = { isAnte?: boolean isLiquidityProvision?: boolean isRedemption?: boolean + challengeSlug?: string } & Partial export type NumericBet = Bet & { diff --git a/common/challenge.ts b/common/challenge.ts new file mode 100644 index 00000000..1a227f94 --- /dev/null +++ b/common/challenge.ts @@ -0,0 +1,63 @@ +export type Challenge = { + // The link to send: https://manifold.markets/challenges/username/market-slug/{slug} + // Also functions as the unique id for the link. + slug: string + + // The user that created the challenge. + creatorId: string + creatorUsername: string + creatorName: string + creatorAvatarUrl?: string + + // Displayed to people claiming the challenge + message: string + + // How much to put up + creatorAmount: number + + // YES or NO for now + creatorOutcome: string + + // Different than the creator + acceptorOutcome: string + acceptorAmount: number + + // The probability the challenger thinks + creatorOutcomeProb: number + + contractId: string + contractSlug: string + contractQuestion: string + contractCreatorUsername: string + + createdTime: number + // If null, the link is valid forever + expiresTime: number | null + + // How many times the challenge can be used + maxUses: number + + // Used for simpler caching + acceptedByUserIds: string[] + // Successful redemptions of the link + acceptances: Acceptance[] + + // TODO: will have to fill this on resolve contract + isResolved: boolean + resolutionOutcome?: string +} + +export type Acceptance = { + // User that accepted the challenge + userId: string + userUsername: string + userName: string + userAvatarUrl: string + + // The ID of the successful bet that tracks the money moved + betId: string + + createdTime: number +} + +export const CHALLENGES_ENABLED = true diff --git a/common/notification.ts b/common/notification.ts index 5fd4236b..fa4cd90a 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -37,6 +37,7 @@ export type notification_source_types = | 'group' | 'user' | 'bonus' + | 'challenge' export type notification_source_update_types = | 'created' @@ -64,3 +65,4 @@ export type notification_reason_types = | 'tip_received' | 'bet_fill' | 'user_joined_from_your_group_invite' + | 'challenge_accepted' diff --git a/firestore.rules b/firestore.rules index 05721dcf..b0befc85 100644 --- a/firestore.rules +++ b/firestore.rules @@ -39,6 +39,17 @@ service cloud.firestore { allow read; } + match /{somePath=**}/challenges/{challengeId}{ + allow read; + } + + match /contracts/{contractId}/challenges/{challengeId}{ + allow read; + allow create: if request.auth.uid == request.resource.data.creatorId; + // allow update if there have been no claims yet and if the challenge is still open + allow update: if request.auth.uid == resource.data.creatorId; + } + match /users/{userId}/follows/{followUserId} { allow read; allow write: if request.auth.uid == userId; diff --git a/functions/src/accept-challenge.ts b/functions/src/accept-challenge.ts new file mode 100644 index 00000000..fa98c8c6 --- /dev/null +++ b/functions/src/accept-challenge.ts @@ -0,0 +1,164 @@ +import { z } from 'zod' +import { APIError, newEndpoint, validate } from './api' +import { log } from './utils' +import { Contract, CPMMBinaryContract } from '../../common/contract' +import { User } from '../../common/user' +import * as admin from 'firebase-admin' +import { FieldValue } from 'firebase-admin/firestore' +import { removeUndefinedProps } from '../../common/util/object' +import { Acceptance, Challenge } from '../../common/challenge' +import { CandidateBet } from '../../common/new-bet' +import { createChallengeAcceptedNotification } from './create-notification' +import { noFees } from '../../common/fees' +import { formatMoney, formatPercent } from '../../common/util/format' + +const bodySchema = z.object({ + contractId: z.string(), + challengeSlug: z.string(), + outcomeType: z.literal('BINARY'), + closeTime: z.number().gte(Date.now()), +}) +const firestore = admin.firestore() + +export const acceptchallenge = newEndpoint({}, async (req, auth) => { + const { challengeSlug, contractId } = validate(bodySchema, req.body) + + const result = await firestore.runTransaction(async (trans) => { + const contractDoc = firestore.doc(`contracts/${contractId}`) + const userDoc = firestore.doc(`users/${auth.uid}`) + const challengeDoc = firestore.doc( + `contracts/${contractId}/challenges/${challengeSlug}` + ) + const [contractSnap, userSnap, challengeSnap] = await trans.getAll( + contractDoc, + userDoc, + challengeDoc + ) + if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') + if (!userSnap.exists) throw new APIError(400, 'User not found.') + if (!challengeSnap.exists) throw new APIError(400, 'Challenge not found.') + + const anyContract = contractSnap.data() as Contract + const user = userSnap.data() as User + const challenge = challengeSnap.data() as Challenge + + if (challenge.acceptances.length > 0) + throw new APIError(400, 'Challenge already accepted.') + + const creatorDoc = firestore.doc(`users/${challenge.creatorId}`) + const creatorSnap = await trans.get(creatorDoc) + if (!creatorSnap.exists) throw new APIError(400, 'User not found.') + const creator = creatorSnap.data() as User + + const { + creatorAmount, + acceptorOutcome, + creatorOutcome, + creatorOutcomeProb, + acceptorAmount, + } = challenge + + if (user.balance < acceptorAmount) + throw new APIError(400, 'Insufficient balance.') + + const contract = anyContract as CPMMBinaryContract + const shares = (1 / creatorOutcomeProb) * creatorAmount + const createdTime = Date.now() + const probOfYes = + creatorOutcome === 'YES' ? creatorOutcomeProb : 1 - creatorOutcomeProb + + log( + 'Creating challenge bet for', + user.username, + shares, + acceptorOutcome, + 'shares', + 'at', + formatPercent(creatorOutcomeProb), + 'for', + formatMoney(acceptorAmount) + ) + + const yourNewBet: CandidateBet = removeUndefinedProps({ + orderAmount: acceptorAmount, + amount: acceptorAmount, + shares, + isCancelled: false, + contractId: contract.id, + outcome: acceptorOutcome, + probBefore: probOfYes, + probAfter: probOfYes, + loanAmount: 0, + createdTime, + fees: noFees, + challengeSlug: challenge.slug, + }) + + const yourNewBetDoc = contractDoc.collection('bets').doc() + trans.create(yourNewBetDoc, { + id: yourNewBetDoc.id, + userId: user.id, + ...yourNewBet, + }) + + trans.update(userDoc, { balance: FieldValue.increment(-yourNewBet.amount) }) + + const creatorNewBet: CandidateBet = removeUndefinedProps({ + orderAmount: creatorAmount, + amount: creatorAmount, + shares, + isCancelled: false, + contractId: contract.id, + outcome: creatorOutcome, + probBefore: probOfYes, + probAfter: probOfYes, + loanAmount: 0, + createdTime, + fees: noFees, + challengeSlug: challenge.slug, + }) + const creatorBetDoc = contractDoc.collection('bets').doc() + trans.create(creatorBetDoc, { + id: creatorBetDoc.id, + userId: creator.id, + ...creatorNewBet, + }) + + trans.update(creatorDoc, { + balance: FieldValue.increment(-creatorNewBet.amount), + }) + + const volume = contract.volume + yourNewBet.amount + creatorNewBet.amount + trans.update(contractDoc, { volume }) + + trans.update( + challengeDoc, + removeUndefinedProps({ + acceptedByUserIds: [user.id], + acceptances: [ + { + userId: user.id, + betId: yourNewBetDoc.id, + createdTime, + amount: acceptorAmount, + userUsername: user.username, + userName: user.name, + userAvatarUrl: user.avatarUrl, + } as Acceptance, + ], + }) + ) + + await createChallengeAcceptedNotification( + user, + creator, + challenge, + acceptorAmount, + contract + ) + log('Done, sent notification.') + return yourNewBetDoc + }) + + return { betId: result.id } +}) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 7cc05760..83568535 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -16,6 +16,7 @@ import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' import { Group, GROUP_CHAT_SLUG } from '../../common/group' +import { Challenge } from '../../common/challenge' const firestore = admin.firestore() type user_to_reason_texts = { @@ -478,3 +479,35 @@ export const createReferralNotification = async ( } const groupPath = (groupSlug: string) => `/group/${groupSlug}` + +export const createChallengeAcceptedNotification = async ( + challenger: User, + challengeCreator: User, + challenge: Challenge, + acceptedAmount: number, + contract: Contract +) => { + const notificationRef = firestore + .collection(`/users/${challengeCreator.id}/notifications`) + .doc() + const notification: Notification = { + id: notificationRef.id, + userId: challengeCreator.id, + reason: 'challenge_accepted', + createdTime: Date.now(), + isSeen: false, + sourceId: challenge.slug, + sourceType: 'challenge', + sourceUpdateType: 'updated', + sourceUserName: challenger.name, + sourceUserUsername: challenger.username, + sourceUserAvatarUrl: challenger.avatarUrl, + sourceText: acceptedAmount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceSlug: `/challenges/${challengeCreator.username}/${challenge.contractSlug}/${challenge.slug}`, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 76e54f1c..125cdea4 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -64,6 +64,7 @@ import { resolvemarket } from './resolve-market' import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' +import { acceptchallenge } from './accept-challenge' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -87,6 +88,7 @@ const unsubscribeFunction = toCloudFunction(unsubscribe) const stripeWebhookFunction = toCloudFunction(stripewebhook) const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) +const acceptChallenge = toCloudFunction(acceptchallenge) export { healthFunction as health, @@ -108,4 +110,5 @@ export { stripeWebhookFunction as stripewebhook, createCheckoutSessionFunction as createcheckoutsession, getCurrentUserFunction as getcurrentuser, + acceptChallenge as acceptchallenge, } diff --git a/og-image/README.md b/og-image/README.md index 7d0d2f92..6ecc4e82 100644 --- a/og-image/README.md +++ b/og-image/README.md @@ -1,32 +1,35 @@ +# Installing +1. `yarn install` +2. `yarn start` +3. `Y` to `Set up and develop “~path/to/the/repo/manifold”? [Y/n]` +4. `Manifold Markets` to `Which scope should contain your project? [Y/n] ` +5. `Y` to `Link to existing project? [Y/n] ` +6. `opengraph-image` to `What’s the name of your existing project?` + # Quickstart -1. To get started: `yarn install` -2. To test locally: `yarn start` +1. To test locally: `yarn start` The local image preview is broken for some reason; but the service works. E.g. try `http://localhost:3000/manifold.png` -3. To deploy: push to Github - -For more info, see Contributing.md - -- note2: You may have to configure Vercel the first time: - - ``` - $ yarn start - yarn run v1.22.10 - $ cd .. && vercel dev - Vercel CLI 23.1.2 dev (beta) — https://vercel.com/feedback - ? Set up and develop “~/Code/mantic”? [Y/n] y - ? Which scope should contain your project? Mantic Markets - ? Found project “mantic/mantic”. Link to it? [Y/n] n - ? Link to different existing project? [Y/n] y - ? What’s the name of your existing project? manifold-og-image - ``` - -- note2: (Not `dev` because that's reserved for Vercel) -- note3: (Or `cd .. && vercel --prod`, I think) +2. To deploy: push to Github +- note: (Not `dev` because that's reserved for Vercel) +- note2: (Or `cd .. && vercel --prod`, I think) +For more info, see Contributing.md (Everything below is from the original repo) +# Development +- Code of interest is contained in the `api/_lib` directory, i.e. `template.ts` is the page that renders the UI. +- Edit `parseRequest(req: IncomingMessage)` in `parser.ts` to add/edit query parameters. +- Note: When testing a remote branch on vercel, the og-image previews that apps load will point to +`https://manifold-og-image.vercel.app/m.png?question=etc.`, (see relevant code in `SEO.tsx`) and not your remote branch. +You have to find your opengraph-image branch's url and replace the part before `m.png` with it. + - You can also preview the image locally, e.g. `http://localhost:3000/m.png?question=etc.` + - Every time you change the template code you'll have to change the query parameter slightly as the image will likely be cached. +- You can find your remote branch's opengraph-image url by click `Visit Preview` on Github: +![](../../../../../Desktop/Screen Shot 2022-08-01 at 2.56.42 PM.png) + + # [Open Graph Image as a Service](https://og-image.vercel.app) diff --git a/og-image/api/_lib/challenge-template.ts b/og-image/api/_lib/challenge-template.ts new file mode 100644 index 00000000..6dc43ac1 --- /dev/null +++ b/og-image/api/_lib/challenge-template.ts @@ -0,0 +1,203 @@ +import { sanitizeHtml } from './sanitizer' +import { ParsedRequest } from './types' + +function getCss(theme: string, fontSize: string) { + let background = 'white' + let foreground = 'black' + let radial = 'lightgray' + + if (theme === 'dark') { + background = 'black' + foreground = 'white' + radial = 'dimgray' + } + // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` + return ` + @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); + + body { + background: ${background}; + background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); + background-size: 100px 100px; + height: 100vh; + font-family: "Readex Pro", sans-serif; + } + + code { + color: #D400FF; + font-family: 'Vera'; + white-space: pre-wrap; + letter-spacing: -5px; + } + + code:before, code:after { + content: '\`'; + } + + .logo-wrapper { + display: flex; + align-items: center; + align-content: center; + justify-content: center; + justify-items: center; + } + + .logo { + margin: 0 75px; + } + + .plus { + color: #BBB; + font-family: Times New Roman, Verdana; + font-size: 100px; + } + + .spacer { + margin: 150px; + } + + .emoji { + height: 1em; + width: 1em; + margin: 0 .05em 0 .1em; + vertical-align: -0.1em; + } + + .heading { + font-family: 'Major Mono Display', monospace; + font-size: ${sanitizeHtml(fontSize)}; + font-style: normal; + color: ${foreground}; + line-height: 1.8; + } + + .font-major-mono { + font-family: "Major Mono Display", monospace; + } + + .text-primary { + color: #11b981; + } + ` +} + +export function getChallengeHtml(parsedReq: ParsedRequest) { + const { + theme, + fontSize, + question, + creatorName, + creatorAvatarUrl, + challengerAmount, + challengerOutcome, + creatorAmount, + creatorOutcome, + acceptedName, + acceptedAvatarUrl, + } = parsedReq + const MAX_QUESTION_CHARS = 78 + const truncatedQuestion = + question.length > MAX_QUESTION_CHARS + ? question.slice(0, MAX_QUESTION_CHARS) + '...' + : question + const hideAvatar = creatorAvatarUrl ? '' : 'hidden' + const hideAcceptedAvatar = acceptedAvatarUrl ? '' : 'hidden' + const accepted = acceptedName !== '' + return ` + + + + Generated Image + + + + + +
+ + +
+
+ ${truncatedQuestion} +
+
+
+ + +
+

${creatorName}

+ +
+
+
${'M$' + creatorAmount}
+
${'on'}
+
${creatorOutcome}
+
+
+ + +
+ VS +
+
+ + +
+

You

+ +
+ +
+

${acceptedName}

+ +
+
+
${'M$' + challengerAmount}
+
${'on'}
+
${challengerOutcome}
+
+
+
+ +
+
+ +
+ + + +` +} diff --git a/og-image/api/_lib/parser.ts b/og-image/api/_lib/parser.ts index b8163719..1a0863bd 100644 --- a/og-image/api/_lib/parser.ts +++ b/og-image/api/_lib/parser.ts @@ -20,6 +20,14 @@ export function parseRequest(req: IncomingMessage) { creatorName, creatorUsername, creatorAvatarUrl, + + // Challenge attributes: + challengerAmount, + challengerOutcome, + creatorAmount, + creatorOutcome, + acceptedName, + acceptedAvatarUrl, } = query || {} if (Array.isArray(fontSize)) { @@ -67,6 +75,12 @@ export function parseRequest(req: IncomingMessage) { creatorName: getString(creatorName) || 'Manifold Markets', creatorUsername: getString(creatorUsername) || 'ManifoldMarkets', creatorAvatarUrl: getString(creatorAvatarUrl) || '', + challengerAmount: getString(challengerAmount) || '', + challengerOutcome: getString(challengerOutcome) || '', + creatorAmount: getString(creatorAmount) || '', + creatorOutcome: getString(creatorOutcome) || '', + acceptedName: getString(acceptedName) || '', + acceptedAvatarUrl: getString(acceptedAvatarUrl) || '', } parsedRequest.images = getDefaultImages(parsedRequest.images) return parsedRequest diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index a6b0336c..1fe54554 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -126,7 +126,7 @@ export function getHtml(parsedReq: ParsedRequest) { - +
Internal Error

Sorry, there was a problem

"); - console.error(e); + res.statusCode = 500 + res.setHeader('Content-Type', 'text/html') + res.end('

Internal Error

Sorry, there was a problem

') + console.error(e) } } diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 11e24c99..b1e0ca5f 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react' import Head from 'next/head' +import { Challenge } from 'common/challenge' export type OgCardProps = { question: string @@ -10,7 +11,16 @@ export type OgCardProps = { creatorAvatarUrl?: string } -function buildCardUrl(props: OgCardProps) { +function buildCardUrl(props: OgCardProps, challenge?: Challenge) { + const { + creatorAmount, + acceptances, + acceptorAmount, + creatorOutcome, + acceptorOutcome, + } = challenge || {} + const { userName, userAvatarUrl } = acceptances?.[0] ?? {} + const probabilityParam = props.probability === undefined ? '' @@ -20,6 +30,12 @@ function buildCardUrl(props: OgCardProps) { ? '' : `&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` + @@ -28,7 +44,8 @@ function buildCardUrl(props: OgCardProps) { `&metadata=${encodeURIComponent(props.metadata)}` + `&creatorName=${encodeURIComponent(props.creatorName)}` + creatorAvatarUrlParam + - `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + + challengeUrlParams ) } @@ -38,8 +55,9 @@ export function SEO(props: { url?: string children?: ReactNode ogCardProps?: OgCardProps + challenge?: Challenge }) { - const { title, description, url, children, ogCardProps } = props + const { title, description, url, children, ogCardProps, challenge } = props return ( @@ -71,13 +89,13 @@ export function SEO(props: { <> diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index aea38c86..c0f7ff94 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -16,8 +16,7 @@ import { import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet' import { User } from 'web/lib/firebase/users' import { Bet, LimitBet } from 'common/bet' -import { APIError, placeBet } from 'web/lib/firebase/api' -import { sellShares } from 'web/lib/firebase/api' +import { APIError, placeBet, sellShares } from 'web/lib/firebase/api' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' import { @@ -351,7 +350,7 @@ function BuyPanel(props: { {user && (