Merge branch 'main' into monthly-leaderboard

This commit is contained in:
Pico2x 2022-09-07 16:52:40 +01:00
commit 5f8cd66420
255 changed files with 8872 additions and 7501 deletions

43
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Run linter (remove unused imports)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
env:
FORCE_COLOR: 3
NEXT_TELEMETRY_DISABLED: 1
# mqp - i generated a personal token to use for these writes -- it's unclear
# why, but the default token didn't work, even when i gave it max permissions
jobs:
lint:
name: Auto-lint
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
token: ${{ secrets.FORMATTER_ACCESS_TOKEN }}
- name: Restore cached node_modules
uses: actions/cache@v2
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }}
- name: Install missing dependencies
run: yarn install --prefer-offline --frozen-lockfile
- name: Run lint script
run: yarn lint
- name: Commit any lint changes
if: always()
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: Auto-remove unused imports
branch: ${{ github.head_ref }}

View File

@ -1,5 +1,5 @@
module.exports = {
plugins: ['lodash'],
plugins: ['lodash', 'unused-imports'],
extends: ['eslint:recommended'],
ignorePatterns: ['lib'],
env: {
@ -26,6 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_',
},
],
'unused-imports/no-unused-imports': 'error',
},
},
],

158
common/calculate-metrics.ts Normal file
View File

