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 a217b292..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,4 +18,21 @@ export type Comment = { userName: string userUsername: string userAvatarUrl?: 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/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/util/array.ts b/common/util/array.ts index 8a429262..fd5efcc6 100644 --- a/common/util/array.ts +++ b/common/util/array.ts @@ -1,3 +1,5 @@ +import { isEqual } from 'lodash' + export function filterDefined(array: (T | null | undefined)[]) { return array.filter((item) => item !== null && item !== undefined) as T[] } @@ -26,7 +28,7 @@ export function groupConsecutive(xs: T[], key: (x: T) => U) { let curr = { key: key(xs[0]), items: [xs[0]] } for (const x of xs.slice(1)) { const k = key(x) - if (k !== curr.key) { + if (!isEqual(key, curr.key)) { result.push(curr) curr = { key: k, items: [x] } } else { 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/firestore.indexes.json b/firestore.indexes.json index 12e88033..874344be 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -496,6 +496,28 @@ } ] }, + { + "collectionGroup": "comments", + "fieldPath": "contractId", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, { "collectionGroup": "comments", "fieldPath": "createdTime", 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/analytics.ts b/functions/src/analytics.ts index d178ee0f..e0199616 100644 --- a/functions/src/analytics.ts +++ b/functions/src/analytics.ts @@ -3,7 +3,7 @@ import * as Amplitude from '@amplitude/node' import { DEV_CONFIG } from '../../common/envs/dev' import { PROD_CONFIG } from '../../common/envs/prod' -import { isProd } from './utils' +import { isProd, tryOrLogError } from './utils' const key = isProd() ? PROD_CONFIG.amplitudeApiKey : DEV_CONFIG.amplitudeApiKey @@ -15,10 +15,12 @@ export const track = async ( eventProperties?: any, amplitudeProperties?: Partial ) => { - await amp.logEvent({ - event_type: eventName, - user_id: userId, - event_properties: eventProperties, - ...amplitudeProperties, - }) + return await tryOrLogError( + amp.logEvent({ + event_type: eventName, + user_id: userId, + event_properties: eventProperties, + ...amplitudeProperties, + }) + ) } diff --git a/functions/src/create-contract.ts b/functions/src/create-market.ts similarity index 91% rename from functions/src/create-contract.ts rename to functions/src/create-market.ts index 44ced6a8..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(), @@ -133,41 +135,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { if (ante > user.balance) throw new APIError(400, `Balance must be at least ${ante}.`) - const slug = await getSlug(question) - const contractRef = firestore.collection('contracts').doc() - - console.log( - 'creating contract for', - user.username, - 'on', - question, - 'ante:', - ante || 0 - ) - - const contract = getNewContract( - contractRef.id, - slug, - user, - question, - outcomeType, - description ?? {}, - initialProb ?? 0, - ante, - closeTime.getTime(), - tags ?? [], - NUMERIC_BUCKET_COUNT, - min ?? 0, - max ?? 0, - isLogScale ?? false, - answers ?? [] - ) - - if (ante) await chargeUser(user.id, ante, true) - - await contractRef.create(contract) - - let group = null + let group: Group | null = null if (groupId) { const groupDocRef = firestore.collection('groups').doc(groupId) const groupDoc = await groupDocRef.get() @@ -186,15 +154,68 @@ export const createmarket = newEndpoint({}, async (req, auth) => { 'User must be a member/creator of the group or group must be open to add markets to it.' ) } + } + const slug = await getSlug(question) + const contractRef = firestore.collection('contracts').doc() + + console.log( + 'creating contract for', + user.username, + 'on', + question, + 'ante:', + 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, + newDescription, + initialProb ?? 0, + ante, + closeTime.getTime(), + tags ?? [], + NUMERIC_BUCKET_COUNT, + min ?? 0, + max ?? 0, + isLogScale ?? false, + answers ?? [] + ) + + if (ante) await chargeUser(user.id, ante, true) + + await contractRef.create(contract) + + if (group != null) { if (!group.contractIds.includes(contractRef.id)) { await createGroupLinks(group, [contractRef.id], auth.uid) - await groupDocRef.update({ + const groupDocRef = firestore.collection('groups').doc(group.id) + groupDocRef.update({ contractIds: uniq([...group.contractIds, contractRef.id]), }) } } - 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-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) - - - - - - - + + + + + + - - - - - - - - - + + - + + + - - - -
- -
+
+ +
- - - - + + +
+ + + + + + +
- -
+ +
- - - - - - -
+ + + + - - -
- +
- - - + + - - -
- +
+ -
-
- - -
-
- -
+
+
+
+ + + + + +
+ +
- - - - + + + 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 a097393e..acab22d8 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' @@ -74,9 +76,8 @@ export const sendMarketResolutionEmail = async ( // Modify template here: // https://app.mailgun.com/app/sending/domains/mg.manifold.markets/templates/edit/market-resolved/initial - // Mailgun username: james@mantic.markets - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, subject, 'market-resolved', @@ -152,7 +153,7 @@ export const sendWelcomeEmail = async ( const emailType = 'generic' const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, 'Welcome to Manifold Markets!', 'welcome', @@ -166,6 +167,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 @@ -183,7 +221,7 @@ export const sendOneWeekBonusEmail = async ( const emailType = 'generic' const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, 'Manifold Markets one week anniversary gift', 'one-week', @@ -198,6 +236,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 @@ -215,7 +284,7 @@ export const sendThankYouEmail = async ( const emailType = 'generic' const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, 'Thanks for your Manifold purchase', 'thank-you', @@ -250,7 +319,7 @@ export const sendMarketCloseEmail = async ( const emailType = 'market-resolve' const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, 'Your market has closed', 'market-close', @@ -309,7 +378,7 @@ export const sendNewCommentEmail = async ( if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { const answerNumber = `#${answerId}` - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, subject, 'market-answer-comment', @@ -332,7 +401,7 @@ export const sendNewCommentEmail = async ( bet.outcome )}` } - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, subject, 'market-comment', @@ -377,7 +446,7 @@ export const sendNewAnswerEmail = async ( const subject = `New answer on ${question}` const from = `${name} ` - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, subject, 'market-answer', diff --git a/functions/src/index.ts b/functions/src/index.ts index 4c8a6782..6d1f0c01 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -38,7 +38,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 +57,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-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index d7aa0c5e..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' @@ -24,7 +24,12 @@ export const onCreateCommentOnContract = functions if (!contract) throw new Error('Could not find contract corresponding with comment') - const comment = change.data() as Comment + await change.ref.update({ + contractSlug: contract.slug, + contractQuestion: contract.question, + }) + + const comment = change.data() as ContractComment const lastCommentTime = comment.createdTime const commentCreator = await getUser(comment.userId) @@ -59,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 a9ada863..44a96210 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({ @@ -89,7 +81,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/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/scripts/denormalize-comment-contract-data.ts b/functions/src/scripts/denormalize-comment-contract-data.ts new file mode 100644 index 00000000..0358c5a1 --- /dev/null +++ b/functions/src/scripts/denormalize-comment-contract-data.ts @@ -0,0 +1,70 @@ +// Filling in the contract-based fields on comments. + +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { + DocumentCorrespondence, + findDiffs, + describeDiff, + applyDiff, +} from './denormalize' +import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' + +initAdmin() +const firestore = admin.firestore() + +async function getContractsById(transaction: Transaction) { + const contracts = await transaction.get(firestore.collection('contracts')) + const results = Object.fromEntries(contracts.docs.map((doc) => [doc.id, doc])) + console.log(`Found ${contracts.size} contracts.`) + return results +} + +async function getCommentsByContractId(transaction: Transaction) { + const comments = await transaction.get( + firestore.collectionGroup('comments').where('contractId', '!=', null) + ) + const results = new Map() + comments.forEach((doc) => { + const contractId = doc.get('contractId') + const contractComments = results.get(contractId) || [] + contractComments.push(doc) + results.set(contractId, contractComments) + }) + console.log(`Found ${comments.size} comments on ${results.size} contracts.`) + return results +} + +async function denormalize() { + let hasMore = true + while (hasMore) { + hasMore = await admin.firestore().runTransaction(async (transaction) => { + const [contractsById, commentsByContractId] = await Promise.all([ + getContractsById(transaction), + getCommentsByContractId(transaction), + ]) + const mapping = Object.entries(contractsById).map( + ([id, doc]): DocumentCorrespondence => { + return [doc, commentsByContractId.get(id) || []] + } + ) + const slugDiffs = findDiffs(mapping, 'slug', 'contractSlug') + const qDiffs = findDiffs(mapping, 'question', 'contractQuestion') + console.log(`Found ${slugDiffs.length} comments with mismatched slugs.`) + console.log(`Found ${qDiffs.length} comments with mismatched questions.`) + const diffs = slugDiffs.concat(qDiffs) + diffs.slice(0, 500).forEach((d) => { + console.log(describeDiff(d)) + applyDiff(transaction, d) + }) + if (diffs.length > 500) { + console.log(`Applying first 500 because of Firestore limit...`) + } + return diffs.length > 500 + }) + } +} + +if (require.main === module) { + denormalize().catch((e) => console.error(e)) +} diff --git a/functions/src/send-email.ts b/functions/src/send-email.ts index 7ff4c047..cb054aa7 100644 --- a/functions/src/send-email.ts +++ b/functions/src/send-email.ts @@ -1,27 +1,35 @@ import * as mailgun from 'mailgun-js' +import { tryOrLogError } from './utils' const initMailgun = () => { const apiKey = process.env.MAILGUN_KEY as string return mailgun({ apiKey, domain: 'mg.manifold.markets' }) } -export const sendTextEmail = (to: string, subject: string, text: string) => { +export const sendTextEmail = async ( + to: string, + subject: string, + text: string, + options?: Partial +) => { const data: mailgun.messages.SendData = { - from: 'Manifold Markets ', + ...options, + from: options?.from ?? 'Manifold Markets ', to, subject, text, // Don't rewrite urls in plaintext emails 'o:tracking-clicks': 'htmlonly', } - const mg = initMailgun() - return mg.messages().send(data, (error) => { - if (error) console.log('Error sending email', error) - else console.log('Sent text email', to, subject) - }) + const mg = initMailgun().messages() + const result = await tryOrLogError(mg.send(data)) + if (result != null) { + console.log('Sent text email', to, subject) + } + return result } -export const sendTemplateEmail = ( +export const sendTemplateEmail = async ( to: string, subject: string, templateId: string, @@ -38,10 +46,10 @@ export const sendTemplateEmail = ( 'o:tag': templateId, 'o:tracking': true, } - 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) - }) + const mg = initMailgun().messages() + const result = await tryOrLogError(mg.send(data)) + if (result != null) { + console.log('Sent template email', templateId, to, subject) + } + return result } 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/functions/src/utils.ts b/functions/src/utils.ts index 0414b01e..721f33d0 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -42,6 +42,15 @@ export const writeAsync = async ( } } +export const tryOrLogError = async (task: Promise) => { + try { + return await task + } catch (e) { + console.error(e) + return null + } +} + export const isProd = () => { return admin.instanceId().app.options.projectId === 'mantic-markets' } 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/avatar.tsx b/web/components/avatar.tsx index 6ca06cbb..55cf3169 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -1,6 +1,6 @@ import Router from 'next/router' import clsx from 'clsx' -import { MouseEvent, useState } from 'react' +import { MouseEvent, useEffect, useState } from 'react' import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid' export function Avatar(props: { @@ -12,6 +12,7 @@ export function Avatar(props: { }) { const { username, noLink, size, className } = props const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) + useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl]) const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const onClick = 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 (