diff --git a/common/charity.ts b/common/charity.ts index c18c6ba1..fd5abc36 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -565,6 +565,30 @@ Improve the lives of the world's most vulnerable people. Reduce the number of easily preventable deaths worldwide. Work towards sustainable, systemic change.`, }, + { + name: 'YIMBY Law', + website: 'https://www.yimbylaw.org/', + photo: 'https://i.imgur.com/zlzp21Z.png', + preview: + 'YIMBY Law works to make housing in California more accessible and affordable, by enforcing state housing laws.', + description: ` + YIMBY Law works to make housing in California more accessible and affordable. Our method is to enforce state housing laws, and some examples are outlined below. We send letters to cities considering zoning or general plan compliant housing developments informing them of their duties under state law, and sue them when they do not comply. + +If you would like to support our work, you can do so by getting involved or by donating.`, + }, + { + name: 'CaRLA', + website: 'https://carlaef.org/', + photo: 'https://i.imgur.com/IsNVTOY.png', + preview: + 'The California Renters Legal Advocacy and Education Fund’s core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color.', + description: ` + The California Renters Legal Advocacy and Education Fund’s core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color. + +CaRLA uses legal advocacy and education to ensure all cities comply with their own zoning and state housing laws and do their part to help solve the state’s housing shortage. + +In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`, + }, ].map((charity) => { const slug = charity.name.toLowerCase().replace(/\s/g, '-') return { diff --git a/common/comment.ts b/common/comment.ts index 77b211d3..c7f9b855 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,13 +1,11 @@ import type { JSONContent } from '@tiptap/core' +export type AnyCommentType = OnContract | OnGroup + // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. -export type Comment = { +export type Comment = { id: string - contractId?: string - groupId?: string - betId?: string - answerOutcome?: string replyToCommentId?: string userId: string @@ -20,6 +18,21 @@ export type Comment = { userName: string userUsername: string userAvatarUrl?: string - contractSlug?: string - contractQuestion?: string +} & T + +type OnContract = { + commentType: 'contract' + contractId: string + contractSlug: string + contractQuestion: string + answerOutcome?: string + betId?: string } + +type OnGroup = { + commentType: 'group' + groupId: string +} + +export type ContractComment = Comment +export type GroupComment = Comment diff --git a/common/notification.ts b/common/notification.ts index fa4cd90a..99f9d852 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -38,6 +38,7 @@ export type notification_source_types = | 'user' | 'bonus' | 'challenge' + | 'betting_streak_bonus' export type notification_source_update_types = | 'created' @@ -66,3 +67,4 @@ export type notification_reason_types = | 'bet_fill' | 'user_joined_from_your_group_invite' | 'challenge_accepted' + | 'betting_streak_incremented' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index f399aa5a..9d41d54f 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -4,3 +4,5 @@ export const NUMERIC_FIXED_VAR = 0.005 export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_TEXT_COLOR = 'text-blue-500' export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 +export const BETTING_STREAK_BONUS_AMOUNT = 5 +export const BETTING_STREAK_RESET_HOUR = 9 diff --git a/common/recommended-contracts.ts b/common/recommended-contracts.ts deleted file mode 100644 index 3a6eca38..00000000 --- a/common/recommended-contracts.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { union, sum, sumBy, sortBy, groupBy, mapValues } from 'lodash' -import { Bet } from './bet' -import { Contract } from './contract' -import { ClickEvent } from './tracking' -import { filterDefined } from './util/array' -import { addObjects } from './util/object' - -export const MAX_FEED_CONTRACTS = 75 - -export const getRecommendedContracts = ( - contractsById: { [contractId: string]: Contract }, - yourBetOnContractIds: string[] -) => { - const contracts = Object.values(contractsById) - const yourContracts = filterDefined( - yourBetOnContractIds.map((contractId) => contractsById[contractId]) - ) - - const yourContractIds = new Set(yourContracts.map((c) => c.id)) - const notYourContracts = contracts.filter((c) => !yourContractIds.has(c.id)) - - const yourWordFrequency = contractsToWordFrequency(yourContracts) - const otherWordFrequency = contractsToWordFrequency(notYourContracts) - const words = union( - Object.keys(yourWordFrequency), - Object.keys(otherWordFrequency) - ) - - const yourWeightedFrequency = Object.fromEntries( - words.map((word) => { - const [yourFreq, otherFreq] = [ - yourWordFrequency[word] ?? 0, - otherWordFrequency[word] ?? 0, - ] - - const score = yourFreq / (yourFreq + otherFreq + 0.0001) - - return [word, score] - }) - ) - - // console.log( - // 'your weighted frequency', - // _.sortBy(_.toPairs(yourWeightedFrequency), ([, freq]) => -freq) - // ) - - const scoredContracts = contracts.map((contract) => { - const wordFrequency = contractToWordFrequency(contract) - - const score = sumBy(Object.keys(wordFrequency), (word) => { - const wordFreq = wordFrequency[word] ?? 0 - const weight = yourWeightedFrequency[word] ?? 0 - return wordFreq * weight - }) - - return { - contract, - score, - } - }) - - return sortBy(scoredContracts, (scored) => -scored.score).map( - (scored) => scored.contract - ) -} - -const contractToText = (contract: Contract) => { - const { description, question, tags, creatorUsername } = contract - return `${creatorUsername} ${question} ${tags.join(' ')} ${description}` -} - -const MAX_CHARS_IN_WORD = 100 - -const getWordsCount = (text: string) => { - const normalizedText = text.replace(/[^a-zA-Z]/g, ' ').toLowerCase() - const words = normalizedText - .split(' ') - .filter((word) => word) - .filter((word) => word.length <= MAX_CHARS_IN_WORD) - - const counts: { [word: string]: number } = {} - for (const word of words) { - if (counts[word]) counts[word]++ - else counts[word] = 1 - } - return counts -} - -const toFrequency = (counts: { [word: string]: number }) => { - const total = sum(Object.values(counts)) - return mapValues(counts, (count) => count / total) -} - -const contractToWordFrequency = (contract: Contract) => - toFrequency(getWordsCount(contractToText(contract))) - -const contractsToWordFrequency = (contracts: Contract[]) => { - const frequencySum = contracts - .map(contractToWordFrequency) - .reduce(addObjects, {}) - - return toFrequency(frequencySum) -} - -export const getWordScores = ( - contracts: Contract[], - contractViewCounts: { [contractId: string]: number }, - clicks: ClickEvent[], - bets: Bet[] -) => { - const contractClicks = groupBy(clicks, (click) => click.contractId) - const contractBets = groupBy(bets, (bet) => bet.contractId) - - const yourContracts = contracts.filter( - (c) => - contractViewCounts[c.id] || contractClicks[c.id] || contractBets[c.id] - ) - const yourTfIdf = calculateContractTfIdf(yourContracts) - - const contractWordScores = mapValues(yourTfIdf, (wordsTfIdf, contractId) => { - const viewCount = contractViewCounts[contractId] ?? 0 - const clickCount = contractClicks[contractId]?.length ?? 0 - const betCount = contractBets[contractId]?.length ?? 0 - - const factor = - -1 * Math.log(viewCount + 1) + - 10 * Math.log(betCount + clickCount / 4 + 1) - - return mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor) - }) - - const wordScores = Object.values(contractWordScores).reduce(addObjects, {}) - const minScore = Math.min(...Object.values(wordScores)) - const maxScore = Math.max(...Object.values(wordScores)) - const normalizedWordScores = mapValues( - wordScores, - (score) => (score - minScore) / (maxScore - minScore) - ) - - // console.log( - // 'your word scores', - // _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(0, 100), - // _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(-100) - // ) - - return normalizedWordScores -} - -export function getContractScore( - contract: Contract, - wordScores: { [word: string]: number } -) { - if (Object.keys(wordScores).length === 0) return 1 - - const wordFrequency = contractToWordFrequency(contract) - const score = sumBy(Object.keys(wordFrequency), (word) => { - const wordFreq = wordFrequency[word] ?? 0 - const weight = wordScores[word] ?? 0 - return wordFreq * weight - }) - - return score -} - -// Caluculate Term Frequency-Inverse Document Frequency (TF-IDF): -// https://medium.datadriveninvestor.com/tf-idf-in-natural-language-processing-8db8ef4a7736 -function calculateContractTfIdf(contracts: Contract[]) { - const contractFreq = contracts.map((c) => contractToWordFrequency(c)) - const contractWords = contractFreq.map((freq) => Object.keys(freq)) - - const wordsCount: { [word: string]: number } = {} - for (const words of contractWords) { - for (const word of words) { - wordsCount[word] = (wordsCount[word] ?? 0) + 1 - } - } - - const wordIdf = mapValues(wordsCount, (count) => - Math.log(contracts.length / count) - ) - const contractWordsTfIdf = contractFreq.map((wordFreq) => - mapValues(wordFreq, (freq, word) => freq * wordIdf[word]) - ) - return Object.fromEntries( - contracts.map((c, i) => [c.id, contractWordsTfIdf[i]]) - ) -} diff --git a/common/txn.ts b/common/txn.ts index 701b67fe..00b19570 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -16,7 +16,13 @@ export type Txn = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' + category: + | 'CHARITY' + | 'MANALINK' + | 'TIP' + | 'REFERRAL' + | 'UNIQUE_BETTOR_BONUS' + | 'BETTING_STREAK_BONUS' // Any extra data data?: { [key: string]: any } @@ -57,7 +63,7 @@ type Referral = { type Bonus = { fromType: 'BANK' toType: 'USER' - category: 'UNIQUE_BETTOR_BONUS' + category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' } export type DonationTxn = Txn & Donation diff --git a/common/user.ts b/common/user.ts index f2ff8fb5..2910c54e 100644 --- a/common/user.ts +++ b/common/user.ts @@ -41,6 +41,8 @@ export type User = { referredByGroupId?: string lastPingTime?: number shouldShowWelcome?: boolean + lastBetTime?: number + currentBettingStreak?: number } export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 diff --git a/docs/docs/api.md b/docs/docs/api.md index e4936418..7b0058c2 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -135,7 +135,8 @@ Requires no authorization. // Market attributes. All times are in milliseconds since epoch closeTime?: number // Min of creator's chosen date, and resolutionTime question: string - description: string + description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json + textDescription: string // string description without formatting, images, or embeds // A list of tags on each market. Any user can add tags to any market. // This list also includes the predefined categories shown as filters on the home page. @@ -162,6 +163,8 @@ Requires no authorization. resolutionTime?: number resolution?: string resolutionProbability?: number // Used for BINARY markets resolved to MKT + + lastUpdatedTime?: number } ``` @@ -541,6 +544,7 @@ Parameters: - `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`. - `question`: Required. The headline question for the market. - `description`: Required. A long description describing the rules for the market. + - Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json). - `closeTime`: Required. The time at which the market will close, represented as milliseconds since the epoch. - `tags`: Optional. An array of string tags for the market. diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index 0871be52..458b81ee 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -18,6 +18,7 @@ A list of community-created projects built on, or related to, Manifold Markets. - [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics) - [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets - [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets +- [manifold-sdk](https://github.com/keriwarr/manifold-sdk) - TypeScript/JavaScript client for the Manifold API ## Bots diff --git a/functions/src/accept-challenge.ts b/functions/src/accept-challenge.ts index eae6ab55..a96a4027 100644 --- a/functions/src/accept-challenge.ts +++ b/functions/src/accept-challenge.ts @@ -11,6 +11,7 @@ import { CandidateBet } from '../../common/new-bet' import { createChallengeAcceptedNotification } from './create-notification' import { noFees } from '../../common/fees' import { formatMoney, formatPercent } from '../../common/util/format' +import { redeemShares } from './redeem-shares' const bodySchema = z.object({ contractId: z.string(), @@ -163,5 +164,7 @@ export const acceptchallenge = newEndpoint({}, async (req, auth) => { return yourNewBetDoc }) + await redeemShares(auth.uid, contractId) + return { betId: result.id } }) diff --git a/functions/src/create-contract.ts b/functions/src/create-market.ts similarity index 93% rename from functions/src/create-contract.ts rename to functions/src/create-market.ts index 9a44b100..3e9998ed 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-market.ts @@ -14,15 +14,17 @@ import { import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' -import { chargeUser, getContract } from './utils' +import { chargeUser, getContract, isProd } from './utils' import { APIError, newEndpoint, validate, zTimestamp } from './api' import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, FIXED_ANTE, getCpmmInitialLiquidity, getFreeAnswerAnte, getMultipleChoiceAntes, getNumericAnte, + HOUSE_LIQUIDITY_PROVIDER_ID, } from '../../common/antes' import { Answer, getNoneAnswer } from '../../common/answer' import { getNewContract } from '../../common/new-contract' @@ -59,7 +61,7 @@ const descScehma: z.ZodType = z.lazy(() => const bodySchema = z.object({ question: z.string().min(1).max(MAX_QUESTION_LENGTH), - description: descScehma.optional(), + description: descScehma.or(z.string()).optional(), tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(), closeTime: zTimestamp().refine( (date) => date.getTime() > new Date().getTime(), @@ -165,13 +167,27 @@ export const createmarket = newEndpoint({}, async (req, auth) => { ante || 0 ) + // convert string descriptions into JSONContent + const newDescription = + typeof description === 'string' + ? { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: description }], + }, + ], + } + : description ?? {} + const contract = getNewContract( contractRef.id, slug, user, question, outcomeType, - description ?? {}, + newDescription, initialProb ?? 0, ante, closeTime.getTime(), @@ -197,7 +213,9 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } } - const providerId = user.id + const providerId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { const liquidityDoc = firestore diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 51b884ad..90250e73 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -504,3 +504,38 @@ export const createChallengeAcceptedNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } + +export const createBettingStreakBonusNotification = async ( + user: User, + txnId: string, + bet: Bet, + contract: Contract, + amount: number, + idempotencyKey: string +) => { + const notificationRef = firestore + .collection(`/users/${user.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: user.id, + reason: 'betting_streak_incremented', + createdTime: Date.now(), + isSeen: false, + sourceId: txnId, + sourceType: 'betting_streak_bonus', + sourceUpdateType: 'created', + sourceUserName: user.name, + sourceUserUsername: user.username, + sourceUserAvatarUrl: user.avatarUrl, + sourceText: amount.toString(), + sourceSlug: `/${contract.creatorUsername}/${contract.slug}/bets/${bet.id}`, + sourceTitle: 'Betting Streak Bonus', + // Perhaps not necessary, but just in case + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index bd65b14a..c0b03e23 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -16,7 +16,7 @@ import { cleanDisplayName, cleanUsername, } from '../../common/util/clean-username' -import { sendWelcomeEmail } from './emails' +import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails' import { isWhitelisted } from '../../common/envs/constants' import { CATEGORIES_GROUP_SLUG_POSTFIX, @@ -96,6 +96,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { await addUserToDefaultGroups(user) await sendWelcomeEmail(user, privateUser) + await sendPersonalFollowupEmail(user, privateUser) await track(auth.uid, 'create user', { username }, { ip: req.ip }) return { user, privateUser } diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html index 1ef9dbb7..6c75f026 100644 --- a/functions/src/email-templates/500-mana.html +++ b/functions/src/email-templates/500-mana.html @@ -128,7 +128,20 @@