@ -0,0 +1,158 @@
import { last, sortBy, sum, sumBy } from 'lodash'
import { calculatePayout } from './calculate'
import { Bet } from './bet'
import { Contract } from './contract'
import { PortfolioMetrics, User } from './user'
import { DAY_MS } from './util/time'
const computeInvestmentValue = (
bets: Bet[],
contractsDict: { [k: string]: Contract }
) => {
return sumBy(bets, (bet) => {
const contract = contractsDict[bet.contractId]
if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0
const payout = calculatePayout(contract, bet, 'MKT')
const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0
return value
})
}
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime
)
return sum(
periodFilteredContracts.map((contract) => sum(Object.values(contract.pool)))
)
}
export const computeVolume = (contractBets: Bet[], since: number) => {
return sumBy(contractBets, (b) =>
b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0
)
}
const calculateProbChangeSince = (descendingBets: Bet[], since: number) => {
const newestBet = descendingBets[0]
if (!newestBet) return 0
const betBeforeSince = descendingBets.find((b) => b.createdTime < since)
if (!betBeforeSince) {
const oldestBet = last(descendingBets) ?? newestBet
return newestBet.probAfter - oldestBet.probBefore
}
return newestBet.probAfter - betBeforeSince.probAfter
}
export const calculateProbChanges = (descendingBets: Bet[]) => {
const now = Date.now()
const yesterday = now - DAY_MS
const weekAgo = now - 7 * DAY_MS
const monthAgo = now - 30 * DAY_MS
return {
day: calculateProbChangeSince(descendingBets, yesterday),
week: calculateProbChangeSince(descendingBets, weekAgo),
month: calculateProbChangeSince(descendingBets, monthAgo),
}
}
export const calculateCreatorVolume = (userContracts: Contract[]) => {
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
const monthlyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 30 * DAY_MS
)
const weeklyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 7 * DAY_MS
)
const dailyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 1 * DAY_MS
)
return {
daily: dailyCreatorVolume,
weekly: weeklyCreatorVolume,
monthly: monthlyCreatorVolume,
allTime: allTimeCreatorVolume,
}
}
export const calculateNewPortfolioMetrics = (
user: User,
contractsById: { [k: string]: Contract },
currentBets: Bet[]
) => {
const investmentValue = computeInvestmentValue(currentBets, contractsById)
const newPortfolio = {
investmentValue: investmentValue,
balance: user.balance,
totalDeposits: user.totalDeposits,
timestamp: Date.now(),
userId: user.id,
}
return newPortfolio
}
const calculateProfitForPeriod = (
startTime: number,
descendingPortfolio: PortfolioMetrics[],
currentProfit: number
) => {
const startingPortfolio = descendingPortfolio.find(
(p) => p.timestamp < startTime
)
if (startingPortfolio === undefined) {
return currentProfit
}
const startingProfit = calculateTotalProfit(startingPortfolio)
return currentProfit - startingProfit
}
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
}
export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[],
newPortfolio: PortfolioMetrics
) => {
const allTimeProfit = calculateTotalProfit(newPortfolio)
const descendingPortfolio = sortBy(
portfolioHistory,
(p) => p.timestamp
).reverse()
const newProfit = {
daily: calculateProfitForPeriod(
Date.now() - 1 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
weekly: calculateProfitForPeriod(
Date.now() - 7 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
monthly: calculateProfitForPeriod(
Date.now() - 30 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
allTime: allTimeProfit,
}
return newProfit
}

View File

@ -140,6 +140,8 @@ function getCpmmInvested(yourBets: Bet[]) {
const sortedBets = sortBy(yourBets, 'createdTime')
for (const bet of sortedBets) {
const { outcome, shares, amount } = bet
if (floatingEqual(shares, 0)) continue
if (amount > 0) {
totalShares[outcome] = (totalShares[outcome] ?? 0) + shares
totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount

View File

@ -23,10 +23,16 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
type OnContract = {
commentType: 'contract'
contractId: string
contractSlug: string
contractQuestion: string
answerOutcome?: string
betId?: string
// denormalized from contract
contractSlug: string
contractQuestion: string
// denormalized from bet
betAmount?: number
betOutcome?: string
}
type OnGroup = {

View File

@ -27,10 +27,10 @@ export function contractMetrics(contract: Contract) {
export function contractTextDetails(contract: Contract) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const dayjs = require('dayjs')
const { closeTime, tags } = contract
const { closeTime, groupLinks } = contract
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
const hashtags = tags.map((tag) => `#${tag}`)
const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`)
return (
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
@ -40,7 +40,7 @@ export function contractTextDetails(contract: Contract) {
).format('MMM D, h:mma')}`
: '') +
`${volumeLabel}` +
(hashtags.length > 0 ? `${hashtags.join(' ')}` : '')
(groupHashtags ? `${groupHashtags.join(' ')}` : '')
)
}
@ -92,6 +92,7 @@ export const getOpenGraphProps = (contract: Contract) => {
creatorAvatarUrl,
description,
numericValue,
resolution,
}
}
@ -103,6 +104,7 @@ export type OgCardProps = {
creatorUsername: string
creatorAvatarUrl?: string
numericValue?: string
resolution?: string
}
export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
@ -113,22 +115,32 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
creatorOutcome,
acceptorOutcome,
} = challenge || {}
const {
probability,
numericValue,
resolution,
creatorAvatarUrl,
question,
metadata,
creatorUsername,
creatorName,
} = props
const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
const probabilityParam =
props.probability === undefined
probability === undefined
? ''
: `&probability=${encodeURIComponent(props.probability ?? '')}`
: `&probability=${encodeURIComponent(probability ?? '')}`
const numericValueParam =
props.numericValue === undefined
numericValue === undefined
? ''
: `&numericValue=${encodeURIComponent(props.numericValue ?? '')}`
: `&numericValue=${encodeURIComponent(numericValue ?? '')}`
const creatorAvatarUrlParam =
props.creatorAvatarUrl === undefined
creatorAvatarUrl === undefined
? ''
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}`
: `&creatorAvatarUrl=${encodeURIComponent(creatorAvatarUrl ?? '')}`
const challengeUrlParams = challenge
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
@ -136,16 +148,21 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
: ''
const resolutionUrlParam = resolution
? `&resolution=${encodeURIComponent(resolution)}`
: ''
// URL encode each of the props, then add them as query params
return (
`https://manifold-og-image.vercel.app/m.png` +
`?question=${encodeURIComponent(props.question)}` +
`?question=${encodeURIComponent(question)}` +
probabilityParam +
numericValueParam +
`&metadata=${encodeURIComponent(props.metadata)}` +
`&creatorName=${encodeURIComponent(props.creatorName)}` +
`&metadata=${encodeURIComponent(metadata)}` +
`&creatorName=${encodeURIComponent(creatorName)}` +
creatorAvatarUrlParam +
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` +
challengeUrlParams
`&creatorUsername=${encodeURIComponent(creatorUsername)}` +
challengeUrlParams +
resolutionUrlParam
)
}

View File

@ -57,6 +57,10 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
uniqueBettorIds?: string[]
uniqueBettorCount?: number
popularityScore?: number
followerCount?: number
featuredOnHomeRank?: number
likedByUserIds?: string[]
likedByUserCount?: number
} & T
export type BinaryContract = Contract & Binary
@ -83,6 +87,12 @@ export type CPMM = {
pool: { [outcome: string]: number }
p: number // probability constant in y^p * n^(1-p) = k
totalLiquidity: number // in M$
prob: number
probChanges: {
day: number
week: number
month: number
}
}
export type Binary = {

View File

@ -11,6 +11,7 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 500
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT =
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 100
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5

View File

@ -34,6 +34,11 @@ export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE'
export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace(
/-/g,
'_'
)}`
// Manifold's domain or any subdomains thereof
export const CORS_ORIGIN_MANIFOLD = new RegExp(
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$'
@ -44,3 +49,7 @@ export const CORS_ORIGIN_VERCEL = new RegExp(
)
// Any localhost server on any port
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/
export function firestoreConsolePath(contractId: string) {
return `https://console.firebase.google.com/project/${PROJECT_ID}/firestore/data/~2Fcontracts~2F${contractId}`
}

View File

@ -35,6 +35,7 @@ export type Economy = {
BETTING_STREAK_BONUS_AMOUNT?: number
BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number
}
type FirebaseConfig = {
@ -70,6 +71,8 @@ export const PROD_CONFIG: EnvConfig = {
'taowell@gmail.com', // Stephen
'abc.sinclair@gmail.com', // Sinclair
'manticmarkets@gmail.com', // Manifold
'iansphilips@gmail.com', // Ian
'd4vidchee@gmail.com', // D4vid
],
visibility: 'PUBLIC',

View File

@ -1,9 +1,9 @@
export const PLATFORM_FEE = 0
export const CREATOR_FEE = 0.1
export const CREATOR_FEE = 0
export const LIQUIDITY_FEE = 0
export const DPM_PLATFORM_FEE = 0.01
export const DPM_CREATOR_FEE = 0.04
export const DPM_PLATFORM_FEE = 0.0
export const DPM_CREATOR_FEE = 0.0
export const DPM_FEES = DPM_PLATFORM_FEE + DPM_CREATOR_FEE
export type Fees = {

View File

@ -6,12 +6,11 @@ export type Group = {
creatorId: string // User id
createdTime: number
mostRecentActivityTime: number
memberIds: string[] // User ids
anyoneCanJoin: boolean
contractIds: string[]
totalContracts: number
totalMembers: number
aboutPostId?: string
chatDisabled?: boolean
mostRecentChatActivityTime?: number
mostRecentContractAddedTime?: number
}
export const MAX_GROUP_NAME_LENGTH = 75

8
common/like.ts Normal file
View File

@ -0,0 +1,8 @@
export type Like = {
id: string // will be id of the object liked, i.e. contract.id
userId: string
type: 'contract'
createdTime: number
tipTxnId?: string // only holds most recent tip txn id
}
export const LIKE_TIP_AMOUNT = 5

View File

@ -10,11 +10,11 @@ import {
import { PortfolioMetrics, User } from './user'
import { filterDefined } from './util/array'
const LOAN_WEEKLY_RATE = 0.05
const LOAN_DAILY_RATE = 0.02
const calculateNewLoan = (investedValue: number, loanTotal: number) => {
const netValue = investedValue - loanTotal
return netValue * LOAN_WEEKLY_RATE
return netValue * LOAN_DAILY_RATE
}
export const getLoanUpdates = (
@ -101,7 +101,7 @@ const getBinaryContractLoanUpdate = (contract: CPMMContract, bets: Bet[]) => {
const oldestBet = minBy(bets, (bet) => bet.createdTime)
const newLoan = calculateNewLoan(invested, loanAmount)
if (isNaN(newLoan) || newLoan <= 0 || !oldestBet) return undefined
if (!isFinite(newLoan) || newLoan <= 0 || !oldestBet) return undefined
const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan
@ -118,14 +118,14 @@ const getFreeResponseContractLoanUpdate = (
contract: FreeResponseContract | MultipleChoiceContract,
bets: Bet[]
) => {
const openBets = bets.filter((bet) => bet.isSold || bet.sale)
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
return openBets.map((bet) => {
const loanAmount = bet.loanAmount ?? 0
const newLoan = calculateNewLoan(bet.amount, loanAmount)
const loanTotal = loanAmount + newLoan
if (isNaN(newLoan) || newLoan <= 0) return undefined
if (!isFinite(newLoan) || newLoan <= 0) return undefined
return {
userId: bet.userId,

View File

@ -123,6 +123,8 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
initialProbability: p,
p,
pool: pool,
prob: initialProb,
probChanges: { day: 0, week: 0, month: 0 },
}
return system

View File

@ -15,6 +15,7 @@ export type Notification = {
sourceUserUsername?: string
sourceUserAvatarUrl?: string
sourceText?: string
data?: string
sourceContractTitle?: string
sourceContractCreatorUsername?: string
@ -40,6 +41,8 @@ export type notification_source_types =
| 'challenge'
| 'betting_streak_bonus'
| 'loan'
| 'like'
| 'tip_and_like'
export type notification_source_update_types =
| 'created'
@ -70,3 +73,6 @@ export type notification_reason_types =
| 'challenge_accepted'
| 'betting_streak_incremented'
| 'loan_income'
| 'you_follow_contract'
| 'liked_your_contract'
| 'liked_and_tipped_your_contract'

View File

@ -8,11 +8,11 @@
},
"sideEffects": false,
"dependencies": {
"@tiptap/core": "2.0.0-beta.181",
"@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190",
"@tiptap/starter-kit": "2.0.0-beta.191",
"lodash": "4.17.21"
},
"devDependencies": {

12
common/post.ts Normal file
View File

@ -0,0 +1,12 @@
import { JSONContent } from '@tiptap/core'
export type Post = {
id: string
title: string
content: JSONContent
creatorId: string // User id
createdTime: number
slug: string
}
export const MAX_POST_TITLE_LENGTH = 480

View File

@ -34,6 +34,7 @@ export type User = {
followerCountCached: number
followedCategories?: string[]
homeSections?: { visible: string[]; hidden: string[] }
referredByUserId?: string
referredByContractId?: string
@ -42,6 +43,9 @@ export type User = {
shouldShowWelcome?: boolean
lastBetTime?: number
currentBettingStreak?: number
hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number
isBannedFromPosting?: boolean
}
export type PrivateUser = {
@ -54,6 +58,7 @@ export type PrivateUser = {
unsubscribedFromAnswerEmails?: boolean
unsubscribedFromGenericEmails?: boolean
unsubscribedFromWeeklyTrendingEmails?: boolean
weeklyTrendingEmailSent?: boolean
manaBonusEmailSent?: boolean
initialDeviceToken?: string
initialIpAddress?: string

View File

@ -1,6 +1,6 @@
import { union } from 'lodash'
export const removeUndefinedProps = <T>(obj: T): T => {
export const removeUndefinedProps = <T extends object>(obj: T): T => {
const newObj: any = {}
for (const key of Object.keys(obj)) {
@ -37,4 +37,3 @@ export const subtractObjects = <T extends { [key: string]: number }>(
return newObj as T
}

View File

@ -1,2 +1,3 @@
export const HOUR_MS = 60 * 60 * 1000
export const MINUTE_MS = 60 * 1000
export const HOUR_MS = 60 * MINUTE_MS
export const DAY_MS = 24 * HOUR_MS

View File

@ -35,7 +35,7 @@ export default Node.create<IframeOptions>({
HTMLAttributes: {
class: 'iframe-wrapper' + ' ' + wrapperClasses,
// Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in:
style: 'padding-bottom: 20rem;',
style: 'padding-bottom: 20rem; ',
},
}
},
@ -48,6 +48,9 @@ export default Node.create<IframeOptions>({
frameborder: {
default: 0,
},
height: {
default: 0,
},
allowfullscreen: {
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen,
@ -60,6 +63,11 @@ export default Node.create<IframeOptions>({
},
renderHTML({ HTMLAttributes }) {
this.options.HTMLAttributes.style =
this.options.HTMLAttributes.style +
' height: ' +
HTMLAttributes.height +
';'
return [
'div',
this.options.HTMLAttributes,

2
dev.sh
View File

@ -24,7 +24,7 @@ then
npx concurrently \
-n FIRESTORE,FUNCTIONS,NEXT,TS \
-c green,white,magenta,cyan \
"yarn --cwd=functions firestore" \
"yarn --cwd=functions localDbScript" \
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \

View File

@ -54,6 +54,10 @@ Returns the authenticated user.
Gets all groups, in no particular order.
Parameters:
- `availableToUserId`: Optional. if specified, only groups that the user can
join and groups they've already joined will be returned.
Requires no authorization.
### `GET /v0/groups/[slug]`
@ -62,12 +66,18 @@ Gets a group by its slug.
Requires no authorization.
### `GET /v0/groups/by-id/[id]`
### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID.
Requires no authorization.
### `GET /v0/group/by-id/[id]/markets`
Gets a group's markets by its unique ID.
Requires no authorization.
### `GET /v0/markets`
Lists all markets, ordered by creation date descending.
@ -97,7 +107,6 @@ Requires no authorization.
"creatorAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
"closeTime":1653893940000,
"question":"Will I write a new blog post today?",
"description":"I'm supposed to, or else Beeminder charges me $90.\nTentative topic ideas:\n- \"Manifold funding, a history\"\n- \"Markets and bounties allow trades through time\"\n- \"equity vs money vs time\"\n\nClose date updated to 2022-05-29 11:59 pm",
"tags":[
"personal",
"commitments"
@ -135,8 +144,6 @@ 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: 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.
@ -398,6 +405,8 @@ Requires no authorization.
bets: Bet[]
comments: Comment[]
answers?: Answer[]
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
textDescription: string // string description without formatting, images, or embeds
}
type Bet = {

View File

@ -15,6 +15,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
## API / Dev
- [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API
- [PyManifold fork](https://github.com/gappleto97/PyManifold/) - Fork maintained by [@LivInTheLookingGlass](https://manifold.markets/LivInTheLookingGlass)
- [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
@ -24,3 +25,16 @@ A list of community-created projects built on, or related to, Manifold Markets.
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets
- [mana](https://github.com/AnnikaCodes/mana) - A Discord bot for Manifold by [@arae](https://manifold.markets/arae)
## Writeups
- [Information Markets, Decision Markets, Attention Markets, Action Markets](https://astralcodexten.substack.com/p/information-markets-decision-markets) by Scott Alexander
- [Mismatched Monetary Motivation in Manifold Markets](https://kevin.zielnicki.com/2022/02/17/manifold/) by Kevin Zielnicki
- [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania
- [What I learned about running a betting market game night contest](https://shakeddown.wordpress.com/2022/08/04/what-i-learned-about-running-a-betting-market-game-night-contest/) by shakeddown
- [Free-riding on prediction markets](https://pedunculate.substack.com/p/free-riding-on-prediction-markets) by John Roxton
## Art
- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png)
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg)

View File

@ -22,6 +22,20 @@
}
]
},
{
"collectionGroup": "bets",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "isFilled",
"order": "ASCENDING"
},
{
"fieldPath": "userId",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "bets",
"queryScope": "COLLECTION_GROUP",
@ -36,6 +50,70 @@
}
]
},
{
"collectionGroup": "bets",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isCancelled",
"order": "ASCENDING"
},
{
"fieldPath": "isFilled",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "challenges",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "creatorId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "commentType",
"order": "ASCENDING"
},
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP",
@ -78,6 +156,42 @@
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "creatorId",
"order": "ASCENDING"
},
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "popularityScore",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "groupSlugs",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "popularityScore",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
@ -124,6 +238,46 @@
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "visibility",
"order": "ASCENDING"
},
{
"fieldPath": "closeTime",
"order": "ASCENDING"
},
{
"fieldPath": "popularityScore",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "visibility",
"order": "ASCENDING"
},
{
"fieldPath": "popularityScore",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",

View File

@ -10,7 +10,9 @@ service cloud.firestore {
'akrolsmir@gmail.com',
'jahooma@gmail.com',
'taowell@gmail.com',
'manticmarkets@gmail.com'
'abc.sinclair@gmail.com',
'manticmarkets@gmail.com',
'iansphilips@gmail.com'
]
}
@ -22,7 +24,7 @@ service cloud.firestore {
allow read;
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']);
// User referral rules
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
@ -43,6 +45,11 @@ service cloud.firestore {
allow read;
}
match /contracts/{contractId}/follows/{userId} {
allow read;
allow create, delete: if userId == request.auth.uid;
}
match /contracts/{contractId}/challenges/{challengeId}{
allow read;
allow create: if request.auth.uid == request.resource.data.creatorId;
@ -55,6 +62,11 @@ service cloud.firestore {
allow write: if request.auth.uid == userId;
}
match /users/{userId}/likes/{likeId} {
allow read;
allow write: if request.auth.uid == userId;
}
match /{somePath=**}/follows/{followUserId} {
allow read;
}
@ -148,25 +160,49 @@ service cloud.firestore {
.hasOnly(['isSeen', 'viewTime']);
}
match /groups/{groupId} {
match /{somePath=**}/groupMembers/{memberId} {
allow read;
}
match /{somePath=**}/groupContracts/{contractId} {
allow read;
}
match /groups/{groupId} {
allow read;
allow update: if request.auth.uid == resource.data.creatorId
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin' ]);
allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin)
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly([ 'contractIds', 'memberIds' ]);
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
allow delete: if request.auth.uid == resource.data.creatorId;
function isMember() {
return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds;
match /groupContracts/{contractId} {
allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
}
match /groupMembers/{memberId}{
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
allow delete: if request.auth.uid == resource.data.userId;
}
function isGroupMember() {
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
}
match /comments/{commentId} {
allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember();
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
}
}
match /posts/{postId} {
allow read;
allow update: if isAdmin() || request.auth.uid == resource.data.creatorId
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['name', 'content']);
allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId;
}
}
}

View File

@ -1,5 +1,5 @@
module.exports = {
plugins: ['lodash'],
plugins: ['lodash', 'unused-imports'],
extends: ['eslint:recommended'],
ignorePatterns: ['dist', 'lib'],
env: {
@ -26,6 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_',
},
],
'unused-imports/no-unused-imports': 'error',
},
},
],

View File

@ -13,8 +13,8 @@
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log",
"dev": "nodemon src/serve.ts",
"firestore": "firebase emulators:start --only firestore --import=./firestore_export",
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
"localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
"db:backup-local": "firebase emulators:export --force ./firestore_export",
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
@ -26,11 +26,11 @@
"dependencies": {
"@amplitude/node": "1.10.0",
"@google-cloud/functions-framework": "3.1.2",
"@tiptap/core": "2.0.0-beta.181",
"@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190",
"@tiptap/starter-kit": "2.0.0-beta.191",
"cors": "2.8.5",
"dayjs": "1.11.4",
"express": "4.18.1",

View File

@ -58,13 +58,23 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
createdTime: Date.now(),
mostRecentActivityTime: Date.now(),
// TODO: allow users to add contract ids on group creation
contractIds: [],
anyoneCanJoin,
memberIds,
totalContracts: 0,
totalMembers: memberIds.length,
}
await groupRef.create(group)
// create a GroupMemberDoc for each member
await Promise.all(
memberIds.map((memberId) =>
groupRef.collection('groupMembers').doc(memberId).create({
userId: memberId,
createdTime: Date.now(),
})
)
)
return { status: 'success', group: group }
})

View File

@ -15,15 +15,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 { FIXED_ANTE } from 'common/economy'
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
getCpmmInitialLiquidity,
getFreeAnswerAnte,
getMultipleChoiceAntes,
getNumericAnte,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract'
@ -34,6 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core'
import { uniq, zip } from 'lodash'
import { Bet } from '../../common/bet'
import { FieldValue } from 'firebase-admin/firestore'
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection(
@ -137,9 +140,10 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
const user = userDoc.data() as User
const ante = FIXED_ANTE
const deservesFreeMarket =
(user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
// TODO: this is broken because it's not in a transaction
if (ante > user.balance)
if (ante > user.balance && !deservesFreeMarket)
throw new APIError(400, `Balance must be at least ${ante}.`)
let group: Group | null = null
@ -151,8 +155,14 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
}
group = groupDoc.data() as Group
const groupMembersSnap = await firestore
.collection(`groups/${groupId}/groupMembers`)
.get()
const groupMemberDocs = groupMembersSnap.docs.map(
(doc) => doc.data() as { userId: string; createdTime: number }
)
if (
!group.memberIds.includes(user.id) &&
!groupMemberDocs.map((m) => m.userId).includes(user.id) &&
!group.anyoneCanJoin &&
group.creatorId !== user.id
) {
@ -207,22 +217,40 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
visibility
)
if (ante) await chargeUser(user.id, ante, true)
const providerId = deservesFreeMarket
? isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
: user.id
if (ante) await chargeUser(providerId, ante, true)
if (deservesFreeMarket)
await firestore
.collection('users')
.doc(user.id)
.update({ freeMarketsCreated: FieldValue.increment(1) })
await contractRef.create(contract)
if (group != null) {
if (!group.contractIds.includes(contractRef.id)) {
const groupContractsSnap = await firestore
.collection(`groups/${groupId}/groupContracts`)
.get()
const groupContracts = groupContractsSnap.docs.map(
(doc) => doc.data() as { contractId: string; createdTime: number }
)
if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) {
await createGroupLinks(group, [contractRef.id], auth.uid)
const groupDocRef = firestore.collection('groups').doc(group.id)
groupDocRef.update({
contractIds: uniq([...group.contractIds, contractRef.id]),
const groupContractRef = firestore
.collection(`groups/${groupId}/groupContracts`)
.doc(contract.id)
await groupContractRef.set({
contractId: contract.id,
createdTime: Date.now(),
})
}
}
const providerId = user.id
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)

View File

@ -7,7 +7,7 @@ import {
} from '../../common/notification'
import { User } from '../../common/user'
import { Contract } from '../../common/contract'
import { getValues } from './utils'
import { getValues, log } from './utils'
import { Comment } from '../../common/comment'
import { uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
@ -18,6 +18,7 @@ import { TipTxn } from '../../common/txn'
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
import { Challenge } from '../../common/challenge'
import { richTextToString } from '../../common/util/parse'
import { Like } from '../../common/like'
const firestore = admin.firestore()
type user_to_reason_texts = {
@ -33,19 +34,12 @@ export const createNotification = async (
sourceText: string,
miscData?: {
contract?: Contract
relatedSourceType?: notification_source_types
recipients?: string[]
slug?: string
title?: string
}
) => {
const {
contract: sourceContract,
relatedSourceType,
recipients,
slug,
title,
} = miscData ?? {}
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
const shouldGetNotification = (
userId: string,
@ -90,24 +84,6 @@ export const createNotification = async (
)
}
const notifyLiquidityProviders = async (
userToReasonTexts: user_to_reason_texts,
contract: Contract
) => {
const liquidityProviders = await firestore
.collection(`contracts/${contract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
)
liquidityProvidersIds.forEach((userId) => {
if (!shouldGetNotification(userId, userToReasonTexts)) return
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyUsersFollowers = async (
userToReasonTexts: user_to_reason_texts
) => {
@ -129,23 +105,6 @@ export const createNotification = async (
})
}
const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string,
relatedSourceType: notification_source_types
) => {
if (!shouldGetNotification(relatedUserId, userToReasonTexts)) return
if (relatedSourceType === 'comment') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
}
const notifyFollowedUser = (
userToReasonTexts: user_to_reason_texts,
followedUserId: string
@ -182,9 +141,150 @@ export const createNotification = async (
}
}
const notifyOtherAnswerersOnContract = async (
const notifyUserAddedToGroup = (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
relatedUserId: string
) => {
if (shouldGetNotification(relatedUserId, userToReasonTexts))
userToReasonTexts[relatedUserId] = {
reason: 'added_you_to_group',
}
}
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
if (sourceType === 'follow' && recipients?.[0]) {
notifyFollowedUser(userToReasonTexts, recipients[0])
} else if (
sourceType === 'group' &&
sourceUpdateType === 'created' &&
recipients
) {
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'created' &&
sourceContract
) {
await notifyUsersFollowers(userToReasonTexts)
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'closed' &&
sourceContract
) {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
})
} else if (
sourceType === 'liquidity' &&
sourceUpdateType === 'created' &&
sourceContract
) {
await notifyContractCreator(userToReasonTexts, sourceContract)
}
await createUsersNotifications(userToReasonTexts)
}
export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceId: string,
sourceType: notification_source_types,
sourceUpdateType: notification_source_update_types,
sourceUser: User,
idempotencyKey: string,
sourceText: string,
sourceContract: Contract,
miscData?: {
relatedSourceType?: notification_source_types
repliedUserId?: string
taggedUserIds?: string[]
}
) => {
const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {}
const createUsersNotifications = async (
userToReasonTexts: user_to_reason_texts
) => {
await Promise.all(
Object.keys(userToReasonTexts).map(async (userId) => {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reason: userToReasonTexts[userId].reason,
createdTime: Date.now(),
isSeen: false,
sourceId,
sourceType,
sourceUpdateType,
sourceContractId: sourceContract.id,
sourceUserName: sourceUser.name,
sourceUserUsername: sourceUser.username,
sourceUserAvatarUrl: sourceUser.avatarUrl,
sourceText,
sourceContractCreatorUsername: sourceContract.creatorUsername,
sourceContractTitle: sourceContract.question,
sourceContractSlug: sourceContract.slug,
sourceSlug: sourceContract.slug,
sourceTitle: sourceContract.question,
}
await notificationRef.set(removeUndefinedProps(notification))
})
)
}
// get contract follower documents and check here if they're a follower
const contractFollowersSnap = await firestore
.collection(`contracts/${sourceContract.id}/follows`)
.get()
const contractFollowersIds = contractFollowersSnap.docs.map(
(doc) => doc.data().id
)
log('contractFollowerIds', contractFollowersIds)
const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId)
}
const shouldGetNotification = (
userId: string,
userToReasonTexts: user_to_reason_texts
) => {
return (
sourceUser.id != userId &&
!Object.keys(userToReasonTexts).includes(userId)
)
}
const notifyContractFollowers = async (
userToReasonTexts: user_to_reason_texts
) => {
for (const userId of contractFollowersIds) {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'you_follow_contract',
}
}
}
const notifyContractCreator = async (
userToReasonTexts: user_to_reason_texts
) => {
if (
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
stillFollowingContract(sourceContract.creatorId)
)
userToReasonTexts[sourceContract.creatorId] = {
reason: 'on_users_contract',
}
}
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const answers = await getValues<Answer>(
firestore
@ -194,7 +294,10 @@ export const createNotification = async (
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_answer',
}
@ -202,8 +305,7 @@ export const createNotification = async (
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
userToReasonTexts: user_to_reason_texts
) => {
const comments = await getValues<Comment>(
firestore
@ -213,7 +315,10 @@ export const createNotification = async (
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_comment',
}
@ -221,8 +326,7 @@ export const createNotification = async (
}
const notifyBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
userToReasonTexts: user_to_reason_texts
) => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`)
@ -240,84 +344,86 @@ export const createNotification = async (
}
)
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyUserAddedToGroup = (
const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string
relatedUserId: string,
relatedSourceType: notification_source_types
) => {
if (shouldGetNotification(relatedUserId, userToReasonTexts))
userToReasonTexts[relatedUserId] = {
reason: 'added_you_to_group',
}
}
const notifyContractCreatorOfUniqueBettorsBonus = async (
userToReasonTexts: user_to_reason_texts,
userId: string
) => {
userToReasonTexts[userId] = {
reason: 'unique_bettors_on_your_contract',
}
}
const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
if (sourceType === 'follow' && recipients?.[0]) {
notifyFollowedUser(userToReasonTexts, recipients[0])
} else if (
sourceType === 'group' &&
sourceUpdateType === 'created' &&
recipients
) {
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
}
// The following functions need sourceContract to be defined.
if (!sourceContract) return userToReasonTexts
if (
sourceType === 'comment' ||
sourceType === 'answer' ||
(sourceType === 'contract' &&
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
shouldGetNotification(relatedUserId, userToReasonTexts) &&
stillFollowingContract(relatedUserId)
) {
if (sourceType === 'comment') {
if (recipients?.[0] && relatedSourceType)
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
if (relatedSourceType === 'comment') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
await notifyUsersFollowers(userToReasonTexts)
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
})
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
await notifyContractCreator(userToReasonTexts, sourceContract)
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
// Note: the daily bonus won't have a contract attached to it
await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
}
return userToReasonTexts
}
const userToReasonTexts = await getUsersToNotify()
const notifyTaggedUsers = (
userToReasonTexts: user_to_reason_texts,
userIds: (string | undefined)[]
) => {
userIds.forEach((id) => {
console.log('tagged user: ', id)
// Allowing non-following users to get tagged
if (id && shouldGetNotification(id, userToReasonTexts))
userToReasonTexts[id] = {
reason: 'tagged_user',
}
})
}
const notifyLiquidityProviders = async (
userToReasonTexts: user_to_reason_texts
) => {
const liquidityProviders = await firestore
.collection(`contracts/${sourceContract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
)
liquidityProvidersIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
) {
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
}
})
}
const userToReasonTexts: user_to_reason_texts = {}
if (sourceType === 'comment') {
if (repliedUserId && relatedSourceType)
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType)
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? [])
}
await notifyContractCreator(userToReasonTexts)
await notifyOtherAnswerersOnContract(userToReasonTexts)
await notifyLiquidityProviders(userToReasonTexts)
await notifyBettorsOnContract(userToReasonTexts)
await notifyOtherCommentersOnContract(userToReasonTexts)
// if they weren't added previously, add them now
await notifyContractFollowers(userToReasonTexts)
await createUsersNotifications(userToReasonTexts)
}
@ -565,3 +671,85 @@ export const createBettingStreakBonusNotification = async (
}
return await notificationRef.set(removeUndefinedProps(notification))
}
export const createLikeNotification = async (
fromUser: User,
toUser: User,
like: Like,
idempotencyKey: string,
contract: Contract,
tip?: TipTxn
) => {
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract',
createdTime: Date.now(),
isSeen: false,
sourceId: like.id,
sourceType: tip ? 'tip_and_like' : 'like',
sourceUpdateType: 'created',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: tip?.amount.toString(),
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: contract.slug,
sourceTitle: contract.question,
}
return await notificationRef.set(removeUndefinedProps(notification))
}
export async function filterUserIdsForOnlyFollowerIds(
userIds: string[],
contractId: string
) {
// get contract follower documents and check here if they're a follower
const contractFollowersSnap = await firestore
.collection(`contracts/${contractId}/follows`)
.get()
const contractFollowersIds = contractFollowersSnap.docs.map(
(doc) => doc.data().id
)
return userIds.filter((id) => contractFollowersIds.includes(id))
}
export const createUniqueBettorBonusNotification = async (
contractCreatorId: string,
bettor: User,
txnId: string,
contract: Contract,
amount: number,
idempotencyKey: string
) => {
const notificationRef = firestore
.collection(`/users/${contractCreatorId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: contractCreatorId,
reason: 'unique_bettors_on_your_contract',
createdTime: Date.now(),
isSeen: false,
sourceId: txnId,
sourceType: 'bonus',
sourceUpdateType: 'created',
sourceUserName: bettor.name,
sourceUserUsername: bettor.username,
sourceUserAvatarUrl: bettor.avatarUrl,
sourceText: amount.toString(),
sourceSlug: contract.slug,
sourceTitle: contract.question,
// Perhaps not necessary, but just in case
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
sourceContractTitle: contract.question,
sourceContractCreatorUsername: contract.creatorUsername,
}
return await notificationRef.set(removeUndefinedProps(notification))
}

View File

@ -0,0 +1,83 @@
import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core'
import { z } from 'zod'
const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection(
z.record(z.any()),
z.object({
type: z.string().optional(),
attrs: z.record(z.any()).optional(),
content: z.array(contentSchema).optional(),
marks: z
.array(
z.intersection(
z.record(z.any()),
z.object({
type: z.string(),
attrs: z.record(z.any()).optional(),
})
)
)
.optional(),
text: z.string().optional(),
})
)
)
const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
content: contentSchema,
})
export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
const { title, content } = validate(postSchema, req.body)
const creator = await getUser(auth.uid)
if (!creator)
throw new APIError(400, 'No user exists with the authenticated user ID.')
console.log('creating post owned by', creator.username, 'titled', title)
const slug = await getSlug(title)
const postRef = firestore.collection('posts').doc()
const post: Post = {
id: postRef.id,
creatorId: creator.id,
slug,
title,
createdTime: Date.now(),
content: content,
}
await postRef.create(post)
return { status: 'success', post }
})
export const getSlug = async (title: string) => {
const proposedSlug = slugify(title)
const preexistingPost = await getPostFromSlug(proposedSlug)
return preexistingPost ? proposedSlug + '-' + randomString() : proposedSlug
}
export async function getPostFromSlug(slug: string) {
const firestore = admin.firestore()
const snap = await firestore
.collection('posts')
.where('slug', '==', slug)
.get()
return snap.empty ? undefined : (snap.docs[0].data() as Post)
}

View File

@ -1,14 +1,8 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { uniq } from 'lodash'
import {
MANIFOLD_AVATAR_URL,
MANIFOLD_USERNAME,
PrivateUser,
User,
} from '../../common/user'
import { getUser, getUserByUsername, getValues, isProd } from './utils'
import { PrivateUser, User } from '../../common/user'
import { getUser, getUserByUsername, getValues } from './utils'
import { randomString } from '../../common/util/random'
import {
cleanDisplayName,
@ -22,12 +16,8 @@ import {
import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from 'common/economy'
import { Group } from '../../common/group'
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
const bodySchema = z.object({
deviceToken: z.string().optional(),
@ -126,42 +116,8 @@ const addUserToDefaultGroups = async (user: User) => {
firestore.collection('groups').where('slug', '==', slug)
)
await firestore
.collection('groups')
.doc(groups[0].id)
.update({
memberIds: uniq(groups[0].memberIds.concat(user.id)),
})
}
for (const slug of NEW_USER_GROUP_SLUGS) {
const groups = await getValues<Group>(
firestore.collection('groups').where('slug', '==', slug)
)
const group = groups[0]
await firestore
.collection('groups')
.doc(group.id)
.update({
memberIds: uniq(group.memberIds.concat(user.id)),
})
const manifoldAccount = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
if (slug === 'welcome') {
const welcomeCommentDoc = firestore
.collection(`groups/${group.id}/comments`)
.doc()
await welcomeCommentDoc.create({
id: welcomeCommentDoc.id,
groupId: group.id,
userId: manifoldAccount,
text: `Welcome, @${user.username} aka ${user.name}!`,
createdTime: Date.now(),
userName: 'Manifold Markets',
userUsername: MANIFOLD_USERNAME,
userAvatarUrl: MANIFOLD_AVATAR_URL,
})
}
.collection(`groups/${groups[0].id}/groupMembers`)
.doc(user.id)
.set({ userId: user.id, createdTime: Date.now() })
}
}

View File

@ -444,7 +444,7 @@
style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe</a>.
" target="_blank">click here to unsubscribe</a> from future recommended markets.
</p>
</div>
</td>

View File

@ -1,84 +1,91 @@
<!DOCTYPE html>
<html
style="
<html style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market answer</title>
">
<style type="text/css">
img {
max-width: 100%;
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market answer</title>
<style type="text/css">
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}
body {
background-color: #f6f6f6;
.content {
padding: 0 !important;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
.content-wrap {
padding: 10px !important;
}
</style>
</head>
<body
itemscope
itemtype="http://schema.org/EmailMessage"
style="
.invoice {
width: 100% !important;
}
}
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
" bgcolor="#f6f6f6">
<table class="body-wrap" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
" bgcolor="#f6f6f6">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
" valign="top"></td>
<td class="container" width="600" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
" valign="top">
<div class="content" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -147,14 +136,8 @@
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
" bgcolor="#fff">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
">
<td class="content-wrap aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -183,35 +160,23 @@
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
" align="center" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -220,29 +185,21 @@
margin: 0;
padding: 0 0 0px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td>
</tr>
<tr
style="
" valign="top">
<a href="https://manifold.markets" target="_blank">
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
alt="Manifold Markets" />
</a>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
">
<td class="content-block aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -251,13 +208,8 @@
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
" align="center" valign="top">
<table class="invoice" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -266,19 +218,15 @@
width: 80%;
margin: 40px auto;
margin-top: 10px;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -286,37 +234,26 @@
margin: 0;
padding: 5px 0;
font-weight: bold;
"
valign="top"
>
<div>
<img
src="{{avatarUrl}}"
width="30"
height="30"
style="
" valign="top">
<div>
<img src="{{avatarUrl}}" width="30" height="30" style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
"
alt=""
/>
{{name}}
</div>
</td>
</tr>
<tr
style="
" alt="" />
{{name}}
</div>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -324,40 +261,29 @@
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
<div
style="
" valign="top">
<div style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<span style="white-space: pre-line"
>{{answer}}</span
>
</div>
</td>
</tr>
<tr
style="
">
<span style="white-space: pre-line">{{answer}}</span>
</div>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td style="padding: 20px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a
href="{{marketUrl}}"
target="_blank"
style="
">
<td style="padding: 20px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a href="{{marketUrl}}" target="_blank" style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
@ -375,38 +301,29 @@
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
">
<span style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
"><span style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View answer</span
></span
>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div
class="footer"
style="
">View answer</span></span>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div class="footer" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -415,28 +332,20 @@
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
">
<table width="100%" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
">
<td class="aligncenter content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -446,14 +355,9 @@
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
" align="center" valign="top">
Questions? Come ask in
<a href="https://discord.gg/eHQBNBqXuh" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -461,12 +365,8 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -474,26 +374,22 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
</td>
</tr>
</table>
</div>
">unsubscribe</a>.
</td>
</tr>
</table>
</div>
</td>
<td
style="
</div>
</td>
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
</tr>
</table>
</body>
</html>
" valign="top"></td>
</tr>
</table>
</body>
</html>

View File

@ -1,84 +1,91 @@
<!DOCTYPE html>
<html
style="
<html style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market closed</title>
">
<style type="text/css">
img {
max-width: 100%;
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market closed</title>
<style type="text/css">
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}
body {
background-color: #f6f6f6;
.content {
padding: 0 !important;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
.content-wrap {
padding: 10px !important;
}
</style>
</head>
<body
itemscope
itemtype="http://schema.org/EmailMessage"
style="
.invoice {
width: 100% !important;
}
}
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
" bgcolor="#f6f6f6">
<table class="body-wrap" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
" bgcolor="#f6f6f6">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
" valign="top"></td>
<td class="container" width="600" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
" valign="top">
<div class="content" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -147,14 +136,8 @@
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
" bgcolor="#fff">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
">
<td class="content-wrap aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -183,35 +160,23 @@
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
" align="center" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -220,30 +185,22 @@
margin: 0;
padding: 0 0 40px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td>
</tr>
<tr
style="
" valign="top">
<a href="https://manifold.markets" target="_blank">
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
alt="Manifold Markets" />
</a>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -252,24 +209,18 @@
margin: 0;
padding: 0 0 6px 0;
text-align: left;
"
valign="top"
>
You asked
</td>
</tr>
<tr
style="
" valign="top">
You asked
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -277,12 +228,8 @@
vertical-align: top;
margin: 0;
padding: 0 0 20px;
"
valign="top"
>
<a
href="{{url}}"
style="
" valign="top">
<a href="{{url}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif;
box-sizing: border-box;
@ -295,24 +242,18 @@
color: #4337c9;
display: block;
text-decoration: none;
"
>
{{question}}</a
>
</td>
</tr>
<tr
style="
">
{{question}}</a>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -320,12 +261,8 @@
vertical-align: top;
margin: 0;
padding: 0 0 0px;
"
valign="top"
>
<h2
class="aligncenter"
style="
" valign="top">
<h2 class="aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif;
box-sizing: border-box;
@ -335,25 +272,19 @@
font-weight: 500;
text-align: center;
margin: 10px 0 0;
"
align="center"
>
Market closed
</h2>
</td>
</tr>
<tr
style="
" align="center">
Market closed
</h2>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
">
<td class="content-block aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -362,13 +293,8 @@
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
" align="center" valign="top">
<table class="invoice" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -376,19 +302,15 @@
text-align: left;
width: 80%;
margin: 40px auto;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -396,116 +318,90 @@
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
Hi {{name}},
<br
style="
" valign="top">
Hi {{name}},
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
A market you created has closed. It's attracted
<span style="font-weight: bold">{{volume}}</span>
in bets — congrats!
<br
style="
" />
A market you created has closed. It's attracted
<span style="font-weight: bold">{{volume}}</span>
in bets — congrats!
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Resolve your market to earn {{creatorFee}} as the
creator commission.
<br
style="
" />
Please resolve your market.
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Thanks,
<br
style="
" />
Thanks,
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Manifold Team
<br
style="
" />
Manifold Team
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
</td>
</tr>
<tr
style="
" />
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td style="padding: 10px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a
href="{{url}}"
target="_blank"
style="
">
<td style="padding: 10px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a href="{{url}}" target="_blank" style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
@ -523,38 +419,29 @@
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
">
<span style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
"><span style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View market</span
></span
>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div
class="footer"
style="
">View market</span></span>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div class="footer" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -563,28 +450,20 @@
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
">
<table width="100%" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
">
<td class="aligncenter content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -594,14 +473,9 @@
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
" align="center" valign="top">
Questions? Come ask in
<a href="https://discord.gg/eHQBNBqXuh" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -609,12 +483,8 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -622,26 +492,22 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
</td>
</tr>
</table>
</div>
">unsubscribe</a>.
</td>
</tr>
</table>
</div>
</td>
<td
style="
</div>
</td>
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
</tr>
</table>
</body>
</html>
" valign="top"></td>
</tr>
</table>
</body>
</html>

View File

@ -1,84 +1,91 @@
<!DOCTYPE html>
<html
style="
<html style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market comment</title>
">
<style type="text/css">
img {
max-width: 100%;
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market comment</title>
<style type="text/css">
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}
body {
background-color: #f6f6f6;
.content {
padding: 0 !important;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
.content-wrap {
padding: 10px !important;
}
</style>
</head>
<body
itemscope
itemtype="http://schema.org/EmailMessage"
style="
.invoice {
width: 100% !important;
}
}
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
" bgcolor="#f6f6f6">
<table class="body-wrap" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
" bgcolor="#f6f6f6">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
" valign="top"></td>
<td class="container" width="600" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
" valign="top">
<div class="content" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -147,14 +136,8 @@
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
" bgcolor="#fff">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
">
<td class="content-wrap aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -183,35 +160,23 @@
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
" align="center" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -220,29 +185,21 @@
margin: 0;
padding: 0 0 0px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td>
</tr>
<tr
style="
" valign="top">
<a href="https://manifold.markets" target="_blank">
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
alt="Manifold Markets" />
</a>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
">
<td class="content-block aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -251,13 +208,8 @@
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
" align="center" valign="top">
<table class="invoice" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -266,59 +218,42 @@
width: 80%;
margin: 40px auto;
margin-top: 10px;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 5px 0;
"
valign="top"
>
<div>
<img
src="{{commentorAvatarUrl}}"
width="30"
height="30"
style="
" valign="top">
<div>
<img src="{{commentorAvatarUrl}}" width="30" height="30" style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
"
alt=""
/>
<span style="font-weight: bold"
>{{commentorName}}</span
>
{{betDescription}}
</div>
</td>
</tr>
<tr
style="
" alt="" />
<span style="font-weight: bold">{{commentorName}}</span>
{{betDescription}}
</div>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -326,40 +261,29 @@
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
<div
style="
" valign="top">
<div style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<span style="white-space: pre-line"
>{{comment}}</span
>
</div>
</td>
</tr>
<tr
style="
">
<span style="white-space: pre-line">{{comment}}</span>
</div>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td style="padding: 20px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a
href="{{marketUrl}}"
target="_blank"
style="
">
<td style="padding: 20px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a href="{{marketUrl}}" target="_blank" style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
@ -377,38 +301,29 @@
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
">
<span style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
"><span style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View comment</span
></span
>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div
class="footer"
style="
">View comment</span></span>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div class="footer" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -417,28 +332,20 @@
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
">
<table width="100%" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
">
<td class="aligncenter content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -448,14 +355,9 @@
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
" align="center" valign="top">
Questions? Come ask in
<a href="https://discord.gg/eHQBNBqXuh" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -463,12 +365,8 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -476,26 +374,22 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
</td>
</tr>
</table>
</div>
">unsubscribe</a>.
</td>
</tr>
</table>
</div>
</td>
<td
style="
</div>
</td>
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
</tr>
</table>
</body>
</html>
" valign="top"></td>
</tr>
</table>
</body>
</html>

View File

@ -1,84 +1,91 @@
<!DOCTYPE html>
<html
style="
<html style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market resolved</title>
">
<style type="text/css">
img {
max-width: 100%;
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market resolved</title>
<style type="text/css">
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}
body {
background-color: #f6f6f6;
.content {
padding: 0 !important;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
.content-wrap {
padding: 10px !important;
}
</style>
</head>
<body
itemscope
itemtype="http://schema.org/EmailMessage"
style="
.invoice {
width: 100% !important;
}
}
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
" bgcolor="#f6f6f6">
<table class="body-wrap" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
" bgcolor="#f6f6f6">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
" valign="top"></td>
<td class="container" width="600" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
" valign="top">
<div class="content" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -147,14 +136,8 @@
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
" bgcolor="#fff">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
">
<td class="content-wrap aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -183,35 +160,23 @@
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
" align="center" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -220,30 +185,22 @@
margin: 0;
padding: 0 0 40px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
</td>
</tr>
<tr
style="
" valign="top">
<a href="https://manifold.markets" target="_blank">
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
alt="Manifold Markets" />
</a>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -252,24 +209,18 @@
margin: 0;
padding: 0 0 6px 0;
text-align: left;
"
valign="top"
>
{{creatorName}} asked
</td>
</tr>
<tr
style="
" valign="top">
{{creatorName}} asked
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -277,12 +228,8 @@
vertical-align: top;
margin: 0;
padding: 0 0 20px;
"
valign="top"
>
<a
href="{{url}}"
style="
" valign="top">
<a href="{{url}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif;
box-sizing: border-box;
@ -295,24 +242,18 @@
color: #4337c9;
display: block;
text-decoration: none;
"
>
{{question}}</a
>
</td>
</tr>
<tr
style="
">
{{question}}</a>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -320,12 +261,8 @@
vertical-align: top;
margin: 0;
padding: 0 0 0px;
"
valign="top"
>
<h2
class="aligncenter"
style="
" valign="top">
<h2 class="aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif;
box-sizing: border-box;
@ -335,25 +272,19 @@
font-weight: 500;
text-align: center;
margin: 10px 0 0;
"
align="center"
>
Resolved {{outcome}}
</h2>
</td>
</tr>
<tr
style="
" align="center">
Resolved {{outcome}}
</h2>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
">
<td class="content-block aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -362,13 +293,8 @@
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
" align="center" valign="top">
<table class="invoice" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -376,19 +302,15 @@
text-align: left;
width: 80%;
margin: 40px auto;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -396,138 +318,105 @@
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
Dear {{name}},
<br
style="
" valign="top">
Dear {{name}},
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
A market you bet in has been resolved!
<br
style="
" />
A market you bet in has been resolved!
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Your investment was
<span style="font-weight: bold"
>M$ {{investment}}</span
>.
<br
style="
" />
Your investment was
<span style="font-weight: bold">{{investment}}</span>.
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Your payout is
<span style="font-weight: bold"
>M$ {{payout}}</span
>.
<br
style="
" />
Your payout is
<span style="font-weight: bold">{{payout}}</span>.
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Thanks,
<br
style="
" />
Thanks,
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
Manifold Team
<br
style="
" />
Manifold Team
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
</td>
</tr>
<tr
style="
" />
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td style="padding: 10px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a
href="{{url}}"
target="_blank"
style="
">
<td style="padding: 10px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a href="{{url}}" target="_blank" style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
@ -545,38 +434,29 @@
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
">
<span style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
"><span style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View market</span
></span
>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div
class="footer"
style="
">View market</span></span>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div class="footer" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -585,28 +465,20 @@
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
">
<table width="100%" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
">
<td class="aligncenter content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -616,14 +488,9 @@
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
" align="center" valign="top">
Questions? Come ask in
<a href="https://discord.gg/eHQBNBqXuh" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -631,12 +498,8 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -644,26 +507,22 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
</td>
</tr>
</table>
</div>
">unsubscribe</a>.
</td>
</tr>
</table>
</div>
</td>
<td
style="
</div>
</td>
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
</tr>
</table>
</body>
</html>
" valign="top"></td>
</tr>
</table>
</body>
</html>

View File

@ -4,7 +4,6 @@ import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract'
import { DPM_CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user'
import {
formatLargeNumber,
@ -54,21 +53,28 @@ export const sendMarketResolutionEmail = async (
const subject = `Resolved ${outcome}: ${contract.question}`
const creatorPayoutText =
userId === creator.id
creatorPayout >= 1 && userId === creator.id
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
: ''
const emailType = 'market-resolved'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const displayedInvestment =
Number.isNaN(investment) || investment < 0
? formatMoney(0)
: formatMoney(investment)
const displayedPayout = formatMoney(payout)
const templateData: market_resolved_template = {
userId: user.id,
name: user.name,
creatorName: creator.name,
question: contract.question,
outcome,
investment: `${Math.floor(investment)}`,
payout: `${Math.floor(payout)}${creatorPayoutText}`,
investment: displayedInvestment,
payout: displayedPayout + creatorPayoutText,
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
unsubscribeUrl,
}
@ -116,7 +122,9 @@ const toDisplayResolution = (
}
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
const { resolutionValue } = contract
const { resolution, resolutionValue } = contract
if (resolution === 'CANCEL') return 'N/A'
return resolutionValue
? formatLargeNumber(resolutionValue)
@ -178,7 +186,7 @@ export const sendPersonalFollowupEmail = async (
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?
Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your experience 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).
@ -313,7 +321,7 @@ export const sendMarketCloseEmail = async (
const { username, name, id: userId } = user
const firstName = name.split(' ')[0]
const { question, slug, volume, mechanism, collectedFees } = contract
const { question, slug, volume } = contract
const url = `https://${DOMAIN}/${username}/${slug}`
const emailType = 'market-resolve'
@ -330,10 +338,6 @@ export const sendMarketCloseEmail = async (
userId,
name: firstName,
volume: formatMoney(volume),
creatorFee:
mechanism === 'dpm-2'
? `${DPM_CREATOR_FEE * 100}% of the profits`
: formatMoney(collectedFees.creatorFee),
}
)
}

View File

@ -0,0 +1,36 @@
import * as admin from 'firebase-admin'
const firestore = admin.firestore()
export const addUserToContractFollowers = async (
contractId: string,
userId: string
) => {
const followerDoc = await firestore
.collection(`contracts/${contractId}/follows`)
.doc(userId)
.get()
if (followerDoc.exists) return
await firestore
.collection(`contracts/${contractId}/follows`)
.doc(userId)
.set({
id: userId,
createdTime: Date.now(),
})
}
export const removeUserFromContractFollowers = async (
contractId: string,
userId: string
) => {
const followerDoc = await firestore
.collection(`contracts/${contractId}/follows`)
.doc(userId)
.get()
if (!followerDoc.exists) return
await firestore
.collection(`contracts/${contractId}/follows`)
.doc(userId)
.delete()
}

View File

@ -1,33 +0,0 @@
import * as admin from 'firebase-admin'
import {
APIError,
EndpointDefinition,
lookupUser,
parseCredentials,
writeResponseError,
} from './api'
const opts = {
method: 'GET',
minInstances: 1,
concurrency: 100,
memory: '2GiB',
cpu: 1,
} as const
export const getcustomtoken: EndpointDefinition = {
opts,
handler: async (req, res) => {
try {
const credentials = await parseCredentials(req)
if (credentials.kind != 'jwt') {
throw new APIError(403, 'API keys cannot mint custom tokens.')
}
const user = await lookupUser(credentials)
const token = await admin.auth().createCustomToken(user.uid)
res.status(200).json({ token: token })
} catch (e) {
writeResponseError(e, res)
}
},
}

View File

@ -21,14 +21,15 @@ export * from './on-follow-user'
export * from './on-unfollow-user'
export * from './on-create-liquidity-provision'
export * from './on-update-group'
export * from './on-create-group'
export * from './on-update-user'
export * from './on-create-comment-on-group'
export * from './on-create-txn'
export * from './on-delete-group'
export * from './score-contracts'
export * from './weekly-markets-emails'
export * from './reset-betting-streaks'
export * from './reset-weekly-emails-flag'
export * from './on-update-contract-follow'
export * from './on-update-like'
// v2
export * from './health'
@ -69,7 +70,7 @@ import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge'
import { getcustomtoken } from './get-custom-token'
import { createpost } from './create-post'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
@ -94,7 +95,7 @@ const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge)
const getCustomTokenFunction = toCloudFunction(getcustomtoken)
const createPostFunction = toCloudFunction(createpost)
export {
healthFunction as health,
@ -117,5 +118,5 @@ export {
createCheckoutSessionFunction as createcheckoutsession,
getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge,
getCustomTokenFunction as getcustomtoken,
createPostFunction as createpost,
}

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import { getContract, getUser } from './utils'
import { createNotification } from './create-notification'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Answer } from '../../common/answer'
export const onCreateAnswer = functions.firestore
@ -20,14 +20,13 @@ export const onCreateAnswer = functions.firestore
const answerCreator = await getUser(answer.userId)
if (!answerCreator) throw new Error('Could not find answer creator')
await createNotification(
await createCommentOrAnswerOrUpdatedContractNotification(
answer.id,
'answer',
'created',
answerCreator,
eventId,
answer.text,
{ contract }
contract
)
})

View File

@ -7,7 +7,7 @@ import { getUser, getValues, isProd, log } from './utils'
import {
createBetFillNotification,
createBettingStreakBonusNotification,
createNotification,
createUniqueBettorBonusNotification,
} from './create-notification'
import { filterDefined } from '../../common/util/array'
import { Contract } from '../../common/contract'
@ -54,11 +54,11 @@ export const onCreateBet = functions.firestore
log(`Could not find contract ${contractId}`)
return
}
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId)
const bettor = await getUser(bet.userId)
if (!bettor) return
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor)
await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId)
@ -126,7 +126,7 @@ const updateBettingStreak = async (
const updateUniqueBettorsAndGiveCreatorBonus = async (
contract: Contract,
eventId: string,
bettorId: string
bettor: User
) => {
let previousUniqueBettorIds = contract.uniqueBettorIds
@ -147,13 +147,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
)
}
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId)
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId])
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
// Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await firestore.collection(`contracts`).doc(contract.id).update({
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
@ -161,7 +161,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
}
// No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettorId == contract.creatorId) return
if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
// Create combined txn for all new unique bettors
const bonusTxnDetails = {
@ -192,18 +192,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status)
} else {
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
await createNotification(
await createUniqueBettorBonusNotification(
contract.creatorId,
bettor,
result.txn.id,
'bonus',
'created',
fromUser,
eventId + '-bonus',
result.txn.amount + '',
{
contract,
slug: contract.slug,
title: contract.question,
}
contract,
result.txn.amount,
eventId + '-unique-bettor-bonus'
)
}
}

View File

@ -6,8 +6,12 @@ import { ContractComment } from '../../common/comment'
import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
import { createNotification } from './create-notification'
import {
createCommentOrAnswerOrUpdatedContractNotification,
filterUserIdsForOnlyFollowerIds,
} from './create-notification'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { addUserToContractFollowers } from './follow-market'
const firestore = admin.firestore()
@ -35,6 +39,8 @@ export const onCreateCommentOnContract = functions
const commentCreator = await getUser(comment.userId)
if (!commentCreator) throw new Error('Could not find comment creator')
await addUserToContractFollowers(contract.id, commentCreator.id)
await firestore
.collection('contracts')
.doc(contract.id)
@ -57,11 +63,15 @@ export const onCreateCommentOnContract = functions
.doc(comment.betId)
.get()
bet = betSnapshot.data() as Bet
answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers.find((answer) => answer.id === bet?.outcome)
: undefined
await change.ref.update({
betOutcome: bet.outcome,
betAmount: bet.amount,
})
}
const comments = await getValues<ContractComment>(
@ -77,24 +87,28 @@ export const onCreateCommentOnContract = functions
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId
const recipients = uniq(
compact([...parseMentions(comment.content), repliedUserId])
)
await createNotification(
await createCommentOrAnswerOrUpdatedContractNotification(
comment.id,
'comment',
'created',
commentCreator,
eventId,
richTextToString(comment.content),
{ contract, relatedSourceType, recipients }
contract,
{
relatedSourceType,
repliedUserId,
taggedUserIds: compact(parseMentions(comment.content)),
}
)
const recipientUserIds = uniq([
contract.creatorId,
...comments.map((comment) => comment.userId),
]).filter((id) => id !== comment.userId)
const recipientUserIds = await filterUserIdsForOnlyFollowerIds(
uniq([
contract.creatorId,
...comments.map((comment) => comment.userId),
]).filter((id) => id !== comment.userId),
contractId
)
await Promise.all(
recipientUserIds.map((userId) =>

View File

@ -1,46 +0,0 @@
import * as functions from 'firebase-functions'
import { GroupComment } from '../../common/comment'
import * as admin from 'firebase-admin'
import { Group } from '../../common/group'
import { User } from '../../common/user'
import { createGroupCommentNotification } from './create-notification'
const firestore = admin.firestore()
export const onCreateCommentOnGroup = functions.firestore
.document('groups/{groupId}/comments/{commentId}')
.onCreate(async (change, context) => {
const { eventId } = context
const { groupId } = context.params as {
groupId: string
}
const comment = change.data() as GroupComment
const creatorSnapshot = await firestore
.collection('users')
.doc(comment.userId)
.get()
if (!creatorSnapshot.exists) throw new Error('Could not find user')
const groupSnapshot = await firestore
.collection('groups')
.doc(groupId)
.get()
if (!groupSnapshot.exists) throw new Error('Could not find group')
const group = groupSnapshot.data() as Group
await firestore.collection('groups').doc(groupId).update({
mostRecentChatActivityTime: comment.createdTime,
})
await Promise.all(
group.memberIds.map(async (memberId) => {
return await createGroupCommentNotification(
creatorSnapshot.data() as User,
memberId,
comment,
group,
eventId
)
})
)
})

View File

@ -5,6 +5,7 @@ import { createNotification } from './create-notification'
import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core'
import { addUserToContractFollowers } from './follow-market'
export const onCreateContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
@ -18,6 +19,7 @@ export const onCreateContract = functions
const desc = contract.description as JSONContent
const mentioned = parseMentions(desc)
await addUserToContractFollowers(contract.id, contractCreator.id)
await createNotification(
contract.id,

View File

@ -1,28 +0,0 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNotification } from './create-notification'
import { Group } from '../../common/group'
export const onCreateGroup = functions.firestore
.document('groups/{groupId}')
.onCreate(async (change, context) => {
const group = change.data() as Group
const { eventId } = context
const groupCreator = await getUser(group.creatorId)
if (!groupCreator) throw new Error('Could not find group creator')
// create notifications for all members of the group
await createNotification(
group.id,
'group',
'created',
groupCreator,
eventId,
group.about,
{
recipients: group.memberIds,
slug: group.slug,
title: group.name,
}
)
})

View File

@ -1,7 +1,13 @@
import * as functions from 'firebase-functions'
import { getContract, getUser } from './utils'
import { getContract, getUser, log } from './utils'
import { createNotification } from './create-notification'
import { LiquidityProvision } from 'common/liquidity-provision'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { addUserToContractFollowers } from './follow-market'
import { FIXED_ANTE } from '../../common/economy'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
export const onCreateLiquidityProvision = functions.firestore
.document('contracts/{contractId}/liquidity/{liquidityId}')
@ -10,7 +16,14 @@ export const onCreateLiquidityProvision = functions.firestore
const { eventId } = context
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return
if (
(liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) &&
liquidity.amount === FIXED_ANTE
)
return
log(`onCreateLiquidityProvision: ${JSON.stringify(liquidity)}`)
const contract = await getContract(liquidity.contractId)
if (!contract)
@ -18,6 +31,7 @@ export const onCreateLiquidityProvision = functions.firestore
const liquidityProvider = await getUser(liquidity.userId)
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
await addUserToContractFollowers(contract.id, liquidityProvider.id)
await createNotification(
contract.id,

View File

@ -0,0 +1,46 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
// TODO: should cache the follower user ids in the contract as these triggers aren't idempotent
export const onDeleteContractFollow = functions.firestore
.document('contracts/{contractId}/follows/{userId}')
.onDelete(async (change, context) => {
const { contractId } = context.params as {
contractId: string
}
const firestore = admin.firestore()
const contract = await firestore
.collection(`contracts`)
.doc(contractId)
.get()
if (!contract.exists) throw new Error('Could not find contract')
await firestore
.collection(`contracts`)
.doc(contractId)
.update({
followerCount: FieldValue.increment(-1),
})
})
export const onCreateContractFollow = functions.firestore
.document('contracts/{contractId}/follows/{userId}')
.onCreate(async (change, context) => {
const { contractId } = context.params as {
contractId: string
}
const firestore = admin.firestore()
const contract = await firestore
.collection(`contracts`)
.doc(contractId)
.get()
if (!contract.exists) throw new Error('Could not find contract')
await firestore
.collection(`contracts`)
.doc(contractId)
.update({
followerCount: FieldValue.increment(1),
})
})

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNotification } from './create-notification'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Contract } from '../../common/contract'
export const onUpdateContract = functions.firestore
@ -29,40 +29,37 @@ export const onUpdateContract = functions.firestore
resolutionText = `${contract.resolutionValue}`
}
await createNotification(
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'resolved',
contractUpdater,
eventId,
resolutionText,
{ contract }
contract
)
} else if (
previousValue.closeTime !== contract.closeTime ||
previousValue.description !== contract.description
previousValue.question !== contract.question
) {
let sourceText = ''
if (previousValue.closeTime !== contract.closeTime && contract.closeTime)
if (
previousValue.closeTime !== contract.closeTime &&
contract.closeTime
) {
sourceText = contract.closeTime.toString()
else {
const oldTrimmedDescription = previousValue.description.trim()
const newTrimmedDescription = contract.description.trim()
if (oldTrimmedDescription === '') sourceText = newTrimmedDescription
else
sourceText = newTrimmedDescription
.split(oldTrimmedDescription)[1]
.trim()
} else if (previousValue.question !== contract.question) {
sourceText = contract.question
}
await createNotification(
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'updated',
contractUpdater,
eventId,
sourceText,
{ contract }
contract
)
}
})

View File

@ -15,21 +15,68 @@ export const onUpdateGroup = functions.firestore
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return
if (prevGroup.contractIds.length < group.contractIds.length) {
await firestore
.collection('groups')
.doc(group.id)
.update({ mostRecentContractAddedTime: Date.now() })
//TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url
// but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two
}
await firestore
.collection('groups')
.doc(group.id)
.update({ mostRecentActivityTime: Date.now() })
})
export const onCreateGroupContract = functions.firestore
.document('groups/{groupId}/groupContracts/{contractId}')
.onCreate(async (change) => {
const groupId = change.ref.parent.parent?.id
if (groupId)
await firestore
.collection('groups')
.doc(groupId)
.update({
mostRecentContractAddedTime: Date.now(),
totalContracts: admin.firestore.FieldValue.increment(1),
})
})
export const onDeleteGroupContract = functions.firestore
.document('groups/{groupId}/groupContracts/{contractId}')
.onDelete(async (change) => {
const groupId = change.ref.parent.parent?.id
if (groupId)
await firestore
.collection('groups')
.doc(groupId)
.update({
mostRecentContractAddedTime: Date.now(),
totalContracts: admin.firestore.FieldValue.increment(-1),
})
})
export const onCreateGroupMember = functions.firestore
.document('groups/{groupId}/groupMembers/{memberId}')
.onCreate(async (change) => {
const groupId = change.ref.parent.parent?.id
if (groupId)
await firestore
.collection('groups')
.doc(groupId)
.update({
mostRecentActivityTime: Date.now(),
totalMembers: admin.firestore.FieldValue.increment(1),
})
})
export const onDeleteGroupMember = functions.firestore
.document('groups/{groupId}/groupMembers/{memberId}')
.onDelete(async (change) => {
const groupId = change.ref.parent.parent?.id
if (groupId)
await firestore
.collection('groups')
.doc(groupId)
.update({
mostRecentActivityTime: Date.now(),
totalMembers: admin.firestore.FieldValue.increment(-1),
})
})
export async function removeGroupLinks(group: Group, contractIds: string[]) {
for (const contractId of contractIds) {
const contract = await getContract(contractId)

View File

@ -0,0 +1,109 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Like } from '../../common/like'
import { getContract, getUser, log } from './utils'
import { createLikeNotification } from './create-notification'
import { TipTxn } from '../../common/txn'
import { uniq } from 'lodash'
const firestore = admin.firestore()
export const onCreateLike = functions.firestore
.document('users/{userId}/likes/{likeId}')
.onCreate(async (change, context) => {
const like = change.data() as Like
const { eventId } = context
if (like.type === 'contract') {
await handleCreateLikeNotification(like, eventId)
await updateContractLikes(like)
}
})
export const onUpdateLike = functions.firestore
.document('users/{userId}/likes/{likeId}')
.onUpdate(async (change, context) => {
const like = change.after.data() as Like
const prevLike = change.before.data() as Like
const { eventId } = context
if (like.type === 'contract' && like.tipTxnId !== prevLike.tipTxnId) {
await handleCreateLikeNotification(like, eventId)
await updateContractLikes(like)
}
})
export const onDeleteLike = functions.firestore
.document('users/{userId}/likes/{likeId}')
.onDelete(async (change) => {
const like = change.data() as Like
if (like.type === 'contract') {
await removeContractLike(like)
}
})
const updateContractLikes = async (like: Like) => {
const contract = await getContract(like.id)
if (!contract) {
log('Could not find contract')
return
}
const likedByUserIds = uniq(
(contract.likedByUserIds ?? []).concat(like.userId)
)
await firestore
.collection('contracts')
.doc(like.id)
.update({ likedByUserIds, likedByUserCount: likedByUserIds.length })
}
const handleCreateLikeNotification = async (like: Like, eventId: string) => {
const contract = await getContract(like.id)
if (!contract) {
log('Could not find contract')
return
}
const contractCreator = await getUser(contract.creatorId)
if (!contractCreator) {
log('Could not find contract creator')
return
}
const liker = await getUser(like.userId)
if (!liker) {
log('Could not find liker')
return
}
let tipTxnData = undefined
if (like.tipTxnId) {
const tipTxn = await firestore.collection('txns').doc(like.tipTxnId).get()
if (!tipTxn.exists) {
log('Could not find tip txn')
return
}
tipTxnData = tipTxn.data() as TipTxn
}
await createLikeNotification(
liker,
contractCreator,
like,
eventId,
contract,
tipTxnData
)
}
const removeContractLike = async (like: Like) => {
const contract = await getContract(like.id)
if (!contract) {
log('Could not find contract')
return
}
const likedByUserIds = uniq(contract.likedByUserIds ?? [])
const newLikedByUserIds = likedByUserIds.filter(
(userId) => userId !== like.userId
)
await firestore.collection('contracts').doc(like.id).update({
likedByUserIds: newLikedByUserIds,
likedByUserCount: newLikedByUserIds.length,
})
}

View File

@ -5,10 +5,10 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract'
import { LimitBet } from 'common/bet'
import { LimitBet } from '../../common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
import { Group } from 'common/group'
import { REFERRAL_AMOUNT } from 'common/economy'
import { Group } from '../../common/group'
import { REFERRAL_AMOUNT } from '../../common/economy'
const firestore = admin.firestore()
export const onUpdateUser = functions.firestore

View File

@ -22,6 +22,7 @@ import { LimitBet } from '../../common/bet'
import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares'
import { log } from './utils'
import { addUserToContractFollowers } from './follow-market'
const bodySchema = z.object({
contractId: z.string(),
@ -167,6 +168,8 @@ export const placebet = newEndpoint({}, async (req, auth) => {
return { betId: betDoc.id, makers, newBet }
})
await addUserToContractFollowers(contractId, auth.uid)
log('Main transaction finished.')
if (result.newBet.amount !== 0) {

View File

@ -5,6 +5,7 @@ import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { floatingEqual } from '../../common/util/math'
export const redeemShares = async (userId: string, contractId: string) => {
return await firestore.runTransaction(async (trans) => {
@ -21,7 +22,7 @@ export const redeemShares = async (userId: string, contractId: string) => {
const betsSnap = await trans.get(betsColl.where('userId', '==', userId))
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const { shares, loanPayment, netAmount } = getRedeemableAmount(bets)
if (netAmount === 0) {
if (floatingEqual(netAmount, 0)) {
return { status: 'success' }
}
const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract)

View File

@ -9,13 +9,16 @@ const firestore = admin.firestore()
export const resetBettingStreaksForUsers = functions.pubsub
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
.timeZone('utc')
.timeZone('Etc/UTC')
.onRun(async () => {
await resetBettingStreaksInternal()
})
const resetBettingStreaksInternal = async () => {
const usersSnap = await firestore.collection('users').get()
const usersSnap = await firestore
.collection('users')
.where('currentBettingStreak', '>', 0)
.get()
const users = usersSnap.docs.map((doc) => doc.data() as User)
@ -28,7 +31,7 @@ const resetBettingStreakForUser = async (user: User) => {
const betStreakResetTime = Date.now() - DAY_MS
// if they made a bet within the last day, don't reset their streak
if (
(user.lastBetTime ?? 0 > betStreakResetTime) ||
(user?.lastBetTime ?? 0) > betStreakResetTime ||
!user.currentBettingStreak ||
user.currentBettingStreak === 0
)

View File

@ -0,0 +1,24 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { getAllPrivateUsers } from './utils'
export const resetWeeklyEmailsFlag = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
// every Monday at 12 am PT (UTC -07:00) ( 12 hours before the emails will be sent)
.pubsub.schedule('0 7 * * 1')
.timeZone('Etc/UTC')
.onRun(async () => {
const privateUsers = await getAllPrivateUsers()
// get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
return !user.unsubscribedFromWeeklyTrendingEmails
})
const firestore = admin.firestore()
await Promise.all(
privateUsersToSendEmailsTo.map(async (user) => {
return firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: false,
})
})
)
})

View File

@ -0,0 +1,75 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { getValues } from '../utils'
import { Contract } from 'common/lib/contract'
import { Comment } from 'common/lib/comment'
import { uniq } from 'lodash'
import { Bet } from 'common/lib/bet'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/lib/antes'
const firestore = admin.firestore()
async function backfillContractFollowers() {
console.log('Backfilling contract followers')
const contracts = await getValues<Contract>(
firestore.collection('contracts').where('isResolved', '==', false)
)
let count = 0
for (const contract of contracts) {
const comments = await getValues<Comment>(
firestore.collection('contracts').doc(contract.id).collection('comments')
)
const commenterIds = uniq(comments.map((comment) => comment.userId))
const betsSnap = await firestore
.collection(`contracts/${contract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
// filter bets for only users that have an amount invested still
const bettorIds = uniq(bets.map((bet) => bet.userId))
const liquidityProviders = await firestore
.collection(`contracts/${contract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
// exclude free market liquidity provider
).filter(
(id) =>
id !== HOUSE_LIQUIDITY_PROVIDER_ID ||
id !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID
)
const followerIds = uniq([
...commenterIds,
...bettorIds,
...liquidityProvidersIds,
contract.creatorId,
])
for (const followerId of followerIds) {
await firestore
.collection(`contracts/${contract.id}/follows`)
.doc(followerId)
.set({ id: followerId, createdTime: Date.now() })
}
// Perhaps handled by the trigger?
// const followerCount = followerIds.length
// await firestore
// .collection(`contracts`)
// .doc(contract.id)
// .update({ followerCount: followerCount })
count += 1
if (count % 100 === 0) {
console.log(`${count} contracts processed`)
}
}
}
if (require.main === module) {
backfillContractFollowers()
.then(() => process.exit())
.catch(console.log)
}

View File

@ -0,0 +1,39 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getValues, log, writeAsync } from '../utils'
import { Bet } from '../../../common/bet'
import { groupBy, mapValues, sortBy, uniq } from 'lodash'
initAdmin()
const firestore = admin.firestore()
const getBettorsByContractId = async () => {
const bets = await getValues<Bet>(firestore.collectionGroup('bets'))
log(`Loaded ${bets.length} bets.`)
const betsByContractId = groupBy(bets, 'contractId')
return mapValues(betsByContractId, (bets) =>
uniq(sortBy(bets, 'createdTime').map((bet) => bet.userId))
)
}
const updateUniqueBettors = async () => {
const bettorsByContractId = await getBettorsByContractId()
const updates = Object.entries(bettorsByContractId).map(
([contractId, userIds]) => {
const update = {
uniqueBettorIds: userIds,
uniqueBettorCount: userIds.length,
}
const docRef = firestore.collection('contracts').doc(contractId)
return { doc: docRef, fields: update }
}
)
log(`Updating ${updates.length} contracts.`)
await writeAsync(firestore, updates)
log(`Updated all contracts.`)
}
if (require.main === module) {
updateUniqueBettors()
}

View File

@ -1,108 +0,0 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getValues, isProd } from '../utils'
import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories'
import { Group, GroupLink } from 'common/group'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { filterDefined } from 'common/util/array'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes'
initAdmin()
const adminFirestore = admin.firestore()
const convertCategoriesToGroupsInternal = async (categories: string[]) => {
for (const category of categories) {
const markets = await getValues<Contract>(
adminFirestore
.collection('contracts')
.where('lowercaseTags', 'array-contains', category.toLowerCase())
)
const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX
const oldGroup = await getValues<Group>(
adminFirestore.collection('groups').where('slug', '==', slug)
)
if (oldGroup.length > 0) {
console.log(`Found old group for ${category}`)
await adminFirestore.collection('groups').doc(oldGroup[0].id).delete()
}
const allUsers = await getValues<User>(adminFirestore.collection('users'))
const groupUsers = filterDefined(
allUsers.map((user: User) => {
if (!user.followedCategories || user.followedCategories.length === 0)
return user.id
if (!user.followedCategories.includes(category.toLowerCase()))
return null
return user.id
})
)
const manifoldAccount = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const newGroupRef = await adminFirestore.collection('groups').doc()
const newGroup: Group = {
id: newGroupRef.id,
name: category,
slug,
creatorId: manifoldAccount,
createdTime: Date.now(),
anyoneCanJoin: true,
memberIds: [manifoldAccount],
about: 'Default group for all things related to ' + category,
mostRecentActivityTime: Date.now(),
contractIds: markets.map((market) => market.id),
chatDisabled: true,
}
await adminFirestore.collection('groups').doc(newGroupRef.id).set(newGroup)
// Update group with new memberIds to avoid notifying everyone
await adminFirestore
.collection('groups')
.doc(newGroupRef.id)
.update({
memberIds: uniq(groupUsers),
})
for (const market of markets) {
if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id))
continue // already in that group
const newGroupLinks = [
...(market.groupLinks ?? []),
{
groupId: newGroup.id,
createdTime: Date.now(),
slug: newGroup.slug,
name: newGroup.name,
} as GroupLink,
]
await adminFirestore
.collection('contracts')
.doc(market.id)
.update({
groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]),
groupLinks: newGroupLinks,
})
}
}
}
async function convertCategoriesToGroups() {
// const defaultCategories = Object.values(DEFAULT_CATEGORIES)
const moreCategories = ['world', 'culture']
await convertCategoriesToGroupsInternal(moreCategories)
}
if (require.main === module) {
convertCategoriesToGroups()
.then(() => process.exit())
.catch(console.log)
}

View File

@ -4,21 +4,23 @@ import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { isProd, log } from '../utils'
import { getSlug } from '../create-group'
import { Group } from '../../../common/group'
import { Group, GroupLink } from '../../../common/group'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
const getTaggedContractIds = async (tag: string) => {
const getTaggedContracts = async (tag: string) => {
const firestore = admin.firestore()
const results = await firestore
.collection('contracts')
.where('lowercaseTags', 'array-contains', tag.toLowerCase())
.get()
return results.docs.map((d) => d.id)
return results.docs.map((d) => d.data() as Contract)
}
const createGroup = async (
name: string,
about: string,
contractIds: string[]
contracts: Contract[]
) => {
const firestore = admin.firestore()
const creatorId = isProd()
@ -36,21 +38,60 @@ const createGroup = async (
about,
createdTime: now,
mostRecentActivityTime: now,
contractIds: contractIds,
anyoneCanJoin: true,
memberIds: [],
totalContracts: contracts.length,
totalMembers: 1,
}
return await groupRef.create(group)
await groupRef.create(group)
// create a GroupMemberDoc for the creator
const memberDoc = groupRef.collection('groupMembers').doc(creatorId)
await memberDoc.create({
userId: creatorId,
createdTime: now,
})
// create GroupContractDocs for each contractId
await Promise.all(
contracts
.map((c) => c.id)
.map((contractId) =>
groupRef.collection('groupContracts').doc(contractId).create({
contractId,
createdTime: now,
})
)
)
for (const market of contracts) {
if (market.groupLinks?.map((l) => l.groupId).includes(group.id)) continue // already in that group
const newGroupLinks = [
...(market.groupLinks ?? []),
{
groupId: group.id,
createdTime: Date.now(),
slug: group.slug,
name: group.name,
} as GroupLink,
]
await firestore
.collection('contracts')
.doc(market.id)
.update({
groupSlugs: uniq([...(market.groupSlugs ?? []), group.slug]),
groupLinks: newGroupLinks,
})
}
return { status: 'success', group: group }
}
const convertTagToGroup = async (tag: string, groupName: string) => {
log(`Looking up contract IDs with tag ${tag}...`)
const contractIds = await getTaggedContractIds(tag)
log(`${contractIds.length} contracts found.`)
if (contractIds.length > 0) {
const contracts = await getTaggedContracts(tag)
log(`${contracts.length} contracts found.`)
if (contracts.length > 0) {
log(`Creating group ${groupName}...`)
const about = `Contracts that used to be tagged ${tag}.`
const result = await createGroup(groupName, about, contractIds)
const result = await createGroup(groupName, about, contracts)
log(`Done. Group: `, result)
}
}

View File

@ -0,0 +1,69 @@
// Filling in the bet-based fields on comments.
import * as admin from 'firebase-admin'
import { zip } from 'lodash'
import { initAdmin } from './script-init'
import {
DocumentCorrespondence,
findDiffs,
describeDiff,
applyDiff,
} from './denormalize'
import { log } from '../utils'
import { Transaction } from 'firebase-admin/firestore'
initAdmin()
const firestore = admin.firestore()
async function getBetComments(transaction: Transaction) {
const allComments = await transaction.get(
firestore.collectionGroup('comments')
)
const betComments = allComments.docs.filter((d) => d.get('betId'))
log(`Found ${betComments.length} comments associated with bets.`)
return betComments
}
async function denormalize() {
let hasMore = true
while (hasMore) {
hasMore = await admin.firestore().runTransaction(async (trans) => {
const betComments = await getBetComments(trans)
const bets = await Promise.all(
betComments.map((doc) =>
trans.get(
firestore
.collection('contracts')
.doc(doc.get('contractId'))
.collection('bets')
.doc(doc.get('betId'))
)
)
)
log(`Found ${bets.length} bets associated with comments.`)
const mapping = zip(bets, betComments)
.map(([bet, comment]): DocumentCorrespondence => {
return [bet!, [comment!]] // eslint-disable-line
})
.filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs
const amountDiffs = findDiffs(mapping, 'amount', 'betAmount')
const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome')
log(`Found ${amountDiffs.length} comments with mismatched amounts.`)
log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`)
const diffs = amountDiffs.concat(outcomeDiffs)
diffs.slice(0, 500).forEach((d) => {
log(describeDiff(d))
applyDiff(trans, 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

@ -0,0 +1,122 @@
import * as admin from 'firebase-admin'
import { Group } from 'common/group'
import { initAdmin } from 'functions/src/scripts/script-init'
import { log } from '../utils'
const getGroups = async () => {
const firestore = admin.firestore()
const groups = await firestore.collection('groups').get()
return groups.docs.map((doc) => doc.data() as Group)
}
// const createContractIdForGroup = async (
// groupId: string,
// contractId: string
// ) => {
// const firestore = admin.firestore()
// const now = Date.now()
// const contractDoc = await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupContracts')
// .doc(contractId)
// .get()
// if (!contractDoc.exists)
// await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupContracts')
// .doc(contractId)
// .create({
// contractId,
// createdTime: now,
// })
// }
// const createMemberForGroup = async (groupId: string, userId: string) => {
// const firestore = admin.firestore()
// const now = Date.now()
// const memberDoc = await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupMembers')
// .doc(userId)
// .get()
// if (!memberDoc.exists)
// await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupMembers')
// .doc(userId)
// .create({
// userId,
// createdTime: now,
// })
// }
// async function convertGroupFieldsToGroupDocuments() {
// const groups = await getGroups()
// for (const group of groups) {
// log('updating group', group.slug)
// const groupRef = admin.firestore().collection('groups').doc(group.id)
// const totalMembers = (await groupRef.collection('groupMembers').get()).size
// const totalContracts = (await groupRef.collection('groupContracts').get())
// .size
// if (
// totalMembers === group.memberIds?.length &&
// totalContracts === group.contractIds?.length
// ) {
// log('group already converted', group.slug)
// continue
// }
// const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1
// const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1
// for (const contractId of group.contractIds?.slice(
// contractStart,
// group.contractIds?.length
// ) ?? []) {
// await createContractIdForGroup(group.id, contractId)
// }
// for (const userId of group.memberIds?.slice(
// membersStart,
// group.memberIds?.length
// ) ?? []) {
// await createMemberForGroup(group.id, userId)
// }
// }
// }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function updateTotalContractsAndMembers() {
const groups = await getGroups()
for (const group of groups) {
log('updating group total contracts and members', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalMembers = (await groupRef.collection('groupMembers').get()).size
const totalContracts = (await groupRef.collection('groupContracts').get())
.size
await groupRef.update({
totalMembers,
totalContracts,
})
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeUnusedMemberAndContractFields() {
const groups = await getGroups()
for (const group of groups) {
log('removing member and contract ids', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
await groupRef.update({
memberIds: admin.firestore.FieldValue.delete(),
contractIds: admin.firestore.FieldValue.delete(),
})
}
}
if (require.main === module) {
initAdmin()
// convertGroupFieldsToGroupDocuments()
// updateTotalContractsAndMembers()
removeUnusedMemberAndContractFields()
}

View File

@ -13,6 +13,7 @@ import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
import { redeemShares } from './redeem-shares'
import { removeUserFromContractFollowers } from './follow-market'
const bodySchema = z.object({
contractId: z.string(),
@ -123,9 +124,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
})
)
return { newBet, makers }
return { newBet, makers, maxShares, soldShares }
})
if (result.maxShares === result.soldShares) {
await removeUserFromContractFollowers(contractId, auth.uid)
}
const userIds = uniq(result.makers.map((maker) => maker.bet.userId))
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
log('Share redemption transaction finished.')

View File

@ -26,7 +26,7 @@ import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { getcustomtoken } from './get-custom-token'
import { createpost } from './create-post'
type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express()
@ -65,8 +65,8 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe)
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addEndpointRoute('/getcustomtoken', getcustomtoken)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost)
app.listen(PORT)
console.log(`Serving functions on port ${PORT}.`)

View File

@ -69,6 +69,10 @@ export const unsubscribe: EndpointDefinition = {
res.send(
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
)
else if (type === 'weekly-trending')
res.send(
`${name}, you have been unsubscribed from weekly trending emails on Manifold Markets.`
)
else res.send(`${name}, you have been unsubscribed.`)
},
}

View File

@ -12,8 +12,8 @@ const firestore = admin.firestore()
export const updateLoans = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
// Run every Monday.
.pubsub.schedule('0 0 * * 1')
// Run every day at midnight.
.pubsub.schedule('0 0 * * *')
.timeZone('America/Los_Angeles')
.onRun(updateLoansCore)
@ -79,9 +79,13 @@ async function updateLoansCore() {
const today = new Date().toDateString().replace(' ', '-')
const key = `loan-notifications-${today}`
await Promise.all(
userPayouts.map(({ user, payout }) =>
createLoanIncomeNotification(user, key, payout)
)
userPayouts
// Don't send a notification if the payout is < M$1,
// because a M$0 loan is confusing.
.filter(({ payout }) => payout >= 1)
.map(({ user, payout }) =>
createLoanIncomeNotification(user, key, payout)
)
)
log('Notifications sent!')

View File

@ -1,43 +1,29 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash'
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { calculatePayout } from '../../common/calculate'
import { DAY_MS } from '../../common/util/time'
import { last } from 'lodash'
import { getLoanUpdates } from '../../common/loans'
import {
calculateCreatorVolume,
calculateNewPortfolioMetrics,
calculateNewProfit,
calculateProbChanges,
computeVolume,
} from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate'
const firestore = admin.firestore()
const computeInvestmentValue = (
bets: Bet[],
contractsDict: { [k: string]: Contract }
) => {
return sumBy(bets, (bet) => {
const contract = contractsDict[bet.contractId]
if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0
export const updateMetrics = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)
const payout = calculatePayout(contract, bet, 'MKT')
const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0
return value
})
}
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime
)
return sum(
periodFilteredContracts.map((contract) => sum(Object.values(contract.pool)))
)
}
export const updateMetricsCore = async () => {
export async function updateMetricsCore() {
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
getValues<User>(firestore.collection('users')),
getValues<Contract>(firestore.collection('contracts')),
@ -55,16 +41,36 @@ export const updateMetricsCore = async () => {
const now = Date.now()
const betsByContract = groupBy(bets, (bet) => bet.contractId)
const contractUpdates = contracts.map((contract) => {
const contractBets = betsByContract[contract.id] ?? []
return {
doc: firestore.collection('contracts').doc(contract.id),
fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
},
}
})
const contractUpdates = contracts
.filter((contract) => contract.id)
.map((contract) => {
const contractBets = betsByContract[contract.id] ?? []
const descendingBets = sortBy(
contractBets,
(bet) => bet.createdTime
).reverse()
let cpmmFields: Partial<CPMM> = {}
if (contract.mechanism === 'cpmm-1') {
const prob = descendingBets[0]
? descendingBets[0].probAfter
: getProbability(contract)
cpmmFields = {
prob,
probChanges: calculateProbChanges(descendingBets),
}
}
return {
doc: firestore.collection('contracts').doc(contract.id),
fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
...cpmmFields,
},
}
})
await writeAsync(firestore, contractUpdates)
log(`Updated metrics for ${contracts.length} contracts.`)
@ -86,23 +92,20 @@ export const updateMetricsCore = async () => {
currentBets
)
const lastPortfolio = last(portfolioHistory)
const didProfitChange =
const didPortfolioChange =
lastPortfolio === undefined ||
lastPortfolio.balance !== newPortfolio.balance ||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
const newProfit = calculateNewProfit(
portfolioHistory,
newPortfolio,
didProfitChange
)
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
return {
user,
newCreatorVolume,
newPortfolio,
newProfit,
didProfitChange,
didPortfolioChange,
}
})
@ -118,16 +121,20 @@ export const updateMetricsCore = async () => {
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map(
({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => {
({
user,
newCreatorVolume,
newPortfolio,
newProfit,
didPortfolioChange,
}) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return {
fieldUpdates: {
doc: firestore.collection('users').doc(user.id),
fields: {
creatorVolumeCached: newCreatorVolume,
...(didProfitChange && {
profitCached: newProfit,
}),
profitCached: newProfit,
nextLoanCached,
},
},
@ -138,11 +145,7 @@ export const updateMetricsCore = async () => {
.doc(user.id)
.collection('portfolioHistory')
.doc(),
fields: {
...(didProfitChange && {
...newPortfolio,
}),
},
fields: didPortfolioChange ? newPortfolio : {},
},
}
}
@ -160,108 +163,3 @@ export const updateMetricsCore = async () => {
)
log(`Updated metrics for ${users.length} users.`)
}
const computeVolume = (contractBets: Bet[], since: number) => {
return sumBy(contractBets, (b) =>
b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0
)
}
const calculateProfitForPeriod = (
startTime: number,
portfolioHistory: PortfolioMetrics[],
currentProfit: number
) => {
const startingPortfolio = [...portfolioHistory]
.reverse() // so we search in descending order (most recent first), for efficiency
.find((p) => p.timestamp < startTime)
if (startingPortfolio === undefined) {
return 0
}
const startingProfit = calculateTotalProfit(startingPortfolio)
return currentProfit - startingProfit
}
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
}
const calculateCreatorVolume = (userContracts: Contract[]) => {
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
const monthlyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 30 * DAY_MS
)
const weeklyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 7 * DAY_MS
)
const dailyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 1 * DAY_MS
)
return {
daily: dailyCreatorVolume,
weekly: weeklyCreatorVolume,
monthly: monthlyCreatorVolume,
allTime: allTimeCreatorVolume,
}
}
const calculateNewPortfolioMetrics = (
user: User,
contractsById: { [k: string]: Contract },
currentBets: Bet[]
) => {
const investmentValue = computeInvestmentValue(currentBets, contractsById)
const newPortfolio = {
investmentValue: investmentValue,
balance: user.balance,
totalDeposits: user.totalDeposits,
timestamp: Date.now(),
userId: user.id,
}
return newPortfolio
}
const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[],
newPortfolio: PortfolioMetrics,
didProfitChange: boolean
) => {
if (!didProfitChange) {
return {} // early return for performance
}
const allTimeProfit = calculateTotalProfit(newPortfolio)
const newProfit = {
daily: calculateProfitForPeriod(
Date.now() - 1 * DAY_MS,
portfolioHistory,
allTimeProfit
),
weekly: calculateProfitForPeriod(
Date.now() - 7 * DAY_MS,
portfolioHistory,
allTimeProfit
),
monthly: calculateProfitForPeriod(
Date.now() - 30 * DAY_MS,
portfolioHistory,
allTimeProfit
),
allTime: allTimeProfit,
}
return newProfit
}
export const updateMetrics = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)

View File

@ -311,6 +311,6 @@ export const updateStatsCore = async () => {
}
export const updateStats = functions
.runWith({ memory: '1GB', timeoutSeconds: 540 })
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 60 minutes')
.onRun(updateStatsCore)

View File

@ -4,6 +4,7 @@ import { chunk } from 'lodash'
import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user'
import { Group } from '../../common/group'
import { Post } from 'common/post'
export const log = (...args: unknown[]) => {
console.log(`[${new Date().toISOString()}]`, ...args)
@ -80,6 +81,10 @@ export const getGroup = (groupId: string) => {
return getDoc<Group>('groups', groupId)
}
export const getPost = (postId: string) => {
return getDoc<Post>('posts', postId)
}
export const getUser = (userId: string) => {
return getDoc<User>('users', userId)
}

View File

@ -2,16 +2,24 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract'
import { getAllPrivateUsers, getUser, getValues, log } from './utils'
import {
getAllPrivateUsers,
getPrivateUser,
getUser,
getValues,
isProd,
log,
} from './utils'
import { sendInterestingMarketsEmail } from './emails'
import { createRNG, shuffle } from '../../common/util/random'
import { DAY_MS } from '../../common/util/time'
import { filterDefined } from '../../common/util/array'
export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
// every Monday at 12pm PT (UTC -07:00)
.pubsub.schedule('0 19 * * 1')
.timeZone('utc')
// every minute on Monday for an hour at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19 * * 1')
.timeZone('Etc/UTC')
.onRun(async () => {
await sendTrendingMarketsEmailsToAllUsers()
})
@ -34,20 +42,38 @@ export async function getTrendingContracts() {
async function sendTrendingMarketsEmailsToAllUsers() {
const numContractsToSend = 6
const privateUsers = await getAllPrivateUsers()
const privateUsers = isProd()
? await getAllPrivateUsers()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
// get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
return !user.unsubscribedFromWeeklyTrendingEmails
return (
!user.unsubscribedFromWeeklyTrendingEmails &&
!user.weeklyTrendingEmailSent
)
})
log(
'Sending weekly trending emails to',
privateUsersToSendEmailsTo.length,
'users'
)
const trendingContracts = (await getTrendingContracts())
.filter(
(contract) =>
!(
contract.question.toLowerCase().includes('trump') &&
contract.question.toLowerCase().includes('president')
) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS
) &&
(contract?.closeTime ?? 0) > Date.now() + DAY_MS &&
!contract.groupSlugs?.includes('manifold-features') &&
!contract.groupSlugs?.includes('manifold-6748e065087e')
)
.slice(0, 20)
log(
`Found ${trendingContracts.length} trending contracts:\n`,
trendingContracts.map((c) => c.question).join('\n ')
)
for (const privateUser of privateUsersToSendEmailsTo) {
if (!privateUser.email) {
log(`No email for ${privateUser.username}`)
@ -70,12 +96,17 @@ async function sendTrendingMarketsEmailsToAllUsers() {
if (!user) continue
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true,
})
}
}
const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString()
const rng = createRNG(seed)
function chooseRandomSubset(contracts: Contract[], count: number) {
const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString()
shuffle(contracts, createRNG(seed))
shuffle(contracts, rng)
return contracts.slice(0, count)
}

View File

@ -1,85 +1,5 @@
import { sanitizeHtml } from './sanitizer'
import { ParsedRequest } from './types'
function getCss(theme: string, fontSize: string) {
let background = 'white'
let foreground = 'black'
let radial = 'lightgray'
if (theme === 'dark') {
background = 'black'
foreground = 'white'
radial = 'dimgray'
}
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
return `
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
body {
background: ${background};
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
background-size: 100px 100px;
height: 100vh;
font-family: "Readex Pro", sans-serif;
}
code {
color: #D400FF;
font-family: 'Vera';
white-space: pre-wrap;
letter-spacing: -5px;
}
code:before, code:after {
content: '\`';
}
.logo-wrapper {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
}
.logo {
margin: 0 75px;
}
.plus {
color: #BBB;
font-family: Times New Roman, Verdana;
font-size: 100px;
}
.spacer {
margin: 150px;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.heading {
font-family: 'Major Mono Display', monospace;
font-size: ${sanitizeHtml(fontSize)};
font-style: normal;
color: ${foreground};
line-height: 1.8;
}
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
`
}
import { getTemplateCss } from './template-css'
export function getChallengeHtml(parsedReq: ParsedRequest) {
const {
@ -112,7 +32,7 @@ export function getChallengeHtml(parsedReq: ParsedRequest) {
<script src="https://cdn.tailwindcss.com"></script>
</head>
<style>
${getCss(theme, fontSize)}
${getTemplateCss(theme, fontSize)}
</style>
<body>
<div class="px-24">

View File

@ -21,6 +21,7 @@ export function parseRequest(req: IncomingMessage) {
creatorName,
creatorUsername,
creatorAvatarUrl,
resolution,
// Challenge attributes:
challengerAmount,
@ -71,6 +72,7 @@ export function parseRequest(req: IncomingMessage) {
question:
getString(question) || 'Will you create a prediction market on Manifold?',
resolution: getString(resolution),
probability: getString(probability),
numericValue: getString(numericValue) || '',
metadata: getString(metadata) || 'Jan 1 &nbsp;•&nbsp; M$ 123 pool',

View File

@ -0,0 +1,81 @@
import { sanitizeHtml } from './sanitizer'
export function getTemplateCss(theme: string, fontSize: string) {
let background = 'white'
let foreground = 'black'
let radial = 'lightgray'
if (theme === 'dark') {
background = 'black'
foreground = 'white'
radial = 'dimgray'
}
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
return `
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
body {
background: ${background};
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
background-size: 100px 100px;
height: 100vh;
font-family: "Readex Pro", sans-serif;
}
code {
color: #D400FF;
font-family: 'Vera';
white-space: pre-wrap;
letter-spacing: -5px;
}
code:before, code:after {
content: '\`';
}
.logo-wrapper {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
}
.logo {
margin: 0 75px;
}
.plus {
color: #BBB;
font-family: Times New Roman, Verdana;
font-size: 100px;
}
.spacer {
margin: 150px;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.heading {
font-family: 'Major Mono Display', monospace;
font-size: ${sanitizeHtml(fontSize)};
font-style: normal;
color: ${foreground};
line-height: 1.8;
}
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
`
}

View File

@ -1,85 +1,5 @@
import { sanitizeHtml } from './sanitizer'
import { ParsedRequest } from './types'
function getCss(theme: string, fontSize: string) {
let background = 'white'
let foreground = 'black'
let radial = 'lightgray'
if (theme === 'dark') {
background = 'black'
foreground = 'white'
radial = 'dimgray'
}
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
return `
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
body {
background: ${background};
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
background-size: 100px 100px;
height: 100vh;
font-family: "Readex Pro", sans-serif;
}
code {
color: #D400FF;
font-family: 'Vera';
white-space: pre-wrap;
letter-spacing: -5px;
}
code:before, code:after {
content: '\`';
}
.logo-wrapper {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
}
.logo {
margin: 0 75px;
}
.plus {
color: #BBB;
font-family: Times New Roman, Verdana;
font-size: 100px;
}
.spacer {
margin: 150px;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.heading {
font-family: 'Major Mono Display', monospace;
font-size: ${sanitizeHtml(fontSize)};
font-style: normal;
color: ${foreground};
line-height: 1.8;
}
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
`
}
import { getTemplateCss } from './template-css'
export function getHtml(parsedReq: ParsedRequest) {
const {
@ -92,6 +12,7 @@ export function getHtml(parsedReq: ParsedRequest) {
creatorUsername,
creatorAvatarUrl,
numericValue,
resolution,
} = parsedReq
const MAX_QUESTION_CHARS = 100
const truncatedQuestion =
@ -99,6 +20,49 @@ export function getHtml(parsedReq: ParsedRequest) {
? question.slice(0, MAX_QUESTION_CHARS) + '...'
: question
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
let resolutionColor = 'text-primary'
let resolutionString = 'YES'
switch (resolution) {
case 'YES':
break
case 'NO':
resolutionColor = 'text-red-500'
resolutionString = 'NO'
break
case 'CANCEL':
resolutionColor = 'text-yellow-500'
resolutionString = 'N/A'
break
case 'MKT':
resolutionColor = 'text-blue-500'
resolutionString = numericValue ? numericValue : probability
break
}
const resolutionDiv = `
<span class='text-center ${resolutionColor}'>
<div class="text-8xl">
${resolutionString}
</div>
<div class="text-4xl">${
resolution === 'CANCEL' ? '' : 'resolved'
}</div>
</span>`
const probabilityDiv = `
<span class='text-primary text-center'>
<div class="text-8xl">${probability}</div>
<div class="text-4xl">chance</div>
</span>`
const numericValueDiv = `
<span class='text-blue-500 text-center'>
<div class="text-8xl ">${numericValue}</div>
<div class="text-4xl">expected</div>
</span>
`
return `<!DOCTYPE html>
<html>
<head>
@ -108,7 +72,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<script src="https://cdn.tailwindcss.com"></script>
</head>
<style>
${getCss(theme, fontSize)}
${getTemplateCss(theme, fontSize)}
</style>
<body>
<div class="px-24">
@ -148,21 +112,22 @@ export function getHtml(parsedReq: ParsedRequest) {
<div class="text-indigo-700 text-6xl leading-tight">
${truncatedQuestion}
</div>
<div class="flex flex-col text-primary">
<div class="text-8xl">${probability}</div>
<div class="text-4xl">${probability !== '' ? 'chance' : ''}</div>
<span class='text-blue-500 text-center'>
<div class="text-8xl ">${
numericValue !== '' && probability === '' ? numericValue : ''
}</div>
<div class="text-4xl">${numericValue !== '' ? 'expected' : ''}</div>
</span>
<div class="flex flex-col">
${
resolution
? resolutionDiv
: numericValue
? numericValueDiv
: probability
? probabilityDiv
: ''
}
</div>
</div>
<!-- Metadata -->
<div class="absolute bottom-16">
<div class="text-gray-500 text-3xl">
<div class="text-gray-500 text-3xl max-w-[80vw]">
${metadata}
</div>
</div>

View File

@ -19,6 +19,7 @@ export interface ParsedRequest {
creatorName: string
creatorUsername: string
creatorAvatarUrl: string
resolution: string
// Challenge attributes:
challengerAmount: string
challengerOutcome: string

View File

@ -8,20 +8,22 @@
"web"
],
"scripts": {
"verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)"
"verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)",
"lint": "eslint common --fix ; eslint web --fix ; eslint functions --fix"
},
"dependencies": {},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "5.25.0",
"@typescript-eslint/parser": "5.25.0",
"@types/node": "16.11.11",
"@typescript-eslint/eslint-plugin": "5.36.0",
"@typescript-eslint/parser": "5.36.0",
"concurrently": "6.5.1",
"eslint": "8.15.0",
"eslint": "8.23.0",
"eslint-plugin-lodash": "^7.4.0",
"prettier": "2.5.0",
"typescript": "4.6.4",
"eslint-plugin-unused-imports": "^2.0.0",
"nodemon": "2.0.19",
"prettier": "2.7.1",
"ts-node": "10.9.1",
"nodemon": "2.0.19"
"typescript": "4.8.2"
},
"resolutions": {
"@types/react": "17.0.43"

View File

@ -1,6 +1,6 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['lodash'],
plugins: ['lodash', 'unused-imports'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
@ -22,6 +22,7 @@ module.exports = {
'@next/next/no-typos': 'off',
'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'],
'unused-imports/no-unused-imports': 'error',
},
env: {
browser: true,

View File

@ -9,6 +9,8 @@ import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { CheckIcon, XIcon } from '@heroicons/react/outline'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { Col } from 'web/components/layout/col'
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
export function NotificationSettings() {
const user = useUser()
@ -17,6 +19,7 @@ export function NotificationSettings() {
const [emailNotificationSettings, setEmailNotificationSettings] =
useState<notification_subscribe_types>('all')
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
const [showModal, setShowModal] = useState(false)
useEffect(() => {
if (user) listenForPrivateUser(user.id, setPrivateUser)
@ -121,12 +124,20 @@ export function NotificationSettings() {
}
function NotificationSettingLine(props: {
label: string
label: string | React.ReactNode
highlight: boolean
onClick?: () => void
}) {
const { label, highlight } = props
const { label, highlight, onClick } = props
return (
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
<Row
className={clsx(
'my-1 gap-1 text-gray-300',
highlight && '!text-black',
onClick ? 'cursor-pointer' : ''
)}
onClick={onClick}
>
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
{label}
</Row>
@ -148,31 +159,45 @@ export function NotificationSettings() {
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
<div className={''}>
You will receive notifications for:
<NotificationSettingLine
label={"Resolution of questions you've interacted with"}
highlight={notificationSettings !== 'none'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={'Activity on your own questions, comments, & answers'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Activity on questions you're betting on"}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Income & referral bonuses you've received"}
/>
<NotificationSettingLine
label={"Activity on questions you've ever bet or commented on"}
highlight={notificationSettings === 'all'}
/>
</div>
</div>
<Col className={''}>
<Row className={'my-1'}>
You will receive notifications for these general events:
</Row>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Income & referral bonuses you've received"}
/>
<Row className={'my-1'}>
You will receive new comment, answer, & resolution notifications on
questions:
</Row>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={
<span>
That <span className={'font-bold'}>you watch </span>- you
auto-watch questions if:
</span>
}
onClick={() => setShowModal(true)}
/>
<Col
className={clsx(
'mb-2 ml-8',
'gap-1 text-gray-300',
notificationSettings !== 'none' && '!text-black'
)}
>
<Row> You create it</Row>
<Row> You bet, comment on, or answer it</Row>
<Row> You add liquidity to it</Row>
<Row>
If you select 'Less' and you've commented on or answered a
question, you'll only receive notification on direct replies to
your comments or answers
</Row>
</Col>
</Col>
</div>
<div className={'mt-4'}>Email Notifications</div>
<ChoicesToggleGroup
@ -205,6 +230,7 @@ export function NotificationSettings() {
/>
</div>
</div>
<FollowMarketModal setOpen={setShowModal} open={showModal} />
</div>
)
}

View File

@ -1,38 +0,0 @@
import clsx from 'clsx'
import { useState, ReactNode } from 'react'
export function AdvancedPanel(props: { children: ReactNode }) {
const { children } = props
const [collapsed, setCollapsed] = useState(true)
return (
<div
tabIndex={0}
className={clsx(
'collapse collapse-arrow relative',
collapsed ? 'collapse-close' : 'collapse-open'
)}
>
<div
onClick={() => setCollapsed((collapsed) => !collapsed)}
className="cursor-pointer"
>
<div className="mt-4 mr-6 text-right text-sm text-gray-500">
Advanced
</div>
<div
className="collapse-title absolute h-0 min-h-0 w-0 p-0"
style={{
top: -2,
right: -15,
color: '#6a7280' /* gray-500 */,
}}
/>
</div>
<div className="collapse-content m-0 !bg-transparent !p-0">
{children}
</div>
</div>
)
}

View File

@ -5,6 +5,7 @@ import { formatMoney } from 'common/util/format'
import { Col } from './layout/col'
import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants'
import { useWindowSize } from 'web/hooks/use-window-size'
export function AmountInput(props: {
amount: number | undefined
@ -33,7 +34,8 @@ export function AmountInput(props: {
const isInvalid = !str || isNaN(amount)
onChange(isInvalid ? undefined : amount)
}
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
return (
<Col className={className}>
<label className="input-group mb-4">
@ -50,6 +52,7 @@ export function AmountInput(props: {
inputMode="numeric"
placeholder="0"
maxLength={6}
autoFocus={!isMobile}
value={amount ?? ''}
disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)}
@ -81,6 +84,7 @@ export function BuyAmountInput(props: {
setError: (error: string | undefined) => void
minimumAmount?: number
disabled?: boolean
showSliderOnMobile?: boolean
className?: string
inputClassName?: string
// Needed to focus the amount input
@ -91,6 +95,7 @@ export function BuyAmountInput(props: {
onChange,
error,
setError,
showSliderOnMobile: showSlider,
disabled,
className,
inputClassName,
@ -118,15 +123,28 @@ export function BuyAmountInput(props: {
}
return (
<AmountInput
amount={amount}
onChange={onAmountChange}
label={ENV_CONFIG.moneyMoniker}
error={error}
disabled={disabled}
className={className}
inputClassName={inputClassName}
inputRef={inputRef}
/>
<>
<AmountInput
amount={amount}
onChange={onAmountChange}
label={ENV_CONFIG.moneyMoniker}
error={error}
disabled={disabled}
className={className}
inputClassName={inputClassName}
inputRef={inputRef}
/>
{showSlider && (
<input
type="range"
min="0"
max="200"
value={amount ?? 0}
onChange={(e) => onAmountChange(parseInt(e.target.value))}
className="range range-lg z-40 mb-2 xl:hidden"
step="5"
/>
)}
</>
)
}

View File

@ -1,4 +1,5 @@
import { Point, ResponsiveLine } from '@nivo/line'
import clsx from 'clsx'
import dayjs from 'dayjs'
import { zip } from 'lodash'
import { useWindowSize } from 'web/hooks/use-window-size'
@ -26,8 +27,10 @@ export function DailyCountChart(props: {
return (
<div
className="w-full overflow-hidden"
style={{ height: !small && (!width || width >= 800) ? 400 : 250 }}
className={clsx(
'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
>
<ResponsiveLine
data={data}
@ -78,8 +81,10 @@ export function DailyPercentChart(props: {
return (
<div
className="w-full overflow-hidden"
style={{ height: !small && (!width || width >= 800) ? 400 : 250 }}
className={clsx(
'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
>
<ResponsiveLine
data={data}

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'
import { useEffect, useRef, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { XIcon } from '@heroicons/react/solid'
import { Answer } from 'common/answer'
@ -24,7 +24,7 @@ import {
} from 'common/calculate-dpm'
import { Bet } from 'common/bet'
import { track } from 'web/lib/service/analytics'
import { SignUpPrompt } from '../sign-up-prompt'
import { BetSignUpPrompt } from '../sign-up-prompt'
import { isIOS } from 'web/lib/util/device'
import { AlertBox } from '../alert-box'
@ -132,7 +132,11 @@ export function AnswerBetPanel(props: {
</button>
)}
</Row>
<div className="my-3 text-left text-sm text-gray-500">Amount </div>
<Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount
<span>Balance: {formatMoney(user?.balance ?? 0)}</span>
</Row>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
@ -141,6 +145,7 @@ export function AnswerBetPanel(props: {
setError={setError}
disabled={isSubmitting}
inputRef={inputRef}
showSliderOnMobile
/>
{(betAmount ?? 0) > 10 &&
@ -204,7 +209,7 @@ export function AnswerBetPanel(props: {
{isSubmitting ? 'Submitting...' : 'Submit trade'}
</button>
) : (
<SignUpPrompt />
<BetSignUpPrompt />
)}
</Col>
)

View File

@ -18,19 +18,20 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
}) {
const { contract, bets, height } = props
const { createdTime, resolutionTime, closeTime, answers } = contract
const now = Date.now()
const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome(
bets,
contract
)
const isClosed = !!closeTime && Date.now() > closeTime
const isClosed = !!closeTime && now > closeTime
const latestTime = dayjs(
resolutionTime && isClosed
? Math.min(resolutionTime, closeTime)
: isClosed
? closeTime
: resolutionTime ?? Date.now()
: resolutionTime ?? now
)
const { width } = useWindowSize()
@ -71,13 +72,14 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
const yTickValues = [0, 25, 50, 75, 100]
const numXTickValues = isLargeWidth ? 5 : 2
const hoursAgo = latestTime.subtract(5, 'hours')
const startDate = dayjs(contract.createdTime).isBefore(hoursAgo)
? new Date(contract.createdTime)
: hoursAgo.toDate()
const startDate = dayjs(contract.createdTime)
const endDate = startDate.add(1, 'hour').isAfter(latestTime)
? latestTime.add(1, 'hours')
: latestTime
const includeMinute = endDate.diff(startDate, 'hours') < 2
const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime)
const multiYear = !startDate.isSame(latestTime, 'year')
const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime)
return (
<div
@ -95,17 +97,25 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
}}
xScale={{
type: 'time',
min: startDate,
max: latestTime.toDate(),
min: startDate.toDate(),
max: endDate.toDate(),
}}
xFormat={(d) =>
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
}
axisBottom={{
tickValues: numXTickValues,
format: (time) => formatTime(+time, multiYear, lessThanAWeek, false),
format: (time) =>
formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
}}
colors={{ scheme: 'pastel1' }}
colors={[
'#fca5a5', // red-300
'#a5b4fc', // indigo-300
'#86efac', // green-300
'#fef08a', // yellow-200
'#fdba74', // orange-300
'#c084fc', // purple-400
]}
pointSize={0}
curve="stepAfter"
enableSlices="x"
@ -149,19 +159,20 @@ function formatPercent(y: DatumValue) {
}
function formatTime(
now: number,
time: number,
includeYear: boolean,
includeHour: boolean,
includeMinute: boolean
) {
const d = dayjs(time)
if (d.add(1, 'minute').isAfter(Date.now())) return 'Now'
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
return 'Now'
let format: string
if (d.isSame(Date.now(), 'day')) {
if (d.isSame(now, 'day')) {
format = '[Today]'
} else if (d.add(1, 'day').isSame(Date.now(), 'day')) {
} else if (d.add(1, 'day').isSame(now, 'day')) {
format = '[Yesterday]'
} else {
format = 'MMM D'

View File

@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item'
import { CreateAnswerPanel } from './create-answer-panel'
import { AnswerResolvePanel } from './answer-resolve-panel'
import { Spacer } from '../layout/spacer'
import { ActivityItem } from '../feed/activity-items'
import { User } from 'common/user'
import { getOutcomeProbability } from 'common/calculate'
import { Answer } from 'common/answer'
@ -21,9 +20,9 @@ import { Modal } from 'web/components/layout/modal'
import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page'
import { Linkify } from 'web/components/linkify'
import { BuyButton } from 'web/components/yes-no-selector'
import { UserLink } from 'web/components/user-link'
export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract
@ -176,7 +175,6 @@ function getAnswerItems(
type: 'answer' as const,
contract,
answer,
items: [] as ActivityItem[],
user,
}
})
@ -186,7 +184,6 @@ function getAnswerItems(
function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract
answer: Answer
items: ActivityItem[]
type: string
}) {
const { answer, contract } = props

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'
import { useState } from 'react'
import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity'
@ -25,6 +25,7 @@ import { Bet } from 'common/bet'
import { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics'
import { lowerCase } from 'lodash'
import { Button } from '../button'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props
@ -115,9 +116,11 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
if (user?.isBannedFromPosting) return <></>
return (
<Col className="gap-4 rounded">
<Col className="flex-1 gap-2">
<Col className="flex-1 gap-2 px-4 xl:px-0">
<div className="mb-1">Add your answer</div>
<Textarea
value={text}
@ -146,7 +149,12 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
{text && (
<>
<Col className="mt-1 gap-2">
<div className="text-sm text-gray-500">Bet amount</div>
<Row className="my-3 justify-between text-left text-sm text-gray-500">
Bet Amount
<span className={'sm:hidden'}>
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>{' '}
<BuyAmountInput
amount={betAmount}
onChange={setBetAmount}
@ -154,6 +162,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
setError={setAmountError}
minimumAmount={1}
disabled={isSubmitting}
showSliderOnMobile
/>
</Col>
<Col className="gap-3">
@ -197,16 +206,18 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
disabled={!canSubmit}
onClick={withTracking(submitAnswer, 'submit answer')}
>
Submit answer & buy
Submit
</button>
) : (
text && (
<button
className="btn self-end whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
<Button
color="green"
size="lg"
className="self-end whitespace-nowrap "
onClick={withTracking(firebaseLogin, 'answer panel sign in')}
>
Sign in
</button>
Add my answer
</Button>
)
)}
</Col>

View File

@ -0,0 +1,142 @@
import clsx from 'clsx'
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'
import { MenuIcon } from '@heroicons/react/solid'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle'
import { useMemberGroups } from 'web/hooks/use-group'
import { filterDefined } from 'common/util/array'
import { keyBy } from 'lodash'
import { User } from 'common/user'
import { Group } from 'common/group'
export function ArrangeHome(props: {
user: User | null
homeSections: { visible: string[]; hidden: string[] }
setHomeSections: (homeSections: {
visible: string[]
hidden: string[]
}) => void
}) {
const { user, homeSections, setHomeSections } = props
const groups = useMemberGroups(user?.id) ?? []
const { itemsById, visibleItems, hiddenItems } = getHomeItems(
groups,
homeSections
)
return (
<DragDropContext
onDragEnd={(e) => {
console.log('drag end', e)
const { destination, source, draggableId } = e
if (!destination) return
const item = itemsById[draggableId]
const newHomeSections = {
visible: visibleItems.map((item) => item.id),
hidden: hiddenItems.map((item) => item.id),
}
const sourceSection = source.droppableId as 'visible' | 'hidden'
newHomeSections[sourceSection].splice(source.index, 1)
const destSection = destination.droppableId as 'visible' | 'hidden'
newHomeSections[destSection].splice(destination.index, 0, item.id)
setHomeSections(newHomeSections)
}}
>
<Row className="relative max-w-lg gap-4">
<DraggableList items={visibleItems} title="Visible" />
<DraggableList items={hiddenItems} title="Hidden" />
</Row>
</DragDropContext>
)
}
function DraggableList(props: {
title: string
items: { id: string; label: string }[]
}) {
const { title, items } = props
return (
<Droppable droppableId={title.toLowerCase()}>
{(provided, snapshot) => (
<Col
{...provided.droppableProps}
ref={provided.innerRef}
className={clsx(
'width-[220px] flex-1 items-start rounded bg-gray-50 p-2',
snapshot.isDraggingOver && 'bg-gray-100'
)}
>
<Subtitle text={title} className="mx-2 !my-2" />
{items.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={provided.draggableProps.style}
className={clsx(
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2',
snapshot.isDragging && 'z-[9000] bg-gray-300'
)}
>
<MenuIcon
className="h-5 w-5 flex-shrink-0 text-gray-500"
aria-hidden="true"
/>{' '}
{item.label}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</Col>
)}
</Droppable>
)
}
export const getHomeItems = (
groups: Group[],
homeSections: { visible: string[]; hidden: string[] }
) => {
const items = [
{ label: 'Trending', id: 'score' },
{ label: 'Newest', id: 'newest' },
{ label: 'Close date', id: 'close-date' },
{ label: 'Your bets', id: 'your-bets' },
...groups.map((g) => ({
label: g.name,
id: g.id,
})),
]
const itemsById = keyBy(items, 'id')
const { visible, hidden } = homeSections
const [visibleItems, hiddenItems] = [
filterDefined(visible.map((id) => itemsById[id])),
filterDefined(hidden.map((id) => itemsById[id])),
]
// Add unmentioned items to the visible list.
visibleItems.push(
...items.filter(
(item) => !visibleItems.includes(item) && !hiddenItems.includes(item)
)
)
return {
visibleItems,
hiddenItems,
itemsById,
}
}

View File

@ -8,18 +8,30 @@ import {
getUserAndPrivateUser,
setCachedReferralInfoForUser,
} from 'web/lib/firebase/users'
import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth'
import { createUser } from 'web/lib/firebase/api'
import { randomString } from 'common/util/random'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
import { AUTH_COOKIE_NAME } from 'common/envs/constants'
import { setCookie } from 'web/lib/util/cookie'
// Either we haven't looked up the logged in user yet (undefined), or we know
// the user is not logged in (null), or we know the user is logged in.
type AuthUser = undefined | null | UserAndPrivateUser
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
// Proxy localStorage in case it's not available (eg in incognito iframe)
const localStorage =
typeof window !== 'undefined'
? window.localStorage
: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
}
const ensureDeviceToken = () => {
let deviceToken = localStorage.getItem('device-token')
if (!deviceToken) {
@ -29,6 +41,16 @@ const ensureDeviceToken = () => {
return deviceToken
}
export const setUserCookie = (cookie: string | undefined) => {
const data = setCookie(AUTH_COOKIE_NAME, cookie ?? '', [
['path', '/'],
['max-age', (cookie === undefined ? 0 : TEN_YEARS_SECS).toString()],
['samesite', 'lax'],
['secure'],
])
document.cookie = data
}
export const AuthContext = createContext<AuthUser>(undefined)
export function AuthProvider(props: {
@ -46,55 +68,67 @@ export function AuthProvider(props: {
}, [setAuthUser, serverUser])
useEffect(() => {
return onIdTokenChanged(auth, async (fbUser) => {
if (fbUser) {
setTokenCookies({
id: await fbUser.getIdToken(),
refresh: fbUser.refreshToken,
})
let current = await getUserAndPrivateUser(fbUser.uid)
if (!current.user || !current.privateUser) {
const deviceToken = ensureDeviceToken()
current = (await createUser({ deviceToken })) as UserAndPrivateUser
if (authUser != null) {
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
} else {
localStorage.removeItem(CACHED_USER_KEY)
}
}, [authUser])
useEffect(() => {
return onIdTokenChanged(
auth,
async (fbUser) => {
if (fbUser) {
setUserCookie(JSON.stringify(fbUser.toJSON()))
let current = await getUserAndPrivateUser(fbUser.uid)
if (!current.user || !current.privateUser) {
const deviceToken = ensureDeviceToken()
current = (await createUser({ deviceToken })) as UserAndPrivateUser
setCachedReferralInfoForUser(current.user)
}
setAuthUser(current)
} else {
// User logged out; reset to null
setUserCookie(undefined)
setAuthUser(null)
}
setAuthUser(current)
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current))
setCachedReferralInfoForUser(current.user)
} else {
// User logged out; reset to null
deleteTokenCookies()
setAuthUser(null)
localStorage.removeItem(CACHED_USER_KEY)
},
(e) => {
console.error(e)
}
})
)
}, [setAuthUser])
const uid = authUser?.user.id
const username = authUser?.user.username
useEffect(() => {
if (uid && username) {
if (uid) {
identifyUser(uid)
setUserProperty('username', username)
const userListener = listenForUser(uid, (user) =>
setAuthUser((authUser) => {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
return { ...authUser!, user: user! }
})
)
const userListener = listenForUser(uid, (user) => {
setAuthUser((currAuthUser) =>
currAuthUser && user ? { ...currAuthUser, user } : null
)
})
const privateUserListener = listenForPrivateUser(uid, (privateUser) => {
setAuthUser((authUser) => {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
return { ...authUser!, privateUser: privateUser! }
})
setAuthUser((currAuthUser) =>
currAuthUser && privateUser ? { ...currAuthUser, privateUser } : null
)
})
return () => {
userListener()
privateUserListener()
}
}
}, [uid, username, setAuthUser])
}, [uid, setAuthUser])
const username = authUser?.user.username
useEffect(() => {
if (username != null) {
setUserProperty('username', username)
}
}, [username])
return (
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>

View File

@ -8,6 +8,8 @@ import { useUser } from 'web/hooks/use-user'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useSaveBinaryShares } from './use-save-binary-shares'
import { Col } from './layout/col'
import { Button } from 'web/components/button'
import { BetSignUpPrompt } from './sign-up-prompt'
/** Button that opens BetPanel in a new modal */
export default function BetButton(props: {
@ -30,23 +32,29 @@ export default function BetButton(props: {
return (
<>
<Col className={clsx('items-center', className)}>
<button
className={clsx(
'btn btn-lg btn-outline my-auto inline-flex h-10 min-h-0 w-24',
btnClassName
)}
onClick={() => setOpen(true)}
>
Bet
</button>
{user ? (
<Button
size="lg"
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
onClick={() => setOpen(true)}
>
Bet
</Button>
) : (
<BetSignUpPrompt />
)}
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
{hasYesShares
? `(${Math.floor(yesShares)} ${isPseudoNumeric ? 'HIGHER' : 'YES'})`
: hasNoShares
? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})`
: ''}
</div>
{user && (
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
{hasYesShares
? `(${Math.floor(yesShares)} ${
isPseudoNumeric ? 'HIGHER' : 'YES'
})`
: hasNoShares
? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})`
: ''}
</div>
)}
</Col>
<Modal open={open} setOpen={setOpen}>

View File

@ -12,7 +12,7 @@ import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user'
import { SignUpPrompt } from './sign-up-prompt'
import { BetSignUpPrompt } from './sign-up-prompt'
import { getCpmmProbability } from 'common/calculate-cpmm'
import { Col } from './layout/col'
import { XIcon } from '@heroicons/react/solid'
@ -22,7 +22,7 @@ import { formatMoney } from 'common/util/format'
export function BetInline(props: {
contract: CPMMBinaryContract | PseudoNumericContract
className?: string
setProbAfter: (probAfter: number) => void
setProbAfter: (probAfter: number | undefined) => void
onClose: () => void
}) {
const { contract, className, setProbAfter, onClose } = props
@ -82,7 +82,7 @@ export function BetInline(props: {
<div className="text-xl">Bet</div>
<YesNoSelector
className="space-x-0"
btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl"
btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl"
selected={outcome}
onSelect={setOutcome}
isPseudoNumeric={isPseudoNumeric}
@ -112,8 +112,13 @@ export function BetInline(props: {
: 'Submit'}
</Button>
)}
<SignUpPrompt size="xs" />
<button onClick={onClose}>
<BetSignUpPrompt size="xs" />
<button
onClick={() => {
setProbAfter(undefined)
onClose()
}}
>
<XIcon className="ml-1 h-6 w-6" />
</button>
</Row>

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'
import React, { useEffect, useState } from 'react'
import { clamp, partition, sum, sumBy } from 'lodash'
import React, { useState } from 'react'
import { clamp, partition, sumBy } from 'lodash'
import { useUser } from 'web/hooks/use-user'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
@ -8,8 +8,8 @@ import { Col } from './layout/col'
import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
import {
formatLargeNumber,
formatMoney,
formatMoneyWithDecimals,
formatPercent,
formatWithCommas,
} from 'common/util/format'
@ -18,7 +18,6 @@ import { User } from 'web/lib/firebase/users'
import { Bet, LimitBet } from 'common/bet'
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import {
BinaryOutcomeLabel,
HigherLabel,
@ -30,11 +29,10 @@ import { getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { getFormattedMappedValue } from 'common/pseudo-numeric'
import { getFormattedMappedValue, getMappedValue } from 'common/pseudo-numeric'
import { SellRow } from './sell-row'
import { useSaveBinaryShares } from './use-save-binary-shares'
import { SignUpPrompt } from './sign-up-prompt'
import { isIOS } from 'web/lib/util/device'
import { BetSignUpPrompt } from './sign-up-prompt'
import { ProbabilityOrNumericInput } from './probability-input'
import { track } from 'web/lib/service/analytics'
import { useUnfilledBets } from 'web/hooks/use-bets'
@ -70,27 +68,32 @@ export function BetPanel(props: {
className
)}
>
<QuickOrLimitBet
isLimitOrder={isLimitOrder}
setIsLimitOrder={setIsLimitOrder}
hideToggle={!user}
/>
<BuyPanel
hidden={isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
/>
<LimitOrderPanel
hidden={!isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
/>
<SignUpPrompt />
{!user && <PlayMoneyDisclaimer />}
{user ? (
<>
<QuickOrLimitBet
isLimitOrder={isLimitOrder}
setIsLimitOrder={setIsLimitOrder}
hideToggle={!user}
/>
<BuyPanel
hidden={isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
/>
<LimitOrderPanel
hidden={!isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
/>
</>
) : (
<>
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</>
)}
</Col>
{user && unfilledBets.length > 0 && (
@ -148,7 +151,7 @@ export function SimpleBetPanel(props: {
onBuySuccess={onBetSuccess}
/>
<SignUpPrompt />
<BetSignUpPrompt />
{!user && <PlayMoneyDisclaimer />}
</Col>
@ -181,12 +184,12 @@ function BuyPanel(props: {
const [inputRef, focusAmountInput] = useFocus()
useEffect(() => {
if (selected) {
if (isIOS()) window.scrollTo(0, window.scrollY + 200)
focusAmountInput()
}
}, [selected, focusAmountInput])
// useEffect(() => {
// if (selected) {
// if (isIOS()) window.scrollTo(0, window.scrollY + 200)
// focusAmountInput()
// }
// }, [selected, focusAmountInput])
function onBetChoice(choice: 'YES' | 'NO') {
setOutcome(choice)
@ -254,19 +257,43 @@ function BuyPanel(props: {
const resultProb = getCpmmProbability(newPool, newP)
const probStayedSame =
formatPercent(resultProb) === formatPercent(initialProb)
const probChange = Math.abs(resultProb - initialProb)
const currentPayout = newBet.shares
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn)
const totalFees = sum(Object.values(newBet.fees))
const format = getFormattedMappedValue(contract)
const getValue = getMappedValue(contract)
const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb))
const displayedDifference = isPseudoNumeric
? formatLargeNumber(rawDifference)
: formatPercent(rawDifference)
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
const warning =
(betAmount ?? 0) > 10 &&
bankrollFraction >= 0.5 &&
bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`}
/>
) : (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`Are you sure you want to move the market by ${displayedDifference}?`}
/>
) : (
<></>
)
return (
<Col className={hidden ? 'hidden' : ''}>
<div className="my-3 text-left text-sm text-gray-500">
@ -280,7 +307,13 @@ function BuyPanel(props: {
isPseudoNumeric={isPseudoNumeric}
/>
<div className="my-3 text-left text-sm text-gray-500">Amount</div>
<Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount
<span className={'xl:hidden'}>
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
@ -289,35 +322,10 @@ function BuyPanel(props: {
setError={setError}
disabled={isSubmitting}
inputRef={inputRef}
showSliderOnMobile
/>
{(betAmount ?? 0) > 10 &&
bankrollFraction >= 0.5 &&
bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`}
/>
) : (
''
)}
{(betAmount ?? 0) > 10 && probChange >= 0.3 ? (
<AlertBox
title="Whoa, there!"
text={`Are you sure you want to move the market ${
isPseudoNumeric && contract.isLogScale
? 'this much'
: format(probChange)
}?`}
/>
) : (
''
)}
{warning}
<Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm">
@ -346,9 +354,6 @@ function BuyPanel(props: {
</>
)}
</div>
<InfoTooltip
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
/>
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
@ -564,7 +569,7 @@ function LimitOrderPanel(props: {
<Row className="mt-1 items-center gap-4">
<Col className="gap-2">
<div className="relative ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} at
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
</div>
<ProbabilityOrNumericInput
contract={contract}
@ -575,7 +580,7 @@ function LimitOrderPanel(props: {
</Col>
<Col className="gap-2">
<div className="ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} at
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
</div>
<ProbabilityOrNumericInput
contract={contract}
@ -598,9 +603,15 @@ function LimitOrderPanel(props: {
</div>
)}
<div className="mt-1 mb-3 text-left text-sm text-gray-500">
Max amount<span className="ml-1 text-red-500">*</span>
</div>
<Row className="mt-1 mb-3 justify-between text-left text-sm text-gray-500">
<span>
Max amount<span className="ml-1 text-red-500">*</span>
</span>
<span className={'xl:hidden'}>
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
@ -608,6 +619,7 @@ function LimitOrderPanel(props: {
error={error}
setError={setError}
disabled={isSubmitting}
showSliderOnMobile
/>
<Col className="mt-3 w-full gap-3">
@ -665,9 +677,9 @@ function LimitOrderPanel(props: {
</>
)}
</div>
<InfoTooltip
{/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
/>
/> */}
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
@ -689,9 +701,9 @@ function LimitOrderPanel(props: {
</>
)}
</div>
<InfoTooltip
{/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
/>
/> */}
</Row>
<div>
<span className="mr-2 whitespace-nowrap">

View File

@ -1,23 +1,13 @@
import Link from 'next/link'
import {
Dictionary,
keyBy,
groupBy,
mapValues,
sortBy,
partition,
sumBy,
uniq,
} from 'lodash'
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
import dayjs from 'dayjs'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import clsx from 'clsx'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users'
import {
formatLargeNumber,
formatMoney,
formatPercent,
formatWithCommas,
@ -28,10 +18,8 @@ import {
Contract,
contractPath,
getBinaryProbPercent,
getContractFromId,
} from 'web/lib/firebase/contracts'
import { Row } from './layout/row'
import { UserLink } from './user-page'
import { sellBet } from 'web/lib/firebase/api'
import { ConfirmationButton } from './confirmation-button'
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label'
@ -46,8 +34,6 @@ import {
resolvedPayout,
getContractBetNullMetrics,
} from 'common/calculate'
import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render'
import { trackLatency } from 'web/lib/firebase/tracking'
import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user'
@ -56,9 +42,10 @@ import { SellSharesModal } from './sell-modal'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet'
import { floatingEqual } from 'common/util/math'
import { filterDefined } from 'common/util/array'
import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets'
import { UserLink } from 'web/components/user-link'
import { useUserBetContracts } from 'web/hooks/use-contracts'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -72,26 +59,22 @@ export function BetsList(props: { user: User }) {
const signedInUser = useUser()
const isYourBets = user.id === signedInUser?.id
const hideBetsBefore = isYourBets ? 0 : JUNE_1_2022
const userBets = useUserBets(user.id, { includeRedemptions: true })
const [contractsById, setContractsById] = useState<
Dictionary<Contract> | undefined
>()
const userBets = useUserBets(user.id)
// Hide bets before 06-01-2022 if this isn't your own profile
// NOTE: This means public profits also begin on 06-01-2022 as well.
const bets = useMemo(
() => userBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)),
() =>
userBets?.filter(
(bet) => !bet.isAnte && bet.createdTime >= (hideBetsBefore ?? 0)
),
[userBets, hideBetsBefore]
)
useEffect(() => {
if (bets) {
const contractIds = uniq(bets.map((b) => b.contractId))
Promise.all(contractIds.map(getContractFromId)).then((contracts) => {
setContractsById(keyBy(filterDefined(contracts), 'id'))
})
}
}, [bets])
const contractList = useUserBetContracts(user.id)
const contractsById = useMemo(() => {
return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList])
const [sort, setSort] = useState<BetSort>('newest')
const [filter, setFilter] = useState<BetFilter>('open')
@ -99,13 +82,6 @@ export function BetsList(props: { user: User }) {
const start = page * CONTRACTS_PER_PAGE
const end = start + CONTRACTS_PER_PAGE
const getTime = useTimeSinceFirstRender()
useEffect(() => {
if (bets && contractsById && signedInUser) {
trackLatency(signedInUser.id, 'portfolio', getTime())
}
}, [signedInUser, bets, contractsById, getTime])
if (!bets || !contractsById) {
return <LoadingIndicator />
}
@ -185,7 +161,7 @@ export function BetsList(props: { user: User }) {
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
return (
<Col className="mt-6">
<Col>
<Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0">
<Row className="gap-8">
<Col>
@ -233,26 +209,27 @@ export function BetsList(props: { user: User }) {
<Col className="mt-6 divide-y">
{displayedContracts.length === 0 ? (
<NoBets user={user} />
<NoMatchingBets />
) : (
displayedContracts.map((contract) => (
<ContractBets
key={contract.id}
contract={contract}
bets={contractBets[contract.id] ?? []}
metric={sort === 'profit' ? 'profit' : 'value'}
isYourBets={isYourBets}
<>
{displayedContracts.map((contract) => (
<ContractBets
key={contract.id}
contract={contract}
bets={contractBets[contract.id] ?? []}
metric={sort === 'profit' ? 'profit' : 'value'}
isYourBets={isYourBets}
/>
))}
<Pagination
page={page}
itemsPerPage={CONTRACTS_PER_PAGE}
totalItems={filteredContracts.length}
setPage={setPage}
/>
))
</>
)}
</Col>
<Pagination
page={page}
itemsPerPage={CONTRACTS_PER_PAGE}
totalItems={filteredContracts.length}
setPage={setPage}
/>
</Col>
)
}
@ -260,7 +237,7 @@ export function BetsList(props: { user: User }) {
const NoBets = ({ user }: { user: User }) => {
const me = useUser()
return (
<div className="mx-4 text-gray-500">
<div className="mx-4 py-4 text-gray-500">
{user.id === me?.id ? (
<>
You have not made any bets yet.{' '}
@ -274,6 +251,11 @@ const NoBets = ({ user }: { user: User }) => {
</div>
)
}
const NoMatchingBets = () => (
<div className="mx-4 py-4 text-gray-500">
No bets matching the current filter.
</div>
)
function ContractBets(props: {
contract: Contract
@ -405,19 +387,16 @@ export function BetsSummary(props: {
const isClosed = closeTime && Date.now() > closeTime
const bets = props.bets.filter((b) => !b.isAnte)
const { hasShares } = getContractBetMetrics(contract, bets)
const { hasShares, invested, profitPercent, payout, profit, totalShares } =
getContractBetMetrics(contract, bets)
const excludeSalesAndAntes = bets.filter(
(b) => !b.isAnte && !b.isSold && !b.sale
)
const yesWinnings = sumBy(excludeSalesAndAntes, (bet) =>
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
const yesWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'YES')
)
const noWinnings = sumBy(excludeSalesAndAntes, (bet) =>
const noWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'NO')
)
const { invested, profitPercent, payout, profit, totalShares } =
getContractBetMetrics(contract, bets)
const [showSellModal, setShowSellModal] = useState(false)
const user = useUser()
@ -500,27 +479,10 @@ export function BetsSummary(props: {
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col>
</>
) : isPseudoNumeric ? (
<>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'>='} {formatLargeNumber(contract.max)}
</div>
<div className="whitespace-nowrap">
{formatMoney(yesWinnings)}
</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'<='} {formatLargeNumber(contract.min)}
</div>
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col>
</>
) : (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Current value
Expected value
</div>
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
</Col>

View File

@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import { MouseEventHandler, ReactNode } from 'react'
import clsx from 'clsx'
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
@ -14,7 +14,7 @@ export type ColorType =
export function Button(props: {
className?: string
onClick?: () => void
onClick?: MouseEventHandler<any> | undefined
children?: ReactNode
size?: SizeType
color?: ColorType
@ -37,8 +37,8 @@ export function Button(props: {
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-3 text-base',
'2xl': 'px-6 py-3 text-xl',
xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl font-semibold',
}[size]
return (
@ -52,9 +52,9 @@ export function Button(props: {
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200',
color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200',
color === 'gradient' &&
'bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200',
className

View File

@ -0,0 +1,72 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { throttle } from 'lodash'
import { ReactNode, useRef, useState, useEffect } from 'react'
import { Row } from './layout/row'
import { VisibilityObserver } from 'web/components/visibility-observer'
export function Carousel(props: {
children: ReactNode
loadMore?: () => void
className?: string
}) {
const { children, loadMore, className } = props
const ref = useRef<HTMLDivElement>(null)
const th = (f: () => any) => throttle(f, 500, { trailing: false })
const scrollLeft = th(() =>
ref.current?.scrollBy({ left: -ref.current.clientWidth })
)
const scrollRight = th(() =>
ref.current?.scrollBy({ left: ref.current.clientWidth })
)
const [atFront, setAtFront] = useState(true)
const [atBack, setAtBack] = useState(false)
const onScroll = throttle(() => {
if (ref.current) {
const { scrollLeft, clientWidth, scrollWidth } = ref.current
setAtFront(scrollLeft < 80)
setAtBack(scrollWidth - (clientWidth + scrollLeft) < 80)
}
}, 500)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(onScroll, [children])
return (
<div className={clsx('relative', className)}>
<Row
className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth"
ref={ref}
onScroll={onScroll}
>
{children}
{loadMore && (
<VisibilityObserver
className="relative -left-96"
onVisibilityUpdated={(visible) => visible && loadMore()}
/>
)}
</Row>
{!atFront && (
<div
className="absolute left-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30"
onMouseDown={scrollLeft}
>
<ChevronLeftIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" />
</div>
)}
{!atBack && (
<div
className="absolute right-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30"
onMouseDown={scrollRight}
>
<ChevronRightIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" />
</div>
)}
</div>
)
}

View File

@ -2,7 +2,7 @@ import { User } from 'common/user'
import { Contract } from 'common/contract'
import { Challenge } from 'common/challenge'
import { useEffect, useState } from 'react'
import { SignUpPrompt } from 'web/components/sign-up-prompt'
import { BetSignUpPrompt } from 'web/components/sign-up-prompt'
import { acceptChallenge, APIError } from 'web/lib/firebase/api'
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
@ -27,7 +27,7 @@ export function AcceptChallengeButton(props: {
setErrorText('')
}, [open])
if (!user) return <SignUpPrompt label="Accept this bet" className="mt-4" />
if (!user) return <BetSignUpPrompt label="Accept this bet" className="mt-4" />
const iAcceptChallenge = () => {
setLoading(true)

View File

@ -147,7 +147,7 @@ function CreateChallengeForm(props: {
setFinishedCreating(true)
}}
>
<Title className="!mt-2" text="Challenge bet " />
<Title className="!mt-2 hidden sm:block" text="Challenge bet " />
<div className="mb-8">
Challenge a friend to bet on{' '}
@ -170,72 +170,76 @@ function CreateChallengeForm(props: {
)}
</div>
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
<div>You'll bet:</div>
<Row
className={
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
}
>
<AmountInput
amount={challengeInfo.amount || undefined}
onChange={(newAmount) =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
amount: newAmount ?? 0,
acceptorAmount: editingAcceptorAmount
? m.acceptorAmount
: newAmount ?? 0,
}
})
}
error={undefined}
label={'M$'}
inputClassName="w-24"
/>
<span className={''}>on</span>
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
</Row>
<Row className={'mt-3 max-w-xs justify-end'}>
<Button
color={'gray-white'}
onClick={() =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
outcome: m.outcome === 'YES' ? 'NO' : 'YES',
}
})
<Col className="mt-2 flex-wrap justify-center gap-x-5 sm:gap-y-2">
<Col>
<div>You'll bet:</div>
<Row
className={
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
}
>
<SwitchVerticalIcon className={'h-6 w-6'} />
</Button>
</Row>
<Row className={'items-center'}>If they bet:</Row>
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
<div className={'w-32 sm:mr-1'}>
<AmountInput
amount={challengeInfo.acceptorAmount || undefined}
onChange={(newAmount) => {
setEditingAcceptorAmount(true)
amount={challengeInfo.amount || undefined}
onChange={(newAmount) =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
acceptorAmount: newAmount ?? 0,
amount: newAmount ?? 0,
acceptorAmount: editingAcceptorAmount
? m.acceptorAmount
: newAmount ?? 0,
}
})
}}
}
error={undefined}
label={'M$'}
inputClassName="w-24"
/>
</div>
<span>on</span>
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
</Row>
</div>
<span className={''}>on</span>
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
</Row>
<Row className={'mt-3 max-w-xs justify-end'}>
<Button
color={'gray-white'}
onClick={() =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
outcome: m.outcome === 'YES' ? 'NO' : 'YES',
}
})
}
>
<SwitchVerticalIcon className={'h-6 w-6'} />
</Button>
</Row>
<Row className={'items-center'}>If they bet:</Row>
<Row
className={'max-w-xs items-center justify-between gap-4 pr-3'}
>
<div className={'w-32 sm:mr-1'}>
<AmountInput
amount={challengeInfo.acceptorAmount || undefined}
onChange={(newAmount) => {
setEditingAcceptorAmount(true)
setChallengeInfo((m: challengeInfo) => {
return {
...m,
acceptorAmount: newAmount ?? 0,
}
})
}}
error={undefined}
label={'M$'}
inputClassName="w-24"
/>
</div>
<span>on</span>
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
</Row>
</Col>
</Col>
{contract && (
<Button
size="2xs"

View File

@ -1,9 +1,9 @@
import { DonationTxn } from 'common/txn'
import { Avatar } from '../avatar'
import { useUserById } from 'web/hooks/use-user'
import { UserLink } from '../user-page'
import { manaToUSD } from '../../../common/util/format'
import { RelativeTimestamp } from '../relative-timestamp'
import { UserLink } from 'web/components/user-link'
export function Donation(props: { txn: DonationTxn }) {
const { txn } = props

View File

@ -1,20 +1,23 @@
import { useEffect, useState } from 'react'
import { Comment, ContractComment } from 'common/comment'
import { ContractComment } from 'common/comment'
import { groupConsecutive } from 'common/util/array'
import { getUsersComments } from 'web/lib/firebase/comments'
import { getUserCommentsQuery } from 'web/lib/firebase/comments'
import { usePagination } from 'web/hooks/use-pagination'
import { SiteLink } from './site-link'
import { Row } from './layout/row'
import { Avatar } from './avatar'
import { RelativeTimestamp } from './relative-timestamp'
import { UserLink } from './user-page'
import { User } from 'common/user'
import { Col } from './layout/col'
import { Content } from './editor'
import { Pagination } from './pagination'
import { LoadingIndicator } from './loading-indicator'
import { UserLink } from 'web/components/user-link'
import { PaginationNextPrev } from 'web/components/pagination'
const COMMENTS_PER_PAGE = 50
type ContractKey = {
contractId: string
contractSlug: string
contractQuestion: string
}
function contractPath(slug: string) {
// by convention this includes the contract creator username, but we don't
@ -24,67 +27,83 @@ function contractPath(slug: string) {
export function UserCommentsList(props: { user: User }) {
const { user } = props
const [comments, setComments] = useState<ContractComment[] | undefined>()
const [page, setPage] = useState(0)
const start = page * COMMENTS_PER_PAGE
const end = start + COMMENTS_PER_PAGE
useEffect(() => {
getUsersComments(user.id).then((cs) => {
// we don't show comments in groups here atm, just comments on contracts
setComments(
cs.filter((c) => c.commentType == 'contract') as ContractComment[]
)
})
}, [user.id])
const page = usePagination({ q: getUserCommentsQuery(user.id), pageSize: 50 })
const { isStart, isEnd, getNext, getPrev, getItems, isLoading } = page
if (comments == null) {
const pageComments = groupConsecutive(getItems(), (c) => {
return {
contractId: c.contractId,
contractQuestion: c.contractQuestion,
contractSlug: c.contractSlug,
}
})
if (isLoading) {
return <LoadingIndicator />
}
const pageComments = groupConsecutive(comments.slice(start, end), (c) => {
return { question: c.contractQuestion, slug: c.contractSlug }
})
if (pageComments.length === 0) {
if (isStart && isEnd) {
return <p>This user hasn't made any comments yet.</p>
} else {
// this can happen if their comment count is a multiple of page size
return <p>No more comments to display.</p>
}
}
return (
<Col className={'bg-white'}>
{pageComments.map(({ key, items }, i) => {
return (
<div key={start + i} className="border-b p-5">
<SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700"
href={contractPath(key.slug)}
>
{key.question}
</SiteLink>
<Col className="gap-6">
{items.map((comment) => (
<ProfileComment
key={comment.id}
comment={comment}
className="relative flex items-start space-x-3"
/>
))}
</Col>
</div>
)
return <ProfileCommentGroup key={i} groupKey={key} items={items} />
})}
<Pagination
page={page}
itemsPerPage={COMMENTS_PER_PAGE}
totalItems={comments.length}
setPage={setPage}
/>
<nav
className="border-t border-gray-200 px-4 py-3 sm:px-6"
aria-label="Pagination"
>
<PaginationNextPrev
prev={!isStart ? 'Previous' : null}
next={!isEnd ? 'Next' : null}
onClickPrev={getPrev}
onClickNext={getNext}
scrollToTop={true}
/>
</nav>
</Col>
)
}
function ProfileComment(props: { comment: Comment; className?: string }) {
const { comment, className } = props
function ProfileCommentGroup(props: {
groupKey: ContractKey
items: ContractComment[]
}) {
const { groupKey, items } = props
const { contractSlug, contractQuestion } = groupKey
const path = contractPath(contractSlug)
return (
<div className="border-b p-5">
<SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700"
href={path}
>
{contractQuestion}
</SiteLink>
<Col className="gap-6">
{items.map((c) => (
<ProfileComment key={c.id} comment={c} />
))}
</Col>
</div>
)
}
function ProfileComment(props: { comment: ContractComment }) {
const { comment } = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
// TODO: find and attach relevant bets by comment betId at some point
return (
<Row className={className}>
<Row className="relative flex items-start space-x-3">
<Avatar username={userUsername} avatarUrl={userAvatarUrl} />
<div className="min-w-0 flex-1">
<p className="mt-0.5 text-sm text-gray-500">

Some files were not shown because too many files have changed in this diff Show More