Merge branch 'main' into loans2

This commit is contained in:
James Grugett 2022-08-19 11:55:42 -05:00
commit d79783ea25
90 changed files with 1420 additions and 1620 deletions

View File

@ -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 Funds 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 Funds 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 states 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 {

View File

@ -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<T extends AnyCommentType = AnyCommentType> = {
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<OnContract>
export type GroupComment = Comment<OnGroup>

View File

@ -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]])
)
}

View File

@ -1,3 +1,5 @@
import { isEqual } from 'lodash'
export function filterDefined<T>(array: (T | null | undefined)[]) {
return array.filter((item) => item !== null && item !== undefined) as T[]
}
@ -26,7 +28,7 @@ export function groupConsecutive<T, U>(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 {

View File

@ -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.

View File

@ -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

View File

@ -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",

View File

@ -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 }
})

View File

@ -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<Amplitude.Event>
) => {
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,
})
)
}

View File

@ -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<JSONContent> = 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

View File

@ -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 }

View File

@ -128,7 +128,20 @@
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; text-align: center; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Hi {{name}},</span></p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for
using Manifold Markets. Running low
@ -161,6 +174,51 @@
</table>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
you know, besides making correct predictions, there are
plenty of other ways to earn mana?</span></p>
<ul>
<li style="line-height:23px;"><span
style="font-family:Arial, sans-serif;font-size:18px;">Receiving
tips on comments</span></li>
<li style="line-height:23px;"><span
style="font-family:Arial, sans-serif;font-size:18px;">Unique
trader bonus for each user who bets on your
markets</span></li>
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank" href="https://manifold.markets/referrals"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Referring
friends</u></span></a></span></li>
<li style="line-height:23px;"><a class="link-build-content"
style="color:inherit;; text-decoration: none;" target="_blank"
href="https://manifold.markets/group/bugs?s=most-traded"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting
bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;">
and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank"
href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving
feedback</u></span></a></li>
</ul>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;">&nbsp;</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">David
from Manifold</span></p>
<p class="text-build-content" data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-bottom: 10px;">&nbsp;</p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">

File diff suppressed because it is too large Load Diff

View File

@ -188,6 +188,56 @@
</table>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
you know, besides betting and making predictions, you can also <a
class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank" href="https://manifold.markets/create"><span
style="color:#55575d;font-family:Arial;font-size:18px;font-weight: bold;"><u>create
your
own
market</u></span></a> on
any question you care about?</span></p>
<p>More resources:</p>
<ul>
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank" href="https://manifold.markets/about"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Learn more</u></span></a>
about Manifold and how our markets work</span></li>
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank" href="https://manifold.markets/referrals"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Refer
your friends</u></span></a> and earn M$500 for each signup!</span></li>
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank" href="https://discord.com/invite/eHQBNBqXuh"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Join our Discord
chat</u></span></a></span></li>
</ul>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;">&nbsp;</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">David
from Manifold</span></p>
<p class="text-build-content" data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-bottom: 10px;">&nbsp;</p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">

View File

@ -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 <james@manifold.markets>',
'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 <david@manifold.markets>',
}
)
}
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} <info@manifold.markets>`
await sendTemplateEmail(
return await sendTemplateEmail(
privateUser.email,
subject,
'market-answer',

View File

@ -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'

View File

@ -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<Comment>(
const comments = await getValues<ContractComment>(
firestore.collection('contracts').doc(contractId).collection('comments')
)
const relatedSourceType = comment.replyToCommentId

View File

@ -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)

View File

@ -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)
}

View File

@ -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)

View File

@ -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.`)
})
}

View File

@ -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<string, DocumentSnapshot[]>()
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))
}

View File

@ -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<mailgun.messages.SendData>
) => {
const data: mailgun.messages.SendData = {
from: 'Manifold Markets <info@manifold.markets>',
...options,
from: options?.from ?? 'Manifold Markets <info@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
}

View File

@ -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'

View File

@ -42,6 +42,15 @@ export const writeAsync = async (
}
}
export const tryOrLogError = async <T>(task: Promise<T>) => {
try {
return await task
} catch (e) {
console.error(e)
return null
}
}
export const isProd = () => {
return admin.instanceId().app.options.projectId === 'mantic-markets'
}

View File

@ -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',

View File

@ -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 =

View File

