diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 00000000..27a54149
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -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 }}
diff --git a/common/.eslintrc.js b/common/.eslintrc.js
index c6f9703e..5212207a 100644
--- a/common/.eslintrc.js
+++ b/common/.eslintrc.js
@@ -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',
},
},
],
diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts
new file mode 100644
index 00000000..3aad1a9c
--- /dev/null
+++ b/common/calculate-metrics.ts
@@ -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
+}
diff --git a/common/calculate.ts b/common/calculate.ts
index 758fc3cd..da4ce13a 100644
--- a/common/calculate.ts
+++ b/common/calculate.ts
@@ -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
diff --git a/common/comment.ts b/common/comment.ts
index c7f9b855..3a4bd9ac 100644
--- a/common/comment.ts
+++ b/common/comment.ts
@@ -23,10 +23,16 @@ export type Comment = {
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 = {
diff --git a/common/contract-details.ts b/common/contract-details.ts
index 02af6359..c231b1e4 100644
--- a/common/contract-details.ts
+++ b/common/contract-details.ts
@@ -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
)
}
diff --git a/common/contract.ts b/common/contract.ts
index 2a8f897a..0d2a38ca 100644
--- a/common/contract.ts
+++ b/common/contract.ts
@@ -57,6 +57,10 @@ export type Contract = {
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 = {
diff --git a/common/economy.ts b/common/economy.ts
index cd40f87c..c1449d4f 100644
--- a/common/economy.ts
+++ b/common/economy.ts
@@ -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
diff --git a/common/envs/constants.ts b/common/envs/constants.ts
index 48f9bf63..ba460d58 100644
--- a/common/envs/constants.ts
+++ b/common/envs/constants.ts
@@ -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}`
+}
diff --git a/common/envs/prod.ts b/common/envs/prod.ts
index 033d050f..2b1ee70e 100644
--- a/common/envs/prod.ts
+++ b/common/envs/prod.ts
@@ -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',
diff --git a/common/fees.ts b/common/fees.ts
index 0a537edc..f944933c 100644
--- a/common/fees.ts
+++ b/common/fees.ts
@@ -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 = {
diff --git a/common/group.ts b/common/group.ts
index 7d3215ae..19f3b7b8 100644
--- a/common/group.ts
+++ b/common/group.ts
@@ -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
diff --git a/common/like.ts b/common/like.ts
new file mode 100644
index 00000000..38b25dad
--- /dev/null
+++ b/common/like.ts
@@ -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
diff --git a/common/loans.ts b/common/loans.ts
index 64742b3e..e05f1c2a 100644
--- a/common/loans.ts
+++ b/common/loans.ts
@@ -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,
diff --git a/common/new-contract.ts b/common/new-contract.ts
index 17b872ab..431f435e 100644
--- a/common/new-contract.ts
+++ b/common/new-contract.ts
@@ -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
diff --git a/common/notification.ts b/common/notification.ts
index 0a69f89d..9ec320fa 100644
--- a/common/notification.ts
+++ b/common/notification.ts
@@ -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'
diff --git a/common/package.json b/common/package.json
index 955e9662..52195398 100644
--- a/common/package.json
+++ b/common/package.json
@@ -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": {
diff --git a/common/post.ts b/common/post.ts
new file mode 100644
index 00000000..05eab685
--- /dev/null
+++ b/common/post.ts
@@ -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
diff --git a/common/user.ts b/common/user.ts
index dee1413f..0e333278 100644
--- a/common/user.ts
+++ b/common/user.ts
@@ -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
diff --git a/common/util/object.ts b/common/util/object.ts
index 5596286e..41d2cd70 100644
--- a/common/util/object.ts
+++ b/common/util/object.ts
@@ -1,6 +1,6 @@
import { union } from 'lodash'
-export const removeUndefinedProps = (obj: T): T => {
+export const removeUndefinedProps = (obj: T): T => {
const newObj: any = {}
for (const key of Object.keys(obj)) {
@@ -37,4 +37,3 @@ export const subtractObjects = (
return newObj as T
}
-
diff --git a/common/util/time.ts b/common/util/time.ts
index 914949e4..9afb8db4 100644
--- a/common/util/time.ts
+++ b/common/util/time.ts
@@ -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
diff --git a/common/util/tiptap-iframe.ts b/common/util/tiptap-iframe.ts
index 5af63d2f..9e260821 100644
--- a/common/util/tiptap-iframe.ts
+++ b/common/util/tiptap-iframe.ts
@@ -35,7 +35,7 @@ export default Node.create({
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({
frameborder: {
default: 0,
},
+ height: {
+ default: 0,
+ },
allowfullscreen: {
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen,
@@ -60,6 +63,11 @@ export default Node.create({
},
renderHTML({ HTMLAttributes }) {
+ this.options.HTMLAttributes.style =
+ this.options.HTMLAttributes.style +
+ ' height: ' +
+ HTMLAttributes.height +
+ ';'
return [
'div',
this.options.HTMLAttributes,
diff --git a/dev.sh b/dev.sh
index ca3246ac..d392646e 100755
--- a/dev.sh
+++ b/dev.sh
@@ -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 \
diff --git a/docs/docs/api.md b/docs/docs/api.md
index 7b0058c2..e284abdf 100644
--- a/docs/docs/api.md
+++ b/docs/docs/api.md
@@ -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 = {
diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md
index 458b81ee..7a30fed6 100644
--- a/docs/docs/awesome-manifold.md
+++ b/docs/docs/awesome-manifold.md
@@ -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) 
+- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) 
diff --git a/firestore.indexes.json b/firestore.indexes.json
index 874344be..bcee41d5 100644
--- a/firestore.indexes.json
+++ b/firestore.indexes.json
@@ -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",
diff --git a/firestore.rules b/firestore.rules
index c0d17dac..15b60d0f 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -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;
+ }
}
}
diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js
index 2c607231..55070858 100644
--- a/functions/.eslintrc.js
+++ b/functions/.eslintrc.js
@@ -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',
},
},
],
diff --git a/functions/package.json b/functions/package.json
index d6278c25..d5a578de 100644
--- a/functions/package.json
+++ b/functions/package.json
@@ -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",
diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts
index 71c6bd64..fc64aeff 100644
--- a/functions/src/create-group.ts
+++ b/functions/src/create-group.ts
@@ -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 }
})
diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts
index c3780a1f..300d91f2 100644
--- a/functions/src/create-market.ts
+++ b/functions/src/create-market.ts
@@ -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 = 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`)
diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts
index 3fb1f9c3..131d6e85 100644
--- a/functions/src/create-notification.ts
+++ b/functions/src/create-notification.ts
@@ -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(
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(
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))
+}
diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts
new file mode 100644
index 00000000..40d39bba
--- /dev/null
+++ b/functions/src/create-post.ts
@@ -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 = 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)
+}
diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts
index 54e37d62..eabe0fd0 100644
--- a/functions/src/create-user.ts
+++ b/functions/src/create-user.ts
@@ -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(
- 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() })
}
}
diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html
index fc067643..d00b227e 100644
--- a/functions/src/email-templates/interesting-markets.html
+++ b/functions/src/email-templates/interesting-markets.html
@@ -444,7 +444,7 @@
style="
color: inherit;
text-decoration: none;
- " target="_blank">click here to unsubscribe.
+ " target="_blank">click here to unsubscribe from future recommended markets.
diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html
index 225436ad..1f7fa5fa 100644
--- a/functions/src/email-templates/market-answer.html
+++ b/functions/src/email-templates/market-answer.html
@@ -1,84 +1,91 @@
-
-
-
-
- Market answer
+ ">
-
-
-
-
+
-
+
-
+ | |
- |
+
-
+
-
+
-
+
-
+ |
-
+
-
+
-
+ |
-
- |
-
-
+
+
+
+
+
+
-
+ |
-
+
-
+
-
+ |
-
- 
+
+ 
- {{name}}
-
- |
-
-
+ {{name}}
+
+
+
+
-
+ |
- |
-
-
+ {{answer}}
+
+
+
+
-
- |
-
-
-
-
- |
-
-
-
+
+
+
+ |
+
+
+
+
+
+
- |
- |
-
-
-
-
+ " valign="top">
+
+
+