+ Hi {{name}},

+
+ + + + +
+

Thanks for using Manifold Markets. Running low @@ -161,6 +174,51 @@ + + +

+

Did + you know, besides making correct predictions, there are + plenty of other ways to earn mana?

+ +

 

+

Cheers, +

+

David + from Manifold

+

 

+
+ + diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html index 64273e7c..674a30ed 100644 --- a/functions/src/email-templates/creating-market.html +++ b/functions/src/email-templates/creating-market.html @@ -1,46 +1,48 @@ - - - (no subject) - - - - - - - + + + + + + - - - - - - - - - + + - + + + - - - -
- -
+
+ +
- - - - + + +
+ + + + + + +
- -
+ +
- - - - - - -
+ + + + - - -
- +
- - - + + - - -
- +
+ -
-
- - -
-
- -
+
+
+
+ + + + + +
+ +
- - - - + + + - + +
Bet
+ + + {user && ( + + )} + + +
+ {error && ( +
+ {error} {tooFewFunds && `(${formatMoney(user?.balance ?? 0)})`} +
+ )} + + ) +} diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index d5f88d43..54aa961d 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -448,8 +448,6 @@ function LimitOrderPanel(props: { const yesAmount = shares * (yesLimitProb ?? 1) const noAmount = shares * (1 - (noLimitProb ?? 0)) - const profitIfBothFilled = shares - (yesAmount + noAmount) - function onBetChange(newAmount: number | undefined) { setWasSubmitted(false) setBetAmount(newAmount) @@ -559,6 +557,8 @@ function LimitOrderPanel(props: { ) const noReturnPercent = formatPercent(noReturn) + const profitIfBothFilled = shares - (yesAmount + noAmount) - yesFees - noFees + return ( diff --git a/web/components/button.tsx b/web/components/button.tsx index 57b2add9..843f74ca 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -1,20 +1,23 @@ import { ReactNode } from 'react' import clsx from 'clsx' +export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' +export type ColorType = + | 'green' + | 'red' + | 'blue' + | 'indigo' + | 'yellow' + | 'gray' + | 'gradient' + | 'gray-white' + export function Button(props: { className?: string onClick?: () => void children?: ReactNode - size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' - color?: - | 'green' - | 'red' - | 'blue' - | 'indigo' - | 'yellow' - | 'gray' - | 'gradient' - | 'gray-white' + size?: SizeType + color?: ColorType type?: 'button' | 'reset' | 'submit' disabled?: boolean }) { diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index e93ec314..b1ac7704 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -11,7 +11,7 @@ import { User } from 'common/user' import { Modal } from 'web/components/layout/modal' import { Button } from '../button' import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' -import { BinaryContract } from 'common/contract' +import { BinaryContract, MAX_QUESTION_LENGTH } from 'common/contract' import { SiteLink } from 'web/components/site-link' import { formatMoney } from 'common/util/format' import { NoLabel, YesLabel } from '../outcome-label' @@ -19,24 +19,32 @@ import { QRCode } from '../qr-code' import { copyToClipboard } from 'web/lib/util/copy' import { AmountInput } from '../amount-input' import { getProbability } from 'common/calculate' +import { createMarket } from 'web/lib/firebase/api' +import { removeUndefinedProps } from 'common/util/object' +import { FIXED_ANTE } from 'common/antes' +import Textarea from 'react-expanding-textarea' +import { useTextEditor } from 'web/components/editor' +import { LoadingIndicator } from 'web/components/loading-indicator' import { track } from 'web/lib/service/analytics' type challengeInfo = { amount: number expiresTime: number | null - message: string outcome: 'YES' | 'NO' | number acceptorAmount: number + question: string } export function CreateChallengeModal(props: { user: User | null | undefined - contract: BinaryContract isOpen: boolean setOpen: (open: boolean) => void + contract?: BinaryContract }) { const { user, contract, isOpen, setOpen } = props const [challengeSlug, setChallengeSlug] = useState('') + const [loading, setLoading] = useState(false) + const { editor } = useTextEditor({ placeholder: '' }) return ( @@ -46,24 +54,42 @@ export function CreateChallengeModal(props: { { - const challenge = await createChallenge({ - creator: user, - creatorAmount: newChallenge.amount, - expiresTime: newChallenge.expiresTime, - message: newChallenge.message, - acceptorAmount: newChallenge.acceptorAmount, - outcome: newChallenge.outcome, - contract: contract, - }) - if (challenge) { - setChallengeSlug(getChallengeUrl(challenge)) - track('challenge created', { - creator: user.username, - amount: newChallenge.amount, - contractId: contract.id, + setLoading(true) + try { + const challengeContract = contract + ? contract + : await createMarket( + removeUndefinedProps({ + question: newChallenge.question, + outcomeType: 'BINARY', + initialProb: 50, + description: editor?.getJSON(), + ante: FIXED_ANTE, + closeTime: dayjs().add(30, 'day').valueOf(), + }) + ) + const challenge = await createChallenge({ + creator: user, + creatorAmount: newChallenge.amount, + expiresTime: newChallenge.expiresTime, + acceptorAmount: newChallenge.acceptorAmount, + outcome: newChallenge.outcome, + contract: challengeContract as BinaryContract, }) + if (challenge) { + setChallengeSlug(getChallengeUrl(challenge)) + track('challenge created', { + creator: user.username, + amount: newChallenge.amount, + contractId: challengeContract.id, + }) + } + } catch (e) { + console.error("couldn't create market/challenge:", e) } + setLoading(false) }} challengeSlug={challengeSlug} /> @@ -75,25 +101,24 @@ export function CreateChallengeModal(props: { function CreateChallengeForm(props: { user: User - contract: BinaryContract onCreate: (m: challengeInfo) => Promise challengeSlug: string + loading: boolean + contract?: BinaryContract }) { - const { user, onCreate, contract, challengeSlug } = props + const { user, onCreate, contract, challengeSlug, loading } = props const [isCreating, setIsCreating] = useState(false) const [finishedCreating, setFinishedCreating] = useState(false) const [error, setError] = useState('') 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({ expiresTime: dayjs().add(2, defaultExpire).valueOf(), outcome: 'YES', amount: 100, acceptorAmount: 100, - message: defaultMessage, + question: contract ? contract.question : '', }) useEffect(() => { setError('') @@ -106,7 +131,15 @@ function CreateChallengeForm(props: { onSubmit={(e) => { e.preventDefault() if (user.balance < challengeInfo.amount) { - setError('You do not have enough mana to create this challenge') + setError("You don't have enough mana to create this challenge") + return + } + if (!contract && user.balance < FIXED_ANTE + challengeInfo.amount) { + setError( + `You don't have enough mana to create this challenge and market. You need ${formatMoney( + FIXED_ANTE + challengeInfo.amount + )}` + ) return } setIsCreating(true) @@ -118,7 +151,23 @@ function CreateChallengeForm(props: {
Challenge a friend to bet on{' '} - {contract.question} + {contract ? ( + {contract.question} + ) : ( +
+ + + + + + +
- -
+ +
- - - - - - -
+ + + + + + + - - -
+
+

+ Hi {{name}},

+
+
-
+
-

+

- + 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! -

-

-   -

-

- Congrats on creating your first market on Manifold! +

+ +

+ Manifold also gives its creators 10 Mana for + ">The following is a short guide to creating markets. +

+

+   +

+

+ What makes a good market? +

+
    +
  • + Interesting + topic. Manifold gives + creators M$10 for each unique trader that bets on your - market! -

    -

    -   -

    -

    - + Clear resolution criteria. Any ambiguities or edge cases in your description + will drive traders away from your markets. +

  • + +
  • + Detailed description. Include images/videos/tweets and any context or + background + information that could be useful to people who + are interested in learning more that are + uneducated on the subject. +
  • +
  • + Add it to a group. Groups are the + primary way users filter for relevant markets. + Also, consider making your own groups and + inviting friends/interested communities to + them from other sites! +
  • +
  • + Share it on social media. You'll earn the M$500 + referral bonus if you get new users to sign up! +
  • +
+

+   +

+

+ What makes a good question? -

- +

+   +

+

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

-

- + Thanks for reading! -

-

Thanks for reading! +

+

- + David from Manifold -

-
-
- - -
-
- -
- - - - + + +
David from Manifold +

+ +
+
+ +
+ + +
+ + + +
- - -
- - - - + + +
+ + +
+ + + + - - -
- -
+ +
- - - - - - -
- + "> +
-
+ + + + - - - + + + + "> + + +
-
+
-

- This e-mail has been sent to {{name}}, - +

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

-
-
click here to unsubscribe. +

+ +
+
-
-
- -
-
- + + +
+
+ - - + + + \ No newline at end of file diff --git a/functions/src/email-templates/welcome.html b/functions/src/email-templates/welcome.html index 74bd6a94..0ffafbd5 100644 --- a/functions/src/email-templates/welcome.html +++ b/functions/src/email-templates/welcome.html @@ -188,6 +188,56 @@
+
+

Did + you know, besides betting and making predictions, you can also create + your + own + market on + any question you care about?

+ +

More resources:

+ + + +

 

+

Cheers, +

+

David + from Manifold

+

 

+
+
diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 7169e80f..97ffce10 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,3 +1,5 @@ +import * as dayjs from 'dayjs' + import { DOMAIN } from '../../common/envs/constants' import { Answer } from '../../common/answer' import { Bet } from '../../common/bet' @@ -14,7 +16,7 @@ import { import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' -import { sendTemplateEmail } from './send-email' +import { sendTemplateEmail, sendTextEmail } from './send-email' import { getPrivateUser, getUser } from './utils' import { getFunctionUrl } from '../../common/api' import { richTextToString } from '../../common/util/parse' @@ -166,6 +168,43 @@ export const sendWelcomeEmail = async ( ) } +export const sendPersonalFollowupEmail = async ( + user: User, + privateUser: PrivateUser +) => { + if (!privateUser || !privateUser.email) return + + const { name } = user + const firstName = name.split(' ')[0] + + const emailBody = `Hi ${firstName}, + +Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your exprience on the platform so far? + +If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh). + +Feel free to reply to this email with any questions or concerns you have. + +Cheers, + +James +Cofounder of Manifold Markets +https://manifold.markets + ` + + const sendTime = dayjs().add(4, 'hours').toString() + + await sendTextEmail( + privateUser.email, + 'How are you finding Manifold?', + emailBody, + { + from: 'James from Manifold ', + 'o:deliverytime': sendTime, + } + ) +} + export const sendOneWeekBonusEmail = async ( user: User, privateUser: PrivateUser @@ -198,6 +237,37 @@ export const sendOneWeekBonusEmail = async ( ) } +export const sendCreatorGuideEmail = async ( + user: User, + privateUser: PrivateUser +) => { + if ( + !privateUser || + !privateUser.email || + privateUser.unsubscribedFromGenericEmails + ) + return + + const { name, id: userId } = user + const firstName = name.split(' ')[0] + + const emailType = 'generic' + const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + + return await sendTemplateEmail( + privateUser.email, + 'Market creation guide', + 'creating-market', + { + name: firstName, + unsubscribeLink, + }, + { + from: 'David from Manifold ', + } + ) +} + export const sendThankYouEmail = async ( user: User, privateUser: PrivateUser diff --git a/functions/src/index.ts b/functions/src/index.ts index 10aaf456..ec1947f1 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -26,6 +26,8 @@ export * from './on-create-txn' export * from './on-delete-group' export * from './score-contracts' export * from './weekly-markets-emails' +export * from './reset-betting-streaks' + // v2 export * from './health' @@ -38,7 +40,7 @@ export * from './cancel-bet' export * from './sell-bet' export * from './sell-shares' export * from './claim-manalink' -export * from './create-contract' +export * from './create-market' export * from './add-liquidity' export * from './withdraw-liquidity' export * from './create-group' @@ -57,7 +59,7 @@ import { cancelbet } from './cancel-bet' import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' -import { createmarket } from './create-contract' +import { createmarket } from './create-market' import { addliquidity } from './add-liquidity' import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index d33e71dd..c5648293 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -3,15 +3,20 @@ import * as admin from 'firebase-admin' import { keyBy, uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' -import { getContract, getUser, getValues, isProd, log } from './utils' +import { getUser, getValues, isProd, log } from './utils' import { createBetFillNotification, + createBettingStreakBonusNotification, createNotification, } from './create-notification' import { filterDefined } from '../../common/util/array' import { Contract } from '../../common/contract' import { runTxn, TxnData } from './transact' -import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants' +import { + BETTING_STREAK_BONUS_AMOUNT, + BETTING_STREAK_RESET_HOUR, + UNIQUE_BETTOR_BONUS_AMOUNT, +} from '../../common/numeric-constants' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, @@ -38,37 +43,99 @@ export const onCreateBet = functions.firestore .doc(contractId) .update({ lastBetTime, lastUpdatedTime: Date.now() }) - await notifyFills(bet, contractId, eventId) - await updateUniqueBettorsAndGiveCreatorBonus( - contractId, - eventId, - bet.userId - ) + const userContractSnap = await firestore + .collection(`contracts`) + .doc(contractId) + .get() + const contract = userContractSnap.data() as Contract + + if (!contract) { + log(`Could not find contract ${contractId}`) + return + } + await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId) + + const bettor = await getUser(bet.userId) + if (!bettor) return + + await notifyFills(bet, contract, eventId, bettor) + await updateBettingStreak(bettor, bet, contract, eventId) + + await firestore.collection('users').doc(bettor.id).update({ lastBetTime }) }) +const updateBettingStreak = async ( + user: User, + bet: Bet, + contract: Contract, + eventId: string +) => { + const betStreakResetTime = getTodaysBettingStreakResetTime() + const lastBetTime = user?.lastBetTime ?? 0 + + // If they've already bet after the reset time, or if we haven't hit the reset time yet + if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime) + return + + const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1 + // Otherwise, add 1 to their betting streak + await firestore.collection('users').doc(user.id).update({ + currentBettingStreak: newBettingStreak, + }) + + // Send them the bonus times their streak + const bonusAmount = Math.min( + BETTING_STREAK_BONUS_AMOUNT * newBettingStreak, + 100 + ) + const fromUserId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + const bonusTxnDetails = { + currentBettingStreak: newBettingStreak, + } + const result = await firestore.runTransaction(async (trans) => { + const bonusTxn: TxnData = { + fromId: fromUserId, + fromType: 'BANK', + toId: user.id, + toType: 'USER', + amount: bonusAmount, + token: 'M$', + category: 'BETTING_STREAK_BONUS', + description: JSON.stringify(bonusTxnDetails), + } + return await runTxn(trans, bonusTxn) + }) + if (!result.txn) { + log("betting streak bonus txn couldn't be made") + return + } + + await createBettingStreakBonusNotification( + user, + result.txn.id, + bet, + contract, + bonusAmount, + eventId + ) +} + const updateUniqueBettorsAndGiveCreatorBonus = async ( - contractId: string, + contract: Contract, eventId: string, bettorId: string ) => { - const userContractSnap = await firestore - .collection(`contracts`) - .doc(contractId) - .get() - const contract = userContractSnap.data() as Contract - if (!contract) { - log(`Could not find contract ${contractId}`) - return - } let previousUniqueBettorIds = contract.uniqueBettorIds if (!previousUniqueBettorIds) { const contractBets = ( - await firestore.collection(`contracts/${contractId}/bets`).get() + await firestore.collection(`contracts/${contract.id}/bets`).get() ).docs.map((doc) => doc.data() as Bet) if (contractBets.length === 0) { - log(`No bets for contract ${contractId}`) + log(`No bets for contract ${contract.id}`) return } @@ -86,7 +153,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( if (!contract.uniqueBettorIds || isNewUniqueBettor) { log(`Got ${previousUniqueBettorIds} unique bettors`) isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) - await firestore.collection(`contracts`).doc(contractId).update({ + await firestore.collection(`contracts`).doc(contract.id).update({ uniqueBettorIds: newUniqueBettorIds, uniqueBettorCount: newUniqueBettorIds.length, }) @@ -97,7 +164,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( // Create combined txn for all new unique bettors const bonusTxnDetails = { - contractId: contractId, + contractId: contract.id, uniqueBettorIds: newUniqueBettorIds, } const fromUserId = isProd() @@ -140,14 +207,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( } } -const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { +const notifyFills = async ( + bet: Bet, + contract: Contract, + eventId: string, + user: User +) => { if (!bet.fills) return - const user = await getUser(bet.userId) - if (!user) return - const contract = await getContract(contractId) - if (!contract) return - const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null) const matchedBets = ( await Promise.all( @@ -180,3 +247,7 @@ const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { }) ) } + +const getTodaysBettingStreakResetTime = () => { + return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) +} diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 3fa0983d..9f19dfcc 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { compact, uniq } from 'lodash' import { getContract, getUser, getValues } from './utils' -import { Comment } from '../../common/comment' +import { ContractComment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' @@ -29,7 +29,7 @@ export const onCreateCommentOnContract = functions contractQuestion: contract.question, }) - const comment = change.data() as Comment + const comment = change.data() as ContractComment const lastCommentTime = comment.createdTime const commentCreator = await getUser(comment.userId) @@ -64,7 +64,7 @@ export const onCreateCommentOnContract = functions : undefined } - const comments = await getValues( + const comments = await getValues( firestore.collection('contracts').doc(contractId).collection('comments') ) const relatedSourceType = comment.replyToCommentId diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts index 0064480f..15f2bbc1 100644 --- a/functions/src/on-create-comment-on-group.ts +++ b/functions/src/on-create-comment-on-group.ts @@ -1,5 +1,5 @@ import * as functions from 'firebase-functions' -import { Comment } from '../../common/comment' +import { GroupComment } from '../../common/comment' import * as admin from 'firebase-admin' import { Group } from '../../common/group' import { User } from '../../common/user' @@ -14,7 +14,7 @@ export const onCreateCommentOnGroup = functions.firestore groupId: string } - const comment = change.data() as Comment + const comment = change.data() as GroupComment const creatorSnapshot = await firestore .collection('users') .doc(comment.userId) diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index 6b57a9a0..73076b7f 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,12 +1,17 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' +import * as admin from 'firebase-admin' + +import { getPrivateUser, getUser } from './utils' import { createNotification } from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' +import { User } from 'common/user' +import { sendCreatorGuideEmail } from './emails' -export const onCreateContract = functions.firestore - .document('contracts/{contractId}') +export const onCreateContract = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .firestore.document('contracts/{contractId}') .onCreate(async (snapshot, context) => { const contract = snapshot.data() as Contract const { eventId } = context @@ -26,4 +31,23 @@ export const onCreateContract = functions.firestore richTextToString(desc), { contract, recipients: mentioned } ) + + await sendGuideEmail(contractCreator) }) + +const firestore = admin.firestore() + +const sendGuideEmail = async (contractCreator: User) => { + const query = await firestore + .collection(`contracts`) + .where('creatorId', '==', contractCreator.id) + .limit(2) + .get() + + if (query.size >= 2) return + + const privateUser = await getPrivateUser(contractCreator.id) + if (!privateUser) return + + await sendCreatorGuideEmail(contractCreator, privateUser) +} diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 8fb5179d..780b50d6 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -30,15 +30,7 @@ const bodySchema = z.object({ const binarySchema = z.object({ outcome: z.enum(['YES', 'NO']), - limitProb: z - .number() - .gte(0.001) - .lte(0.999) - .refine( - (p) => Math.round(p * 100) === p * 100, - 'limitProb must be in increments of 0.01 (i.e. whole percentage points)' - ) - .optional(), + limitProb: z.number().gte(0.001).lte(0.999).optional(), }) const freeResponseSchema = z.object({ @@ -90,7 +82,22 @@ export const placebet = newEndpoint({}, async (req, auth) => { (outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && mechanism == 'cpmm-1' ) { - const { outcome, limitProb } = validate(binarySchema, req.body) + // eslint-disable-next-line prefer-const + let { outcome, limitProb } = validate(binarySchema, req.body) + + if (limitProb !== undefined && outcomeType === 'BINARY') { + const isRounded = floatingEqual( + Math.round(limitProb * 100), + limitProb * 100 + ) + if (!isRounded) + throw new APIError( + 400, + 'limitProb must be in increments of 0.01 (i.e. whole percentage points)' + ) + + limitProb = Math.round(limitProb * 100) / 100 + } const unfilledBetsSnap = await trans.get( getUnfilledBetsQuery(contractDoc) diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts new file mode 100644 index 00000000..0600fa56 --- /dev/null +++ b/functions/src/reset-betting-streaks.ts @@ -0,0 +1,38 @@ +// check every day if the user has created a bet since 4pm UTC, and if not, reset their streak + +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { User } from '../../common/user' +import { DAY_MS } from '../../common/util/time' +import { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants' +const firestore = admin.firestore() + +export const resetBettingStreaksForUsers = functions.pubsub + .schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`) + .onRun(async () => { + await resetBettingStreaksInternal() + }) + +const resetBettingStreaksInternal = async () => { + const usersSnap = await firestore.collection('users').get() + + const users = usersSnap.docs.map((doc) => doc.data() as User) + + for (const user of users) { + await resetBettingStreakForUser(user) + } +} + +const resetBettingStreakForUser = async (user: User) => { + const betStreakResetTime = Date.now() - DAY_MS + // if they made a bet within the last day, don't reset their streak + if ( + (user.lastBetTime ?? 0 > betStreakResetTime) || + !user.currentBettingStreak || + user.currentBettingStreak === 0 + ) + return + await firestore.collection('users').doc(user.id).update({ + currentBettingStreak: 0, + }) +} diff --git a/functions/src/scripts/backfill-comment-types.ts b/functions/src/scripts/backfill-comment-types.ts new file mode 100644 index 00000000..6b61170e --- /dev/null +++ b/functions/src/scripts/backfill-comment-types.ts @@ -0,0 +1,31 @@ +// Comment types were introduced in August 2022. + +import { initAdmin } from './script-init' +import { log, writeAsync } from '../utils' + +if (require.main === module) { + const app = initAdmin() + const firestore = app.firestore() + const commentsRef = firestore.collectionGroup('comments') + commentsRef.get().then(async (commentsSnaps) => { + log(`Loaded ${commentsSnaps.size} comments.`) + const needsFilling = commentsSnaps.docs.filter((ct) => { + return !('commentType' in ct.data()) + }) + log(`Found ${needsFilling.length} comments to update.`) + const updates = needsFilling.map((d) => { + const comment = d.data() + const fields: { [k: string]: unknown } = {} + if (comment.contractId != null && comment.groupId == null) { + fields.commentType = 'contract' + } else if (comment.groupId != null && comment.contractId == null) { + fields.commentType = 'group' + } else { + log(`Invalid comment ${comment}; not touching it.`) + } + return { doc: d.ref, fields, info: comment } + }) + await writeAsync(firestore, updates) + log(`Updated all comments.`) + }) +} diff --git a/functions/src/send-email.ts b/functions/src/send-email.ts index 7a643418..cb054aa7 100644 --- a/functions/src/send-email.ts +++ b/functions/src/send-email.ts @@ -9,10 +9,12 @@ const initMailgun = () => { export const sendTextEmail = async ( to: string, subject: string, - text: string + text: string, + options?: Partial ) => { const data: mailgun.messages.SendData = { - from: 'Manifold Markets ', + ...options, + from: options?.from ?? 'Manifold Markets ', to, subject, text, diff --git a/functions/src/serve.ts b/functions/src/serve.ts index bf96db20..8d848f7f 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -18,7 +18,7 @@ import { cancelbet } from './cancel-bet' import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' -import { createmarket } from './create-contract' +import { createmarket } from './create-market' import { addliquidity } from './add-liquidity' import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' diff --git a/web/.eslintrc.js b/web/.eslintrc.js index fec650f9..0f103080 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -5,6 +5,7 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', 'plugin:@next/next/recommended', + 'prettier', ], rules: { '@typescript-eslint/no-empty-function': 'off', diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 426a9371..cb071850 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -3,7 +3,6 @@ import React from 'react' import { useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { Col } from './layout/col' -import { Spacer } from './layout/spacer' import { SiteLink } from './site-link' import { ENV_CONFIG } from 'common/envs/constants' @@ -37,7 +36,7 @@ export function AmountInput(props: { return (