@ -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 (
<Col className={hidden ? 'hidden' : ''}>
<Row className="mt-1 items-center gap-4">

View File

@ -11,7 +11,7 @@ import { User } from 'common/user'
import { Modal } from 'web/components/layout/modal'
import { Button } from '../button'
import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
import { BinaryContract } from 'common/contract'
import { BinaryContract, MAX_QUESTION_LENGTH } from 'common/contract'
import { SiteLink } from 'web/components/site-link'
import { formatMoney } from 'common/util/format'
import { NoLabel, YesLabel } from '../outcome-label'
@ -19,24 +19,32 @@ import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy'
import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/antes'
import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics'
type challengeInfo = {
amount: number
expiresTime: number | null
message: string
outcome: 'YES' | 'NO' | number
acceptorAmount: number
question: string
}
export function CreateChallengeModal(props: {
user: User | null | undefined
contract: BinaryContract
isOpen: boolean
setOpen: (open: boolean) => void
contract?: BinaryContract
}) {
const { user, contract, isOpen, setOpen } = props
const [challengeSlug, setChallengeSlug] = useState('')
const [loading, setLoading] = useState(false)
const { editor } = useTextEditor({ placeholder: '' })
return (
<Modal open={isOpen} setOpen={setOpen}>
@ -46,24 +54,42 @@ export function CreateChallengeModal(props: {
<CreateChallengeForm
user={user}
contract={contract}
loading={loading}
onCreate={async (newChallenge) => {
const challenge = await createChallenge({
creator: user,
creatorAmount: newChallenge.amount,
expiresTime: newChallenge.expiresTime,
message: newChallenge.message,
acceptorAmount: newChallenge.acceptorAmount,
outcome: newChallenge.outcome,
contract: contract,
})
if (challenge) {
setChallengeSlug(getChallengeUrl(challenge))
track('challenge created', {
creator: user.username,
amount: newChallenge.amount,
contractId: contract.id,
setLoading(true)
try {
const challengeContract = contract
? contract
: await createMarket(
removeUndefinedProps({
question: newChallenge.question,
outcomeType: 'BINARY',
initialProb: 50,
description: editor?.getJSON(),
ante: FIXED_ANTE,
closeTime: dayjs().add(30, 'day').valueOf(),
})
)
const challenge = await createChallenge({
creator: user,
creatorAmount: newChallenge.amount,
expiresTime: newChallenge.expiresTime,
acceptorAmount: newChallenge.acceptorAmount,
outcome: newChallenge.outcome,
contract: challengeContract as BinaryContract,
})
if (challenge) {
setChallengeSlug(getChallengeUrl(challenge))
track('challenge created', {
creator: user.username,
amount: newChallenge.amount,
contractId: challengeContract.id,
})
}
} catch (e) {
console.error("couldn't create market/challenge:", e)
}
setLoading(false)
}}
challengeSlug={challengeSlug}
/>
@ -75,25 +101,24 @@ export function CreateChallengeModal(props: {
function CreateChallengeForm(props: {
user: User
contract: BinaryContract
onCreate: (m: challengeInfo) => Promise<void>
challengeSlug: string
loading: boolean
contract?: BinaryContract
}) {
const { user, onCreate, contract, challengeSlug } = props
const { user, onCreate, contract, challengeSlug, loading } = props
const [isCreating, setIsCreating] = useState(false)
const [finishedCreating, setFinishedCreating] = useState(false)
const [error, setError] = useState<string>('')
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
const defaultExpire = 'week'
const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}`
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
outcome: 'YES',
amount: 100,
acceptorAmount: 100,
message: defaultMessage,
question: contract ? contract.question : '',
})
useEffect(() => {
setError('')
@ -106,7 +131,15 @@ function CreateChallengeForm(props: {
onSubmit={(e) => {
e.preventDefault()
if (user.balance < challengeInfo.amount) {
setError('You do not have enough mana to create this challenge')
setError("You don't have enough mana to create this challenge")
return
}
if (!contract && user.balance < FIXED_ANTE + challengeInfo.amount) {
setError(
`You don't have enough mana to create this challenge and market. You need ${formatMoney(
FIXED_ANTE + challengeInfo.amount
)}`
)
return
}
setIsCreating(true)
@ -118,7 +151,23 @@ function CreateChallengeForm(props: {
<div className="mb-8">
Challenge a friend to bet on{' '}
<span className="underline">{contract.question}</span>
{contract ? (
<span className="underline">{contract.question}</span>
) : (
<Textarea
placeholder="e.g. Will a Democrat be the next president?"
className="input input-bordered mt-1 w-full resize-none"
autoFocus={true}
maxLength={MAX_QUESTION_LENGTH}
value={challengeInfo.question}
onChange={(e) =>
setChallengeInfo({
...challengeInfo,
question: e.target.value,
})
}
/>
)}
</div>
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
@ -187,22 +236,23 @@ function CreateChallengeForm(props: {
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
</Row>
</div>
<Button
size="2xs"
color="gray"
onClick={() => {
setEditingAcceptorAmount(true)
const p = getProbability(contract)
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
const { amount } = challengeInfo
const acceptorAmount = Math.round(amount / prob - amount)
setChallengeInfo({ ...challengeInfo, acceptorAmount })
}}
>
Use market odds
</Button>
{contract && (
<Button
size="2xs"
color="gray"
onClick={() => {
setEditingAcceptorAmount(true)
const p = getProbability(contract)
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
const { amount } = challengeInfo
const acceptorAmount = Math.round(amount / prob - amount)
setChallengeInfo({ ...challengeInfo, acceptorAmount })
}}
>
Use market odds
</Button>
)}
<div className="mt-8">
If the challenge is accepted, whoever is right will earn{' '}
<span className="font-semibold">
@ -210,7 +260,18 @@ function CreateChallengeForm(props: {
challengeInfo.acceptorAmount + challengeInfo.amount || 0
)}
</span>{' '}
in total.
in total.{' '}
<span>
{!contract && (
<span>
Because there's no market yet, you'll be charged
<span className={'mx-1 font-semibold'}>
{formatMoney(FIXED_ANTE)}
</span>
to create it.
</span>
)}
</span>
</div>
<Row className="mt-8 items-center">
@ -218,10 +279,8 @@ function CreateChallengeForm(props: {
type="submit"
color={'gradient'}
size="xl"
className={clsx(
'whitespace-nowrap drop-shadow-md',
isCreating ? 'disabled' : ''
)}
disabled={isCreating || challengeInfo.question === ''}
className={clsx('whitespace-nowrap drop-shadow-md')}
>
Create challenge bet
</Button>
@ -229,7 +288,12 @@ function CreateChallengeForm(props: {
<Row className={'text-error'}>{error} </Row>
</form>
)}
{finishedCreating && (
{loading && (
<Col className={'h-56 w-full items-center justify-center'}>
<LoadingIndicator />
</Col>
)}
{finishedCreating && !loading && (
<>
<Title className="!my-0" text="Challenge Created!" />

View File

@ -1,12 +1,8 @@
import { useEffect, useState } from 'react'
import { Dictionary, keyBy, uniq } from 'lodash'
import { Comment } from 'common/comment'
import { Contract } from 'common/contract'
import { filterDefined, groupConsecutive } from 'common/util/array'
import { contractPath } from 'web/lib/firebase/contracts'
import { Comment, ContractComment } from 'common/comment'
import { groupConsecutive } from 'common/util/array'
import { getUsersComments } from 'web/lib/firebase/comments'
import { getContractFromId } from 'web/lib/firebase/contracts'
import { SiteLink } from './site-link'
import { Row } from './layout/row'
import { Avatar } from './avatar'
@ -20,12 +16,15 @@ import { LoadingIndicator } from './loading-indicator'
const COMMENTS_PER_PAGE = 50
type ContractComment = Comment & { contractId: string }
function contractPath(slug: string) {
// by convention this includes the contract creator username, but we don't
// have that handy, so we just put /market/
return `/market/${slug}`
}
export function UserCommentsList(props: { user: User }) {
const { user } = props
const [comments, setComments] = useState<ContractComment[] | undefined>()
const [contracts, setContracts] = useState<Dictionary<Contract> | undefined>()
const [page, setPage] = useState(0)
const start = page * COMMENTS_PER_PAGE
const end = start + COMMENTS_PER_PAGE
@ -33,38 +32,29 @@ export function UserCommentsList(props: { user: User }) {
useEffect(() => {
getUsersComments(user.id).then((cs) => {
// we don't show comments in groups here atm, just comments on contracts
setComments(cs.filter((c) => c.contractId) as ContractComment[])
setComments(
cs.filter((c) => c.commentType == 'contract') as ContractComment[]
)
})
}, [user.id])
useEffect(() => {
if (comments) {
const contractIds = uniq(comments.map((c) => c.contractId))
Promise.all(contractIds.map(getContractFromId)).then((contracts) => {
setContracts(keyBy(filterDefined(contracts), 'id'))
})
}
}, [comments])
if (comments == null || contracts == null) {
if (comments == null) {
return <LoadingIndicator />
}
const pageComments = groupConsecutive(
comments.slice(start, end),
(c) => c.contractId
)
const pageComments = groupConsecutive(comments.slice(start, end), (c) => {
return { question: c.contractQuestion, slug: c.contractSlug }
})
return (
<Col className={'bg-white'}>
{pageComments.map(({ key, items }, i) => {
const contract = contracts[key]
return (
<div key={start + i} className="border-b p-5">
<SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700"
href={contractPath(contract)}
href={contractPath(key.slug)}
>
{contract.question}
{key.question}
</SiteLink>
<Col className="gap-6">
{items.map((comment) => (

View File

@ -83,7 +83,7 @@ export function ContractSearch(props: {
highlightOptions?: ContractHighlightOptions
onContractClick?: (contract: Contract) => void
hideOrderSelector?: boolean
gridClassName?: string
overrideGridClassName?: string
cardHideOptions?: {
hideGroupLink?: boolean
hideQuickBet?: boolean
@ -91,6 +91,7 @@ export function ContractSearch(props: {
headerClassName?: string
useQuerySortLocalStorage?: boolean
useQuerySortUrlParams?: boolean
isWholePage?: boolean
}) {
const {
user,
@ -98,13 +99,14 @@ export function ContractSearch(props: {
defaultFilter,
additionalFilter,
onContractClick,
gridClassName,
overrideGridClassName,
hideOrderSelector,
cardHideOptions,
highlightOptions,
headerClassName,
useQuerySortLocalStorage,
useQuerySortUrlParams,
isWholePage,
} = props
const [numPages, setNumPages] = useState(1)
@ -139,7 +141,7 @@ export function ContractSearch(props: {
setNumPages(results.nbPages)
if (freshQuery) {
setPages([newPage])
window.scrollTo(0, 0)
if (isWholePage) window.scrollTo(0, 0)
} else {
setPages((pages) => [...pages, newPage])
}
@ -181,7 +183,7 @@ export function ContractSearch(props: {
loadMore={performQuery}
showTime={showTime}
onContractClick={onContractClick}
gridClassName={gridClassName}
overrideGridClassName={overrideGridClassName}
highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions}
/>

View File

@ -122,19 +122,6 @@ export function ContractCard(props: {
) : (
<FreeResponseTopAnswer contract={contract} truncate="long" />
))}
<Row className={'absolute bottom-3 gap-2 md:gap-0'}>
<AvatarDetails
contract={contract}
short={true}
className={'block md:hidden'}
/>
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showTime={showTime}
hideGroupLink={hideGroupLink}
/>
</Row>
</Col>
{showQuickBet ? (
<QuickBet contract={contract} user={user} />
@ -172,6 +159,24 @@ export function ContractCard(props: {
<ProbBar contract={contract} />
</>
)}
<Row
className={clsx(
'absolute bottom-3 gap-2 truncate px-5 md:gap-0',
showQuickBet ? 'w-[85%]' : 'w-full'
)}
>
<AvatarDetails
contract={contract}
short={true}
className={'block md:hidden'}
/>
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showTime={showTime}
hideGroupLink={hideGroupLink}
/>
</Row>
</Row>
)
}

View File

@ -58,13 +58,13 @@ export function MiscDetails(props: {
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
return (
<Row className="items-center gap-3 text-sm text-gray-400">
<Row className="items-center gap-3 truncate text-sm text-gray-400">
{showHotVolume ? (
<Row className="gap-0.5">
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row>
) : showTime === 'close-date' ? (
<Row className="gap-0.5">
<Row className="gap-0.5 whitespace-nowrap">
<ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)}

View File

@ -66,17 +66,17 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<tr>
<td>Payout</td>
<td>
<td className="flex gap-1">
{mechanism === 'cpmm-1' ? (
<>
Fixed{' '}
<InfoTooltip text="Each YES share is worth M$1 if YES wins." />
</>
) : (
<div>
<>
Parimutuel{' '}
<InfoTooltip text="Each share is a fraction of the pool. " />
</div>
</>
)}
</td>
</tr>

View File

@ -1,5 +1,5 @@
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { ContractComment } from 'common/comment'
import { resolvedPayout } from 'common/calculate'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
@ -65,7 +65,7 @@ export function ContractLeaderboard(props: {
export function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: Comment[]
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, bets, comments, tips } = props

View File

@ -1,6 +1,6 @@
import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { Comment } from 'web/lib/firebase/comments'
import { ContractComment } from 'common/comment'
import { User } from 'common/user'
import { ContractActivity } from '../feed/contract-activity'
import { ContractBetsTable, BetsSummary } from '../bets-list'
@ -15,7 +15,7 @@ export function ContractTabs(props: {
contract: Contract
user: User | null | undefined
bets: Bet[]
comments: Comment[]
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, user, bets, tips } = props

View File

@ -20,7 +20,7 @@ export function ContractsGrid(props: {
loadMore?: () => void
showTime?: ShowTime
onContractClick?: (contract: Contract) => void
gridClassName?: string
overrideGridClassName?: string
cardHideOptions?: {
hideQuickBet?: boolean
hideGroupLink?: boolean
@ -32,7 +32,7 @@ export function ContractsGrid(props: {
showTime,
loadMore,
onContractClick,
gridClassName,
overrideGridClassName,
cardHideOptions,
highlightOptions,
} = props
@ -66,8 +66,9 @@ export function ContractsGrid(props: {
<Col className="gap-8">
<ul
className={clsx(
'w-full columns-1 gap-4 space-y-4 md:columns-2',
gridClassName
overrideGridClassName
? overrideGridClassName
: 'grid w-full grid-cols-1 gap-4 md:grid-cols-2'
)}
>
{contracts.map((contract) => (
@ -80,10 +81,11 @@ export function ContractsGrid(props: {
}
hideQuickBet={hideQuickBet}
hideGroupLink={hideGroupLink}
className={clsx(
'break-inside-avoid-column',
contractIds?.includes(contract.id) && highlightClassName
)}
className={
contractIds?.includes(contract.id)
? highlightClassName
: undefined
}
/>
))}
</ul>

View File

@ -65,7 +65,9 @@ export function MarketModal(props: {
<ContractSearch
hideOrderSelector
onContractClick={addContract}
gridClassName="gap-3 space-y-3"
overrideGridClassName={
'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1'
}
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
highlightOptions={{
contractIds: contracts.map((c) => c.id),

View File

@ -51,6 +51,7 @@ export const MentionList = forwardRef((props: SuggestionProps<User>, ref) => {
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
)}
onClick={() => submitUser(i)}
key={user.id}
>
<Avatar avatarUrl={user.avatarUrl} size="xs" />
{user.username}

View File

@ -3,7 +3,7 @@ import { uniq, sortBy } from 'lodash'
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { getOutcomeProbability } from 'common/calculate'
import { Comment } from 'common/comment'
import { ContractComment } from 'common/comment'
import { Contract, FreeResponseContract } from 'common/contract'
import { User } from 'common/user'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
@ -28,7 +28,7 @@ type BaseActivityItem = {
export type CommentInputItem = BaseActivityItem & {
type: 'commentInput'
betsByCurrentUser: Bet[]
commentsByCurrentUser: Comment[]
commentsByCurrentUser: ContractComment[]
}
export type DescriptionItem = BaseActivityItem & {
@ -50,8 +50,8 @@ export type BetItem = BaseActivityItem & {
export type CommentThreadItem = BaseActivityItem & {
type: 'commentThread'
parentComment: Comment
comments: Comment[]
parentComment: ContractComment
comments: ContractComment[]
tips: CommentTipMap
bets: Bet[]
}
@ -60,7 +60,7 @@ export type AnswerGroupItem = BaseActivityItem & {
type: 'answergroup'
user: User | undefined | null
answer: Answer
comments: Comment[]
comments: ContractComment[]
tips: CommentTipMap
bets: Bet[]
}
@ -84,7 +84,7 @@ export type LiquidityItem = BaseActivityItem & {
function getAnswerAndCommentInputGroups(
contract: FreeResponseContract,
bets: Bet[],
comments: Comment[],
comments: ContractComment[],
tips: CommentTipMap,
user: User | undefined | null
) {
@ -116,7 +116,7 @@ function getAnswerAndCommentInputGroups(
function getCommentThreads(
bets: Bet[],
comments: Comment[],
comments: ContractComment[],
tips: CommentTipMap,
contract: Contract
) {
@ -135,7 +135,7 @@ function getCommentThreads(
return items
}
function commentIsGeneralComment(comment: Comment, contract: Contract) {
function commentIsGeneralComment(comment: ContractComment, contract: Contract) {
return (
comment.answerOutcome === undefined &&
(contract.outcomeType === 'FREE_RESPONSE'
@ -147,7 +147,7 @@ function commentIsGeneralComment(comment: Comment, contract: Contract) {
export function getSpecificContractActivityItems(
contract: Contract,
bets: Bet[],
comments: Comment[],
comments: ContractComment[],
liquidityProvisions: LiquidityProvision[],
tips: CommentTipMap,
user: User | null | undefined,

View File

@ -1,167 +0,0 @@
import clsx from 'clsx'
import { PencilIcon } from '@heroicons/react/outline'
import { union, difference } from 'lodash'
import { Row } from '../layout/row'
import { CATEGORIES, category, CATEGORY_LIST } from '../../../common/categories'
import { Modal } from '../layout/modal'
import { Col } from '../layout/col'
import { useState } from 'react'
import { updateUser, User } from 'web/lib/firebase/users'
import { Checkbox } from '../checkbox'
import { track } from 'web/lib/service/analytics'
export function CategorySelector(props: {
category: string
setCategory: (category: string) => void
className?: string
}) {
const { className, category, setCategory } = props
return (
<Row
className={clsx(
'carousel mr-2 items-center space-x-2 space-y-2 overflow-x-scroll pb-4 sm:flex-wrap',
className
)}
>
<div />
<CategoryButton
key="all"
category="All"
isFollowed={category === 'all'}
toggle={() => {
setCategory('all')
}}
/>
<CategoryButton
key="following"
category="Following"
isFollowed={category === 'following'}
toggle={() => {
setCategory('following')
}}
/>
{CATEGORY_LIST.map((cat) => (
<CategoryButton
key={cat}
category={CATEGORIES[cat as category].split(' ')[0]}
isFollowed={cat === category}
toggle={() => {
setCategory(cat)
}}
/>
))}
</Row>
)
}
function CategoryButton(props: {
category: string
isFollowed: boolean
toggle: () => void
className?: string
}) {
const { toggle, category, isFollowed, className } = props
return (
<div
className={clsx(
className,
'rounded-full border-2 px-4 py-1 shadow-md hover:bg-gray-200',
'cursor-pointer select-none',
isFollowed ? 'border-gray-300 bg-gray-300' : 'bg-white'
)}
onClick={toggle}
>
<span className="text-sm text-gray-500">{category}</span>
</div>
)
}
export function EditCategoriesButton(props: {
user: User
className?: string
}) {
const { user, className } = props
const [isOpen, setIsOpen] = useState(false)
return (
<div
className={clsx(
className,
'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700'
)}
onClick={() => {
setIsOpen(true)
track('edit categories button')
}}
>
<PencilIcon className="inline h-4 w-4" />
Categories
<CategorySelectorModal
user={user}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>
</div>
)
}
function CategorySelectorModal(props: {
user: User
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
}) {
const { user, isOpen, setIsOpen } = props
const followedCategories =
user?.followedCategories === undefined
? CATEGORY_LIST
: user.followedCategories
const selectAll =
user.followedCategories === undefined ||
followedCategories.length < CATEGORY_LIST.length
return (
<Modal open={isOpen} setOpen={setIsOpen}>
<Col className="rounded bg-white p-6">
<button
className="btn btn-sm btn-outline mb-4 self-start normal-case"
onClick={() => {
if (selectAll) {
updateUser(user.id, {
followedCategories: CATEGORY_LIST,
})
} else {
updateUser(user.id, {
followedCategories: [],
})
}
}}
>
Select {selectAll ? 'all' : 'none'}
</button>
<Col className="grid w-full grid-cols-2 gap-4">
{CATEGORY_LIST.map((cat) => (
<Checkbox
className="col-span-1"
key={cat}
label={CATEGORIES[cat as category].split(' ')[0]}
checked={followedCategories.includes(cat)}
toggle={(checked) => {
updateUser(user.id, {
followedCategories: checked
? difference(followedCategories, [cat])
: union([cat], followedCategories),
})
}}
/>
))}
</Col>
</Col>
</Modal>
)
}

View File

@ -1,5 +1,5 @@
import { Contract } from 'web/lib/firebase/contracts'
import { Comment } from 'web/lib/firebase/comments'
import { ContractComment } from 'common/comment'
import { Bet } from 'common/bet'
import { useBets } from 'web/hooks/use-bets'
import { getSpecificContractActivityItems } from './activity-items'
@ -12,7 +12,7 @@ import { LiquidityProvision } from 'common/liquidity-provision'
export function ContractActivity(props: {
contract: Contract
bets: Bet[]
comments: Comment[]
comments: ContractComment[]
liquidityProvisions: LiquidityProvision[]
tips: CommentTipMap
user: User | null | undefined

View File

@ -1,6 +1,6 @@
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { ContractComment } from 'common/comment'
import React, { useEffect, useState } from 'react'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
@ -24,7 +24,7 @@ export function FeedAnswerCommentGroup(props: {
contract: any
user: User | undefined | null
answer: Answer
comments: Comment[]
comments: ContractComment[]
tips: CommentTipMap
bets: Bet[]
}) {
@ -69,7 +69,7 @@ export function FeedAnswerCommentGroup(props: {
])
const scrollAndOpenReplyInput = useEvent(
(comment?: Comment, answer?: Answer) => {
(comment?: ContractComment, answer?: Answer) => {
setReplyToUser(
comment
? { id: comment.userId, username: comment.userUsername }

View File

@ -1,5 +1,5 @@
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { ContractComment } from 'common/comment'
import { User } from 'common/user'
import { Contract } from 'common/contract'
import React, { useEffect, useState } from 'react'
@ -32,9 +32,9 @@ import { Editor } from '@tiptap/react'
export function FeedCommentThread(props: {
contract: Contract
comments: Comment[]
comments: ContractComment[]
tips: CommentTipMap
parentComment: Comment
parentComment: ContractComment
bets: Bet[]
smallAvatar?: boolean
}) {
@ -50,7 +50,7 @@ export function FeedCommentThread(props: {
)
commentsList.unshift(parentComment)
function scrollAndOpenReplyInput(comment: Comment) {
function scrollAndOpenReplyInput(comment: ContractComment) {
setReplyToUser({ id: comment.userId, username: comment.userUsername })
setShowReply(true)
}
@ -95,10 +95,10 @@ export function FeedCommentThread(props: {
export function CommentRepliesList(props: {
contract: Contract
commentsList: Comment[]
commentsList: ContractComment[]
betsByUserId: Dictionary<Bet[]>
tips: CommentTipMap
scrollAndOpenReplyInput: (comment: Comment) => void
scrollAndOpenReplyInput: (comment: ContractComment) => void
bets: Bet[]
treatFirstIndexEqually?: boolean
smallAvatar?: boolean
@ -156,12 +156,12 @@ export function CommentRepliesList(props: {
export function FeedComment(props: {
contract: Contract
comment: Comment
comment: ContractComment
tips: CommentTips
betsBySameUser: Bet[]
probAtCreatedTime?: number
smallAvatar?: boolean
onReplyClick?: (comment: Comment) => void
onReplyClick?: (comment: ContractComment) => void
}) {
const {
contract,
@ -274,7 +274,7 @@ export function FeedComment(props: {
export function getMostRecentCommentableBet(
betsByCurrentUser: Bet[],
commentsByCurrentUser: Comment[],
commentsByCurrentUser: ContractComment[],
user?: User | null,
answerOutcome?: string
) {
@ -319,7 +319,7 @@ function CommentStatus(props: {
export function CommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
commentsByCurrentUser: Comment[]
commentsByCurrentUser: ContractComment[]
replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string

View File

@ -1,6 +1,6 @@
import { groupBy, mapValues, maxBy, sortBy } from 'lodash'
import { Contract } from 'web/lib/firebase/contracts'
import { Comment } from 'web/lib/firebase/comments'
import { ContractComment } from 'common/comment'
import { Bet } from 'common/bet'
const MAX_ACTIVE_CONTRACTS = 75
@ -19,7 +19,7 @@ function lastActivityTime(contract: Contract) {
// - Bet on market
export function findActiveContracts(
allContracts: Contract[],
recentComments: Comment[],
recentComments: ContractComment[],
recentBets: Bet[],
seenContracts: { [contractId: string]: number }
) {
@ -73,7 +73,7 @@ export function findActiveContracts(
)
const contractMostRecentComment = mapValues(
contractComments,
(comments) => maxBy(comments, (c) => c.createdTime) as Comment
(comments) => maxBy(comments, (c) => c.createdTime) as ContractComment
)
const prioritizedContracts = sortBy(activeContracts, (c) => {

View File

@ -4,7 +4,8 @@ import { PrivateUser, User } from 'common/user'
import React, { useEffect, memo, useState, useMemo } from 'react'
import { Avatar } from 'web/components/avatar'
import { Group } from 'common/group'
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments'
import { Comment, GroupComment } from 'common/comment'
import { createCommentOnGroup } from 'web/lib/firebase/comments'
import { CommentInputTextArea } from 'web/components/feed/feed-comments'
import { track } from 'web/lib/service/analytics'
import { firebaseLogin } from 'web/lib/firebase/users'
@ -24,7 +25,7 @@ import { setNotificationsAsSeen } from 'web/pages/notifications'
import { usePrivateUser } from 'web/hooks/use-user'
export function GroupChat(props: {
messages: Comment[]
messages: GroupComment[]
user: User | null | undefined
group: Group
tips: CommentTipMap
@ -58,7 +59,7 @@ export function GroupChat(props: {
// array of groups, where each group is an array of messages that are displayed as one
const groupedMessages = useMemo(() => {
// Group messages with createdTime within 2 minutes of each other.
const tempGrouped: Comment[][] = []
const tempGrouped: GroupComment[][] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (i === 0) tempGrouped.push([message])
@ -193,7 +194,7 @@ export function GroupChat(props: {
}
export function GroupChatInBubble(props: {
messages: Comment[]
messages: GroupComment[]
user: User | null | undefined
privateUser: PrivateUser | null | undefined
group: Group
@ -309,7 +310,7 @@ function GroupChatNotificationsIcon(props: {
const GroupMessage = memo(function GroupMessage_(props: {
user: User | null | undefined
comments: Comment[]
comments: GroupComment[]
group: Group
onReplyClick?: (comment: Comment) => void
setRef?: (ref: HTMLDivElement) => void
@ -353,7 +354,7 @@ const GroupMessage = memo(function GroupMessage_(props: {
elementId={id}
/>
</Row>
<div className="mt-2 text-black">
<div className="mt-2 text-base text-black">
{comments.map((comment) => (
<Content
key={comment.id}

View File

@ -12,6 +12,7 @@ import { Tabs } from './layout/tabs'
import { NoLabel, YesLabel } from './outcome-label'
import { Col } from './layout/col'
import { track } from 'web/lib/service/analytics'
import { InfoTooltip } from './info-tooltip'
export function LiquidityPanel(props: { contract: CPMMContract }) {
const { contract } = props
@ -101,8 +102,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
return (
<>
<div className="align-center mb-4 text-gray-500">
Subsidize this market by adding M$ to the liquidity pool.
<div className="mb-4 text-gray-500">
Contribute your M$ to make this market more accurate.{' '}
<InfoTooltip text="More liquidity stabilizes the market, encouraging traders to bet. You can withdraw your subsidy at any time." />
</div>
<Row>

View File

@ -33,7 +33,7 @@ function getNavigation() {
const signedOutNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/markets', icon: SearchIcon },
{ name: 'Explore', href: '/home', icon: SearchIcon },
]
// From https://codepen.io/chris__sev/pen/QWGvYbL

View File

@ -99,8 +99,8 @@ function getMoreNavigation(user?: User | null) {
}
const signedOutNavigation = [
{ name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Explore', href: '/markets', icon: SearchIcon },
{ name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/home', icon: SearchIcon },
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',

View File

@ -1,55 +0,0 @@
import clsx from 'clsx'
import { useState } from 'react'
import { parseWordsAsTags } from 'common/util/parse'
import { Contract, updateContract } from 'web/lib/firebase/contracts'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { TagsList } from './tags-list'
import { MAX_TAG_LENGTH } from 'common/contract'
export function TagsInput(props: { contract: Contract; className?: string }) {
const { contract, className } = props
const { tags } = contract
const [tagText, setTagText] = useState('')
const newTags = parseWordsAsTags(`${tags.join(' ')} ${tagText}`)
const [isSubmitting, setIsSubmitting] = useState(false)
const updateTags = async () => {
setIsSubmitting(true)
await updateContract(contract.id, {
tags: newTags,
lowercaseTags: newTags.map((tag) => tag.toLowerCase()),
})
setIsSubmitting(false)
setTagText('')
}
return (
<Col className={clsx('gap-4', className)}>
<TagsList tags={newTags} noLabel />
<Row className="items-center gap-4">
<input
style={{ maxWidth: 150 }}
placeholder="Type a tag..."
className="input input-sm input-bordered resize-none"
disabled={isSubmitting}
value={tagText}
maxLength={MAX_TAG_LENGTH}
onChange={(e) => setTagText(e.target.value || '')}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
updateTags()
}
}}
/>
<button className="btn btn-xs btn-outline" onClick={updateTags}>
Save tags
</button>
</Row>
</Col>
)
}

View File

@ -1,86 +0,0 @@
import clsx from 'clsx'
import { CATEGORIES, category } from '../../common/categories'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { SiteLink } from './site-link'
function Hashtag(props: { tag: string; noLink?: boolean }) {
const { tag, noLink } = props
const category = CATEGORIES[tag.replace('#', '').toLowerCase() as category]
const body = (
<div className={clsx('', !noLink && 'cursor-pointer')}>
<span className="text-sm">{category ? '#' + category : tag} </span>
</div>
)
if (noLink) return body
return (
<SiteLink href={`/tag/${tag.substring(1)}`} className="flex items-center">
{body}
</SiteLink>
)
}
export function TagsList(props: {
tags: string[]
className?: string
noLink?: boolean
noLabel?: boolean
label?: string
}) {
const { tags, className, noLink, noLabel, label } = props
return (
<Row className={clsx('flex-wrap items-center gap-2', className)}>
{!noLabel && <div className="mr-1">{label || 'Tags'}</div>}
{tags.map((tag) => (
<Hashtag
key={tag}
tag={tag.startsWith('#') ? tag : `#${tag}`}
noLink={noLink}
/>
))}
</Row>
)
}
export function FoldTag(props: { fold: { slug: string; name: string } }) {
const { fold } = props
const { slug, name } = fold
return (
<SiteLink href={`/fold/${slug}`} className="flex items-center">
<div
className={clsx(
'rounded-full border-2 bg-white px-4 py-1 shadow-md',
'cursor-pointer'
)}
>
<span className="text-sm text-gray-500">{name}</span>
</div>
</SiteLink>
)
}
export function FoldTagList(props: {
folds: { slug: string; name: string }[]
noLabel?: boolean
className?: string
}) {
const { folds, noLabel, className } = props
return (
<Col className="gap-2">
{!noLabel && <div className="mr-1 text-gray-500">Communities</div>}
<Row className={clsx('flex-wrap items-center gap-2', className)}>
{folds.length > 0 && (
<>
{folds.map((fold) => (
<FoldTag key={fold.slug} fold={fold} />
))}
</>
)}
</Row>
</Col>
)
}

View File

@ -42,6 +42,10 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
return
}
const contractId =
comment.commentType === 'contract' ? comment.contractId : undefined
const groupId =
comment.commentType === 'group' ? comment.groupId : undefined
await transact({
amount: change,
fromId: user.id,
@ -50,18 +54,14 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
toType: 'USER',
token: 'M$',
category: 'TIP',
data: {
contractId: comment.contractId,
commentId: comment.id,
groupId: comment.groupId,
},
data: { commentId: comment.id, contractId, groupId },
description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`,
})
track('send comment tip', {
contractId: comment.contractId,
commentId: comment.id,
groupId: comment.groupId,
contractId,
groupId,
amount: change,
fromId: user.id,
toId: comment.userId,

View File

@ -6,7 +6,6 @@ import {
Placement,
shift,
useFloating,
useFocus,
useHover,
useInteractions,
useRole,
@ -48,7 +47,6 @@ export function Tooltip(props: {
const { getReferenceProps, getFloatingProps } = useInteractions([
useHover(context, { mouseOnly: noTap }),
useFocus(context),
useRole(context, { role: 'tooltip' }),
])
// which side of tooltip arrow is on. like: if tooltip is top-left, arrow is on bottom of tooltip
@ -64,7 +62,6 @@ export function Tooltip(props: {
<div
className={clsx('inline-block', className)}
ref={reference}
tabIndex={noTap ? undefined : 0}
{...getReferenceProps()}
>
{children}

View File

@ -58,8 +58,6 @@ export function UserLink(props: {
)
}
export const TAB_IDS = ['markets', 'comments', 'bets', 'groups']
export function UserPage(props: { user: User }) {
const { user } = props
const router = useRouter()
@ -103,10 +101,10 @@ export function UserPage(props: { user: User }) {
</div>
{/* Top right buttons (e.g. edit, follow) */}
<div className="absolute right-0 top-0 mt-4 mr-4">
<div className="absolute right-0 top-0 mt-2 mr-4">
{!isCurrentUser && <UserFollowButton userId={user.id} />}
{isCurrentUser && (
<SiteLink className="btn" href="/profile">
<SiteLink className="sm:btn-md btn-sm btn" href="/profile">
<PencilIcon className="h-5 w-5" />{' '}
<div className="ml-2">Edit</div>
</SiteLink>
@ -116,19 +114,22 @@ export function UserPage(props: { user: User }) {
{/* Profile details: name, username, bio, and link to twitter/discord */}
<Col className="mx-4 -mt-6">
<span className="text-2xl font-bold">{user.name}</span>
<Row className={'items-center gap-2'}>
<span className="text-2xl font-bold">{user.name}</span>
<span className="mt-1 text-gray-500">
<span
className={clsx(
'text-md',
profit >= 0 ? 'text-green-600' : 'text-red-400'
)}
>
{formatMoney(profit)}
</span>{' '}
profit
</span>
</Row>
<span className="text-gray-500">@{user.username}</span>
<span className="text-gray-500">
<span
className={clsx(
'text-md',
profit >= 0 ? 'text-green-600' : 'text-red-400'
)}
>
{formatMoney(profit)}
</span>{' '}
profit
</span>
<Spacer h={4} />
{user.bio && (
<>
@ -138,17 +139,7 @@ export function UserPage(props: { user: User }) {
<Spacer h={4} />
</>
)}
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4">
<Row className="gap-4">
<FollowingButton user={user} />
<FollowersButton user={user} />
{currentUser &&
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
currentUser.username
) && <ReferralsButton user={user} />}
<GroupsButton user={user} />
</Row>
<Row className="flex-wrap items-center gap-2 sm:gap-4">
{user.website && (
<SiteLink
href={
@ -198,7 +189,7 @@ export function UserPage(props: { user: User }) {
</Row>
</SiteLink>
)}
</Col>
</Row>
<Spacer h={5} />
{currentUser?.id === user.id && (
<Row
@ -208,7 +199,7 @@ export function UserPage(props: { user: User }) {
>
<span>
<SiteLink href="/referrals">
Refer a friend and earn {formatMoney(500)} when they sign up!
Earn {formatMoney(500)} when you refer a friend!
</SiteLink>{' '}
You have <ReferralsButton user={user} currentUser={currentUser} />
</span>
@ -244,6 +235,22 @@ export function UserPage(props: { user: User }) {
</>
),
},
{
title: 'Social',
content: (
<Row
className={'mt-2 flex-wrap items-center justify-center gap-6'}
>
<FollowingButton user={user} />
<FollowersButton user={user} />
{currentUser &&
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
currentUser.username
) && <ReferralsButton user={user} />}
<GroupsButton user={user} />
</Row>
),
},
]}
/>
</Col>

View File

@ -1,51 +0,0 @@
import { useState, useEffect } from 'react'
import type { feed } from 'common/feed'
import { useTimeSinceFirstRender } from './use-time-since-first-render'
import { trackLatency } from 'web/lib/firebase/tracking'
import { User } from 'common/user'
import { getCategoryFeeds, getUserFeed } from 'web/lib/firebase/users'
import {
getRecentBetsAndComments,
getTopWeeklyContracts,
} from 'web/lib/firebase/contracts'
export const useAlgoFeed = (
user: User | null | undefined,
category: string
) => {
const [allFeed, setAllFeed] = useState<feed>()
const [categoryFeeds, setCategoryFeeds] = useState<{ [x: string]: feed }>()
const getTime = useTimeSinceFirstRender()
useEffect(() => {
if (user) {
getUserFeed(user.id).then((feed) => {
if (feed.length === 0) {
getDefaultFeed().then((feed) => setAllFeed(feed))
} else setAllFeed(feed)
trackLatency(user.id, 'feed', getTime())
console.log('"all" feed load time', getTime())
})
getCategoryFeeds(user.id).then((feeds) => {
setCategoryFeeds(feeds)
console.log('category feeds load time', getTime())
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id])
const feed = category === 'all' ? allFeed : categoryFeeds?.[category]
return feed
}
const getDefaultFeed = async () => {
const contracts = await getTopWeeklyContracts()
const feed = await Promise.all(
contracts.map((c) => getRecentBetsAndComments(c))
)
return feed
}

View File

@ -1,13 +1,13 @@
import { useEffect, useState } from 'react'
import { Comment, ContractComment, GroupComment } from 'common/comment'
import {
Comment,
listenForCommentsOnContract,
listenForCommentsOnGroup,
listenForRecentComments,
} from 'web/lib/firebase/comments'
export const useComments = (contractId: string) => {
const [comments, setComments] = useState<Comment[] | undefined>()
const [comments, setComments] = useState<ContractComment[] | undefined>()
useEffect(() => {
if (contractId) return listenForCommentsOnContract(contractId, setComments)
@ -16,7 +16,7 @@ export const useComments = (contractId: string) => {
return comments
}
export const useCommentsOnGroup = (groupId: string | undefined) => {
const [comments, setComments] = useState<Comment[] | undefined>()
const [comments, setComments] = useState<GroupComment[] | undefined>()
useEffect(() => {
if (groupId) return listenForCommentsOnGroup(groupId, setComments)

View File

@ -29,13 +29,11 @@ export async function createChallenge(data: {
creatorAmount: number
acceptorAmount: number
expiresTime: number | null
message: string
}) {
const {
creator,
creatorAmount,
expiresTime,
message,
contract,
outcome,
acceptorAmount,
@ -73,7 +71,7 @@ export async function createChallenge(data: {
acceptedByUserIds: [],
acceptances: [],
isResolved: false,
message,
message: '',
}
await setDoc(doc(challenges(contract.id), slug), challenge)

View File

@ -11,7 +11,7 @@ import {
import { getValues, listenForValues } from './utils'
import { db } from './init'
import { User } from 'common/user'
import { Comment } from 'common/comment'
import { Comment, ContractComment, GroupComment } from 'common/comment'
import { removeUndefinedProps } from 'common/util/object'
import { track } from '@amplitude/analytics-browser'
import { JSONContent } from '@tiptap/react'
@ -31,8 +31,10 @@ export async function createCommentOnContract(
const ref = betId
? doc(getCommentsCollection(contractId), betId)
: doc(getCommentsCollection(contractId))
const comment: Comment = removeUndefinedProps({
// contract slug and question are set via trigger
const comment = removeUndefinedProps({
id: ref.id,
commentType: 'contract',
contractId,
userId: commenter.id,
content: content,
@ -59,8 +61,9 @@ export async function createCommentOnGroup(
replyToCommentId?: string
) {
const ref = doc(getCommentsOnGroupCollection(groupId))
const comment: Comment = removeUndefinedProps({
const comment = removeUndefinedProps({
id: ref.id,
commentType: 'group',
groupId,
userId: user.id,
content: content,
@ -94,7 +97,7 @@ export async function listAllComments(contractId: string) {
}
export async function listAllCommentsOnGroup(groupId: string) {
const comments = await getValues<Comment>(
const comments = await getValues<GroupComment>(
getCommentsOnGroupCollection(groupId)
)
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
@ -103,9 +106,9 @@ export async function listAllCommentsOnGroup(groupId: string) {
export function listenForCommentsOnContract(
contractId: string,
setComments: (comments: Comment[]) => void
setComments: (comments: ContractComment[]) => void
) {
return listenForValues<Comment>(
return listenForValues<ContractComment>(
getCommentsCollection(contractId),
(comments) => {
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
@ -115,9 +118,9 @@ export function listenForCommentsOnContract(
}
export function listenForCommentsOnGroup(
groupId: string,
setComments: (comments: Comment[]) => void
setComments: (comments: GroupComment[]) => void
) {
return listenForValues<Comment>(
return listenForValues<GroupComment>(
getCommentsOnGroupCollection(groupId),
(comments) => {
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)

View File

@ -22,7 +22,6 @@ import { createRNG, shuffle } from 'common/util/random'
import { getCpmmProbability } from 'common/calculate-cpmm'
import { formatMoney, formatPercent } from 'common/util/format'
import { DAY_MS } from 'common/util/time'
import { MAX_FEED_CONTRACTS } from 'common/recommended-contracts'
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { ENV_CONFIG } from 'common/envs/constants'
@ -285,16 +284,6 @@ export async function getContractsBySlugs(slugs: string[]) {
return sortBy(data, (contract) => -1 * contract.volume24Hours)
}
const topWeeklyQuery = query(
contracts,
where('isResolved', '==', false),
orderBy('volume7Days', 'desc'),
limit(MAX_FEED_CONTRACTS)
)
export async function getTopWeeklyContracts() {
return await getValues<Contract>(topWeeklyQuery)
}
const closingSoonQuery = query(
contracts,
where('isResolved', '==', false),

View File

@ -14,20 +14,10 @@ import {
onSnapshot,
} from 'firebase/firestore'
import { getAuth } from 'firebase/auth'
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
import { zip } from 'lodash'
import { app, db } from './init'
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
import {
coll,
getValue,
getValues,
listenForValue,
listenForValues,
} from './utils'
import { feed } from 'common/feed'
import { CATEGORY_LIST } from 'common/categories'
import { coll, getValues, listenForValue, listenForValues } from './utils'
import { safeLocalStorage } from '../util/local'
import { filterDefined } from 'common/util/array'
import { addUserToGroupViaId } from 'web/lib/firebase/groups'
@ -202,20 +192,6 @@ export async function firebaseLogout() {
await auth.signOut()
}
const storage = getStorage(app)
// Example: uploadData('avatars/ajfi8iejsf.png', data)
export async function uploadData(
path: string,
data: ArrayBuffer | Blob | Uint8Array
) {
const uploadRef = ref(storage, path)
// Uploaded files should be cached for 1 day, then revalidated
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
const metadata = { cacheControl: 'public, max-age=86400, must-revalidate' }
await uploadBytes(uploadRef, data, metadata)
return await getDownloadURL(uploadRef)
}
export async function listUsers(userIds: string[]) {
if (userIds.length > 10) {
throw new Error('Too many users requested at once; Firestore limits to 10')
@ -263,25 +239,6 @@ export function getUsers() {
return getValues<User>(users)
}
export async function getUserFeed(userId: string) {
const feedDoc = doc(privateUsers, userId, 'cache', 'feed')
const userFeed = await getValue<{
feed: feed
}>(feedDoc)
return userFeed?.feed ?? []
}
export async function getCategoryFeeds(userId: string) {
const cacheCollection = collection(privateUsers, userId, 'cache')
const feedData = await Promise.all(
CATEGORY_LIST.map((category) =>
getValue<{ feed: feed }>(doc(cacheCollection, `feed-${category}`))
)
)
const feeds = feedData.map((data) => data?.feed ?? [])
return Object.fromEntries(zip(CATEGORY_LIST, feeds) as [string, feed][])
}
export async function follow(userId: string, followedUserId: string) {
const followDoc = doc(collection(users, userId, 'follows'), followedUserId)
await setDoc(followDoc, {

View File

@ -1,5 +1,7 @@
const API_DOCS_URL = 'https://docs.manifold.markets/api'
const ABOUT_PAGE_URL = 'https://docs.manifold.markets/$how-to'
/** @type {import('next').NextConfig} */
module.exports = {
staticPageGenerationTimeout: 600, // e.g. stats page
@ -35,6 +37,11 @@ module.exports = {
destination: API_DOCS_URL,
permanent: false,
},
{
source: '/about',
destination: ABOUT_PAGE_URL,
permanent: false,
},
{
source: '/analytics',
destination: '/stats',

View File

@ -74,6 +74,7 @@
"cross-env": "^7.0.3",
"csstype": "^3.1.0",
"eslint-config-next": "12.1.6",
"eslint-config-prettier": "8.5.0",
"next-sitemap": "^2.5.14",
"postcss": "8.3.5",
"prettier-plugin-tailwindcss": "^0.1.5",

View File

@ -17,7 +17,7 @@ import {
import { SEO } from 'web/components/SEO'
import { Page } from 'web/components/page'
import { Bet, listAllBets } from 'web/lib/firebase/bets'
import { Comment, listAllComments } from 'web/lib/firebase/comments'
import { listAllComments } from 'web/lib/firebase/comments'
import Custom404 from '../404'
import { AnswersPanel } from 'web/components/answers/answers-panel'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
@ -38,6 +38,7 @@ import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { getOpenGraphProps } from 'web/components/contract/contract-card-preview'
import { User } from 'common/user'
import { ContractComment } from 'common/comment'
import { listUsers } from 'web/lib/firebase/users'
import { FeedComment } from 'web/components/feed/feed-comments'
import { Title } from 'web/components/title'
@ -78,7 +79,7 @@ export default function ContractPage(props: {
contract: Contract | null
username: string
bets: Bet[]
comments: Comment[]
comments: ContractComment[]
slug: string
backToHome?: () => void
}) {
@ -314,7 +315,7 @@ function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: Comment[]
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, bets, comments, tips } = props

View File

@ -7,6 +7,7 @@ import { User } from 'common/user'
import { removeUndefinedProps } from 'common/util/object'
import { ENV_CONFIG } from 'common/envs/constants'
import { JSONContent } from '@tiptap/core'
import { richTextToString } from 'common/util/parse'
export type LiteMarket = {
// Unique identifer for this market
@ -22,6 +23,7 @@ export type LiteMarket = {
closeTime?: number
question: string
description: string | JSONContent
textDescription: string // string version of description
tags: string[]
url: string
outcomeType: string
@ -40,6 +42,8 @@ export type LiteMarket = {
resolution?: string
resolutionTime?: number
resolutionProbability?: number
lastUpdatedTime?: number
}
export type ApiAnswer = Answer & {
@ -90,6 +94,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
resolution,
resolutionTime,
resolutionProbability,
lastUpdatedTime,
} = contract
const { p, totalLiquidity } = contract as any
@ -97,6 +102,11 @@ export function toLiteMarket(contract: Contract): LiteMarket {
const probability =
contract.outcomeType === 'BINARY' ? getProbability(contract) : undefined
let min, max, isLogScale: any
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
;({ min, max, isLogScale } = contract)
}
return removeUndefinedProps({
id,
creatorUsername,
@ -109,6 +119,10 @@ export function toLiteMarket(contract: Contract): LiteMarket {
: closeTime,
question,
description,
textDescription:
typeof description === 'string'
? description
: richTextToString(description),
tags,
url: `https://manifold.markets/${creatorUsername}/${slug}`,
pool,
@ -124,6 +138,10 @@ export function toLiteMarket(contract: Contract): LiteMarket {
resolution,
resolutionTime,
resolutionProbability,
lastUpdatedTime,
min,
max,
isLogScale,
})
}

View File

@ -16,7 +16,7 @@ import {
useAcceptedChallenges,
useUserChallenges,
} from 'web/lib/firebase/challenges'
import { Challenge } from 'common/challenge'
import { Challenge, CHALLENGES_ENABLED } from 'common/challenge'
import { Tabs } from 'web/components/layout/tabs'
import { SiteLink } from 'web/components/site-link'
import { UserLink } from 'web/components/user-page'
@ -29,6 +29,7 @@ import { copyToClipboard } from 'web/lib/util/copy'
import toast from 'react-hot-toast'
import { Modal } from 'web/components/layout/modal'
import { QRCode } from 'web/components/qr-code'
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
dayjs.extend(customParseFormat)
const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate'
@ -37,6 +38,7 @@ const amountClass = columnClass + ' max-w-[75px] font-bold'
export default function ChallengesListPage() {
const user = useUser()
const challenges = useAcceptedChallenges()
const [open, setOpen] = React.useState(false)
const userChallenges = useUserChallenges(user?.id)
.concat(
user ? challenges.filter((c) => c.acceptances[0].userId === user.id) : []
@ -70,8 +72,25 @@ export default function ChallengesListPage() {
<Col className="w-full px-8">
<Row className="items-center justify-between">
<Title text="Challenges" />
{CHALLENGES_ENABLED && (
<Button size="lg" color="gradient" onClick={() => setOpen(true)}>
Create Challenge
<CreateChallengeModal
isOpen={open}
setOpen={setOpen}
user={user}
/>
</Button>
)}
</Row>
<p>Find or create a question to challenge someone to a bet.</p>
<p>
Want to create your own challenge?
<SiteLink className={'mx-1 font-bold'} href={'/home'}>
Find
</SiteLink>
a market you and a friend disagree on and hit the challenge button, or
tap the button above to create a new market & challenge in one.
</p>
<Tabs tabs={[...userTab, ...publicTab]} />
</Col>

View File

@ -46,7 +46,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Button } from 'web/components/button'
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
import { Comment } from 'common/comment'
import { GroupComment } from 'common/comment'
import { GroupChat } from 'web/components/groups/group-chat'
export const getStaticProps = fromPropz(getStaticPropz)
@ -123,7 +123,7 @@ export default function GroupPage(props: {
topTraders: User[]
creatorScores: { [userId: string]: number }
topCreators: User[]
messages: Comment[]
messages: GroupComment[]
}) {
props = usePropz(props, getStaticPropz) ?? {
group: null,
@ -607,7 +607,9 @@ function AddContractButton(props: { group: Group; user: User }) {
user={user}
hideOrderSelector={true}
onContractClick={addContractToCurrentGroup}
gridClassName="gap-3 space-y-3"
overrideGridClassName={
'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1'
}
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
additionalFilter={{ excludeContractIds: group.contractIds }}
highlightOptions={{

View File

@ -12,15 +12,18 @@ import { getContractFromSlug } from 'web/lib/firebase/contracts'
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { GetServerSideProps } from 'next'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
})
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx)
const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null
return { props: { auth } }
}
const Home = (props: { auth: { user: User } }) => {
const { user } = props.auth
const Home = (props: { auth: { user: User } | null }) => {
const user = props.auth ? props.auth.user : null
const [contract, setContract] = useContractPage()
const router = useRouter()
@ -42,6 +45,7 @@ const Home = (props: { auth: { user: User } }) => {
// Update the url without switching pages in Nextjs.
history.pushState(null, '', `/${c.creatorUsername}/${c.slug}`)
}}
isWholePage
/>
</Col>
<button

View File

@ -30,12 +30,6 @@ export default function Home(props: { hotContracts: Contract[] }) {
<Col className="items-center">
<Col className="max-w-3xl">
<LandingPagePanel hotContracts={hotContracts ?? []} />
{/* <p className="mt-6 text-gray-500">
View{' '}
<SiteLink href="/markets" className="font-bold text-gray-700">
all markets
</SiteLink>
</p> */}
</Col>
</Col>
</Page>

View File

@ -1,19 +0,0 @@
import { useUser } from 'web/hooks/use-user'
import { ContractSearch } from '../components/contract-search'
import { Page } from '../components/page'
import { SEO } from '../components/SEO'
// TODO: Rename endpoint to "Explore"
export default function Markets() {
const user = useUser()
return (
<Page>
<SEO
title="Explore"
description="Discover what's new, trending, or soon-to-close. Or search thousands of prediction markets."
url="/markets"
/>
<ContractSearch user={user} />
</Page>
)
}

View File

@ -1,24 +0,0 @@
import { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user'
import { ContractSearch } from '../../components/contract-search'
import { Page } from '../../components/page'
import { Title } from '../../components/title'
export default function TagPage() {
const router = useRouter()
const user = useUser()
const { tag } = router.query as { tag: string }
if (!router.isReady) return <div />
return (
<Page>
<Title text={`#${tag}`} />
<ContractSearch
user={user}
defaultSort="newest"
defaultFilter="all"
additionalFilter={{ tag }}
/>
</Page>
)
}

View File

@ -18,7 +18,9 @@ online = false
firstPrint = false
flag = true
page = 1
sets = {}
window.console.log(sets)
document.location.search.split('&').forEach((pair) => {
let v = pair.split('=')
if (v[0] === '?whichguesser') {
@ -32,39 +34,38 @@ document.location.search.split('&').forEach((pair) => {
}
})
let firstFetch = fetch('jsons/' + whichGuesser + page + '.json')
if (whichGuesser === 'basic') {
fetch('jsons/set.json')
.then((response) => response.json())
.then((data) => (sets = data))
}
let firstFetch = fetch('jsons/' + whichGuesser + '.json')
fetchToResponse(firstFetch)
function putIntoMapAndFetch(data) {
putIntoMap(data.data)
if (data.has_more) {
page += 1
window.setTimeout(() =>
fetchToResponse(fetch('jsons/' + whichGuesser + page + '.json'))
for (const [key, value] of Object.entries(allData)) {
nameList.push(key)
probList.push(
value.length + (probList.length === 0 ? 0 : probList[probList.length - 1])
)
} else {
for (const [key, value] of Object.entries(allData)) {
nameList.push(key)
probList.push(
value.length +
(probList.length === 0 ? 0 : probList[probList.length - 1])
)
unseenTotal = total
}
window.console.log(allData)
window.console.log(total)
window.console.log(probList)
window.console.log(nameList)
if (whichGuesser === 'counterspell') {
document.getElementById('guess-type').innerText = 'Counterspell Guesser'
} else if (whichGuesser === 'burn') {
document.getElementById('guess-type').innerText = 'Match With Hot Singles'
} else if (whichGuesser === 'beast') {
document.getElementById('guess-type').innerText =
'Finding Fantastic Beasts'
}
setUpNewGame()
unseenTotal = total
}
window.console.log(allData)
window.console.log(total)
window.console.log(probList)
window.console.log(nameList)
if (whichGuesser === 'counterspell') {
document.getElementById('guess-type').innerText = 'Counterspell Guesser'
} else if (whichGuesser === 'burn') {
document.getElementById('guess-type').innerText = 'Match With Hot Singles'
} else if (whichGuesser === 'beast') {
document.getElementById('guess-type').innerText = 'Finding Fantastic Beasts'
} else if (whichGuesser === 'basic') {
document.getElementById('guess-type').innerText = 'How Basic'
}
setUpNewGame()
}
function getKSamples() {
@ -134,11 +135,21 @@ function determineIfSkip(card) {
}
}
if (firstPrint) {
if (
card.reprint === true ||
(card.frame_effects && card.frame_effects.includes('showcase'))
) {
return true
if (whichGuesser == 'basic') {
if (
card.set_type !== 'expansion' &&
card.set_type !== 'funny' &&
card.set_type !== 'draft_innovation'
) {
return true
}
} else {
if (
card.reprint === true ||
(card.frame_effects && card.frame_effects.includes('showcase'))
) {
return true
}
}
}
// reskinned card names show in art crop
@ -160,13 +171,16 @@ function putIntoMap(data) {
if (card.card_faces) {
name = card.card_faces[0].name
}
if (whichGuesser === 'basic') {
name =
'<img class="symbol" style="width: 17px; height: 17px" src="' +
sets[name][1] +
'" /> ' +
sets[name][0]
}
let normalImg = ''
if (card.image_uris.normal) {
normalImg = card.image_uris.normal
} else if (card.image_uris.large) {
normalImg = card.image_uris.large
} else if (card.image_uris.small) {
normalImg = card.image_uris.small
} else {
continue
}
@ -211,7 +225,9 @@ function setUpNewGame() {
artDict = sampledData[0]
let randomImages = Object.keys(artDict)
shuffleArray(randomImages)
let namesList = Array.from(sampledData[1]).sort()
let namesList = Array.from(sampledData[1]).sort((a, b) =>
removeSymbol(a).localeCompare(removeSymbol(b))
)
// fill in the new cards and names
for (let cardIndex = 1; cardIndex <= k; cardIndex++) {
let currCard = document.getElementById('card-' + cardIndex)
@ -224,11 +240,16 @@ function setUpNewGame() {
for (nameIndex = 1; nameIndex <= k + extra; nameIndex++) {
currName = document.getElementById('name-' + nameIndex)
// window.console.log(currName)
currName.innerText = namesList[nameIndex - 1]
currName.innerHTML = namesList[nameIndex - 1]
nameBank.appendChild(currName)
}
}
function removeSymbol(name) {
let arr = name.split('>')
return arr[arr.length - 1]
}
function checkAnswers() {
let score = k
// show the correct full cards
@ -236,9 +257,13 @@ function checkAnswers() {
currCard = document.getElementById('card-' + cardIndex)
let incorrect = true
if (currCard.dataset.name) {
let guess = document.getElementById(currCard.dataset.name).innerText
// window.console.log(artDict[currCard.dataset.url][0], guess);
incorrect = artDict[currCard.dataset.url][0] !== guess
// remove image text
let guess = removeSymbol(
document.getElementById(currCard.dataset.name).innerText
)
let ans = removeSymbol(artDict[currCard.dataset.url][0])
window.console.log(ans, guess)
incorrect = ans !== guess
// decide if their guess was correct
}
if (incorrect) currCard.classList.add('incorrect')
@ -352,6 +377,10 @@ function dropOnCard(id, data) {
}
function setWordsLeft() {
cardName = 'Unused Card Names: '
if (whichGuesser === 'basic') {
cardName = 'Unused Set Names: '
}
document.getElementById('words-left').innerText =
'Unused Card Names: ' + wordsLeft + '/Images: ' + imagesLeft
cardName + wordsLeft + '/Images: ' + imagesLeft
}

View File

@ -77,7 +77,7 @@
}
.answer-page .card {
height: 350px;
height: 353px;
/*padding-top: 310px;*/
/*background-size: cover;*/
overflow: hidden;
@ -253,16 +253,6 @@
.name {
width: 300px;
}
.card {
width: 300px;
background-size: 300px;
height: 266px;
}
.answer-page .card {
height: 454px;
}
}
</style>
</head>

View File

@ -3,7 +3,8 @@ import requests
import json
# add category name here
allCategories = ['counterspell', 'beast', 'terror', 'wrath', 'burn']
allCategories = ['counterspell', 'beast', 'burn'] #, 'terror', 'wrath']
specialCategories = ['set', 'basic']
def generate_initial_query(category):
@ -12,11 +13,11 @@ def generate_initial_query(category):
string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure'
elif category == 'beast':
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken'
elif category == 'terror':
string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
'%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure'
elif category == 'wrath':
string_query += 'otag%3Asweeper-creature+%28t%3Ainstant+or+t%3Asorcery%29+not%3Aadventure'
# elif category == 'terror':
# string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
# '%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure'
# elif category == 'wrath':
# string_query += 'otag%3Asweeper-creature+%28t%3Ainstant+or+t%3Asorcery%29+not%3Aadventure'
elif category == 'burn':
string_query += '%28c>%3Dr+or+mana>%3Dr%29+%28o%3A%2Fdamage+to+them%2F+or+%28o%3Adeals+o%3Adamage+o%3A' \
'%2Fcontroller%28%5C.%7C+%29%2F%29+or+o%3A%2F~+deals+%28.%7C..%29+damage+to+%28any+target%7C' \
@ -24,9 +25,19 @@ def generate_initial_query(category):
'+%28type%3Ainstant+or+type%3Asorcery%29+not%3Aadventure'
# add category string query here
string_query += '+-%28set%3Asld+%28%28cn>%3D231+cn<%3D233%29+or+%28cn>%3D321+cn<%3D324%29+or+%28cn>%3D185+cn' \
'<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \
'%29+-name%3A%2F%5EA-%2F+not%3Adfc+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-set%3Adbl' \
'+-frame%3Aextendedart+language%3Aenglish&unique=art&page='
'<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \
'%29+-name%3A%2F%5EA-%2F+not%3Adfc+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-set%3Adbl' \
'+language%3Aenglish&order=released&dir=asc&unique=prints&page='
print(string_query)
return string_query
def generate_initial_special_query(category):
string_query = 'https://api.scryfall.com/cards/search?q='
if category == 'set':
return 'https://api.scryfall.com/sets'
elif category == 'basic':
string_query += 't%3Abasic&order=released&dir=asc&unique=prints&page='
# add category string query here
print(string_query)
return string_query
@ -34,31 +45,60 @@ def generate_initial_query(category):
def fetch_and_write_all(category, query):
count = 1
will_repeat = True
all_cards = {'data' : []}
art_names = set()
while will_repeat:
will_repeat = fetch_and_write(category, query, count)
count += 1
response = fetch(query, count)
will_repeat = response['has_more']
count+=1
to_compact_write_form(all_cards, art_names, response, category)
with open('jsons/' + category + '.json', 'w') as f:
json.dump(all_cards, f)
def fetch_and_write(category, query, count):
def fetch_and_write_all_special(category, query):
count = 1
will_repeat = True
all_cards = {'data' : []}
art_names = set()
while will_repeat:
if category == 'set':
response = fetch_special(query)
else:
response = fetch(query, count)
will_repeat = response['has_more']
count+=1
to_compact_write_form_special(all_cards, art_names, response, category)
with open('jsons/' + category + '.json', 'w') as f:
json.dump(all_cards, f)
def fetch(query, count):
query += str(count)
response = requests.get(f"{query}").json()
time.sleep(0.1)
with open('jsons/' + category + str(count) + '.json', 'w') as f:
json.dump(to_compact_write_form(response), f)
return response['has_more']
return response
def fetch_special(query):
response = requests.get(f"{query}").json()
time.sleep(0.1)
return response
def to_compact_write_form(response):
fieldsToUse = ['has_more']
def to_compact_write_form(smallJson, art_names, response, category):
fieldsInCard = ['name', 'image_uris', 'content_warning', 'flavor_name', 'reprint', 'frame_effects', 'digital',
'set_type']
smallJson = dict()
data = []
# write all fields needed in response
for field in fieldsToUse:
smallJson[field] = response[field]
# write all fields needed in card
for card in response['data']:
# do not repeat art
if 'illustration_id' not in card or card['illustration_id'] in art_names:
continue
else:
art_names.add(card['illustration_id'])
write_card = dict()
for field in fieldsInCard:
if field == 'name' and 'card_faces' in card:
@ -68,8 +108,33 @@ def to_compact_write_form(response):
elif field in card:
write_card[field] = card[field]
data.append(write_card)
smallJson['data'] = data
return smallJson
smallJson['data'] += data
def to_compact_write_form_special(smallJson, art_names, response, category):
fieldsInBasic = ['image_uris', 'set', 'set_type', 'digital']
data = []
# write all fields needed in card
for card in response['data']:
if category == 'basic':
write_card = dict()
# do not repeat art
if 'illustration_id' not in card or card['illustration_id'] in art_names:
continue
else:
art_names.add(card['illustration_id'])
for field in fieldsInBasic:
if field == 'image_uris':
write_card['image_uris'] = write_image_uris(card['image_uris'])
elif field == 'set':
write_card['name'] = card['set']
elif field in card:
write_card[field] = card[field]
data.append(write_card)
else:
if card['set_type'] != 'token':
smallJson[card['code']] = [card['name'],card['icon_svg_uri']]
smallJson['data'] += data
# only write images needed
@ -87,6 +152,9 @@ def write_image_uris(card_image_uris):
if __name__ == "__main__":
for category in allCategories:
# for category in allCategories:
# print(category)
# fetch_and_write_all(category, generate_initial_query(category))
for category in specialCategories:
print(category)
fetch_and_write_all(category, generate_initial_query(category))
fetch_and_write_all_special(category, generate_initial_special_query(category))

View File

@ -159,6 +159,16 @@
>
<br />
<input type="radio" id="basic" name="whichguesser" value="basic" />
<label class="radio-label" for="basic">
<img
class="thumbnail"
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/3/03683fbb-9843-4c14-bb95-387150e97c90.jpg?1642161346"
/>
<h3>How Basic</h3></label
>
<br />
<details id="addl-options">
<summary>
<img
@ -174,7 +184,7 @@
<label for="un">include un-cards</label>
<br />
<input type="checkbox" name="original" id="original" />
<label for="original">restrict to only original printing</label>
<label for="original">only original set printing</label>
</details>
<input type="submit" id="submit" value="Play" />
</form>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
<url><loc>https://manifold.markets/markets</loc><changefreq>hourly</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/home</loc><changefreq>hourly</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
</urlset>
</urlset>

View File

@ -5764,6 +5764,11 @@ eslint-config-next@12.1.6:
eslint-plugin-react "^7.29.4"
eslint-plugin-react-hooks "^4.5.0"
eslint-config-prettier@8.5.0:
version "8.5.0"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz#5a81680ec934beca02c7b1a61cf8ca34b66feab1"
integrity sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==
eslint-import-resolver-node@^0.3.6:
version "0.3.6"
resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz#4048b958395da89668252001dbd9eca6b83bacbd"