Merge branch 'manifoldmarkets:main' into main

This commit is contained in:
marsteralex 2022-08-25 14:06:34 -07:00 committed by GitHub
commit 127e6930f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
96 changed files with 1861 additions and 831 deletions

View File

@ -11,11 +11,8 @@ import {
import { User } from './user'
import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees'
import { ENV_CONFIG } from './envs/constants'
import { Answer } from './answer'
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id

View File

@ -61,5 +61,3 @@ export type fill = {
// I.e. -fill.shares === matchedBet.shares
isSale?: boolean
}
export const MAX_LOAN_PER_CONTRACT = 20

View File

@ -57,6 +57,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
uniqueBettorIds?: string[]
uniqueBettorCount?: number
popularityScore?: number
followerCount?: number
featuredOnHomeRank?: number
} & T
export type BinaryContract = Contract & Binary

17
common/economy.ts Normal file
View File

@ -0,0 +1,17 @@
import { ENV_CONFIG } from './envs/constants'
const econ = ENV_CONFIG.economy
export const FIXED_ANTE = econ?.FIXED_ANTE ?? 100
export const STARTING_BALANCE = econ?.STARTING_BALANCE ?? 1000
// for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = econ?.SUS_STARTING_BALANCE ?? 10
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 ?? 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

@ -44,3 +44,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

@ -19,10 +19,23 @@ export type EnvConfig = {
navbarLogoPath?: string
newQuestionPlaceholders: string[]
// Currency controls
fixedAnte?: number
startingBalance?: number
referralBonus?: number
economy?: Economy
}
export type Economy = {
FIXED_ANTE?: number
STARTING_BALANCE?: number
SUS_STARTING_BALANCE?: number
REFERRAL_AMOUNT?: number
UNIQUE_BETTOR_BONUS_AMOUNT?: number
BETTING_STREAK_BONUS_AMOUNT?: number
BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number
}
type FirebaseConfig = {
@ -58,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 = {

138
common/loans.ts Normal file
View File

@ -0,0 +1,138 @@
import { Dictionary, groupBy, sumBy, minBy } from 'lodash'
import { Bet } from './bet'
import { getContractBetMetrics } from './calculate'
import {
Contract,
CPMMContract,
FreeResponseContract,
MultipleChoiceContract,
} from './contract'
import { PortfolioMetrics, User } from './user'
import { filterDefined } from './util/array'
const LOAN_DAILY_RATE = 0.01
const calculateNewLoan = (investedValue: number, loanTotal: number) => {
const netValue = investedValue - loanTotal
return netValue * LOAN_DAILY_RATE
}
export const getLoanUpdates = (
users: User[],
contractsById: { [contractId: string]: Contract },
portfolioByUser: { [userId: string]: PortfolioMetrics | undefined },
betsByUser: { [userId: string]: Bet[] }
) => {
const eligibleUsers = filterDefined(
users.map((user) =>
isUserEligibleForLoan(portfolioByUser[user.id]) ? user : undefined
)
)
const betUpdates = eligibleUsers
.map((user) => {
const updates = calculateLoanBetUpdates(
betsByUser[user.id] ?? [],
contractsById
).betUpdates
return updates.map((update) => ({ ...update, user }))
})
.flat()
const updatesByUser = groupBy(betUpdates, (update) => update.userId)
const userPayouts = Object.values(updatesByUser).map((updates) => {
return {
user: updates[0].user,
payout: sumBy(updates, (update) => update.newLoan),
}
})
return {
betUpdates,
userPayouts,
}
}
const isUserEligibleForLoan = (portfolio: PortfolioMetrics | undefined) => {
if (!portfolio) return true
const { balance, investmentValue } = portfolio
return balance + investmentValue > 0
}
const calculateLoanBetUpdates = (
bets: Bet[],
contractsById: Dictionary<Contract>
) => {
const betsByContract = groupBy(bets, (bet) => bet.contractId)
const contracts = filterDefined(
Object.keys(betsByContract).map((contractId) => contractsById[contractId])
).filter((c) => !c.isResolved)
const betUpdates = filterDefined(
contracts
.map((c) => {
if (c.mechanism === 'cpmm-1') {
return getBinaryContractLoanUpdate(c, betsByContract[c.id])
} else if (
c.outcomeType === 'FREE_RESPONSE' ||
c.outcomeType === 'MULTIPLE_CHOICE'
)
return getFreeResponseContractLoanUpdate(c, betsByContract[c.id])
else {
// Unsupported contract / mechanism for loans.
return []
}
})
.flat()
)
const totalNewLoan = sumBy(betUpdates, (loanUpdate) => loanUpdate.loanTotal)
return {
totalNewLoan,
betUpdates,
}
}
const getBinaryContractLoanUpdate = (contract: CPMMContract, bets: Bet[]) => {
const { invested } = getContractBetMetrics(contract, bets)
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const oldestBet = minBy(bets, (bet) => bet.createdTime)
const newLoan = calculateNewLoan(invested, loanAmount)
if (!isFinite(newLoan) || newLoan <= 0 || !oldestBet) return undefined
const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan
return {
userId: oldestBet.userId,
contractId: contract.id,
betId: oldestBet.id,
newLoan,
loanTotal,
}
}
const getFreeResponseContractLoanUpdate = (
contract: FreeResponseContract | MultipleChoiceContract,
bets: Bet[]
) => {
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 (!isFinite(newLoan) || newLoan <= 0) return undefined
return {
userId: bet.userId,
contractId: contract.id,
betId: bet.id,
newLoan,
loanTotal,
}
})
}

View File

@ -1,6 +1,6 @@
import { sortBy, sum, sumBy } from 'lodash'
import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
import { Bet, fill, LimitBet, NumericBet } from './bet'
import {
calculateDpmShares,
getDpmProbability,
@ -276,8 +276,7 @@ export const getBinaryBetStats = (
export const getNewBinaryDpmBetInfo = (
outcome: 'YES' | 'NO',
amount: number,
contract: DPMBinaryContract,
loanAmount: number
contract: DPMBinaryContract
) => {
const { YES: yesPool, NO: noPool } = contract.pool
@ -308,7 +307,7 @@ export const getNewBinaryDpmBetInfo = (
const newBet: CandidateBet = {
contractId: contract.id,
amount,
loanAmount,
loanAmount: 0,
shares,
outcome,
probBefore,
@ -324,7 +323,6 @@ export const getNewMultiBetInfo = (
outcome: string,
amount: number,
contract: FreeResponseContract | MultipleChoiceContract,
loanAmount: number
) => {
const { pool, totalShares, totalBets } = contract
@ -345,7 +343,7 @@ export const getNewMultiBetInfo = (
const newBet: CandidateBet = {
contractId: contract.id,
amount,
loanAmount,
loanAmount: 0,
shares,
outcome,
probBefore,
@ -399,13 +397,3 @@ export const getNumericBetsInfo = (
return { newBet, newPool, newTotalShares, newTotalBets }
}
export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => {
const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale)
const prevLoanAmount = sumBy(openBets, (bet) => bet.loanAmount ?? 0)
const loanAmount = Math.min(
newBetAmount,
MAX_LOAN_PER_CONTRACT - prevLoanAmount
)
return loanAmount
}

View File

@ -39,6 +39,7 @@ export type notification_source_types =
| 'bonus'
| 'challenge'
| 'betting_streak_bonus'
| 'loan'
export type notification_source_update_types =
| 'created'
@ -68,3 +69,5 @@ export type notification_reason_types =
| 'user_joined_from_your_group_invite'
| 'challenge_accepted'
| 'betting_streak_incremented'
| 'loan_income'
| 'you_follow_contract'

View File

@ -3,7 +3,3 @@ export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10
export const BETTING_STREAK_BONUS_AMOUNT = 5
export const BETTING_STREAK_BONUS_MAX = 100
export const BETTING_STREAK_RESET_HOUR = 0

View File

@ -13,8 +13,9 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares)
const shares = Math.max(Math.min(yesShares, noShares), 0)
const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPayment = Math.min(loanAmount, shares)
const loanPayment = loanAmount * soldFrac
const netAmount = shares - loanPayment
return { shares, loanPayment, netAmount }
}

View File

@ -13,7 +13,7 @@ export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
const { pool, totalShares, totalBets } = contract
const { id: betId, amount, shares, outcome } = bet
const { id: betId, amount, shares, outcome, loanAmount } = bet
const adjShareValue = calculateDpmShareValue(contract, bet)
@ -64,6 +64,7 @@ export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
betId,
},
fees,
loanAmount: -(loanAmount ?? 0),
}
return {
@ -79,8 +80,8 @@ export const getCpmmSellBetInfo = (
shares: number,
outcome: 'YES' | 'NO',
contract: CPMMContract,
prevLoanAmount: number,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
loanPaid: number
) => {
const { pool, p } = contract
@ -91,7 +92,6 @@ export const getCpmmSellBetInfo = (
unfilledBets
)
const loanPaid = Math.min(prevLoanAmount, saleValue)
const probBefore = getCpmmProbability(pool, p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)

View File

@ -1,5 +1,3 @@
import { ENV_CONFIG } from './envs/constants'
export type User = {
id: string
createdTime: number
@ -32,6 +30,7 @@ export type User = {
allTime: number
}
nextLoanCached: number
followerCountCached: number
followedCategories?: string[]
@ -43,13 +42,10 @@ export type User = {
shouldShowWelcome?: boolean
lastBetTime?: number
currentBettingStreak?: number
hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number
}
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
// for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
export type PrivateUser = {
id: string // same as User.id
username: string // denormalized from User
@ -60,6 +56,7 @@ export type PrivateUser = {
unsubscribedFromAnswerEmails?: boolean
unsubscribedFromGenericEmails?: boolean
unsubscribedFromWeeklyTrendingEmails?: boolean
weeklyTrendingEmailSent?: boolean
manaBonusEmailSent?: boolean
initialDeviceToken?: string
initialIpAddress?: string

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

@ -97,7 +97,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 +134,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 +395,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

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

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

View File

@ -75,10 +75,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
}
transaction.create(newAnswerDoc, answer)
const loanAmount = 0
const { newBet, newPool, newTotalShares, newTotalBets } =
getNewMultiBetInfo(answerId, amount, contract, loanAmount)
getNewMultiBetInfo(answerId, amount, contract)
const newBalance = user.balance - amount
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()

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, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
import {
FIXED_ANTE,
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
@ -207,7 +211,18 @@ 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)
@ -221,8 +236,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
}
}
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'
@ -33,19 +33,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 +83,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 +104,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,71 +140,6 @@ export const createNotification = async (
}
}
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const answers = await getValues<Answer>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_answer',
}
})
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_comment',
}
})
}
const notifyBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.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 recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
(userId) => {
return (
getContractBetMetrics(
sourceContract,
bets.filter((bet) => bet.userId === userId)
).invested > 0
)
}
)
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyUserAddedToGroup = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string
@ -266,58 +159,289 @@ export const createNotification = async (
}
}
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))
}
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
// The following functions need sourceContract to be defined.
if (!sourceContract) return userToReasonTexts
if (
sourceType === 'comment' ||
sourceType === 'answer' ||
(sourceType === 'contract' &&
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
) {
if (sourceType === 'comment') {
if (recipients?.[0] && relatedSourceType)
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
}
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
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)
} else if (
sourceType === 'bonus' &&
sourceUpdateType === 'created' &&
sourceContract
) {
// Note: the daily bonus won't have a contract attached to it
await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
}
const userToReasonTexts = await getUsersToNotify()
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
.collection('contracts')
.doc(sourceContract.id)
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_answer',
}
})
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_comment',
}
})
}
const notifyBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.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 recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
(userId) => {
return (
getContractBetMetrics(
sourceContract,
bets.filter((bet) => bet.userId === userId)
).invested > 0
)
}
)
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string,
relatedSourceType: notification_source_types
) => {
if (
shouldGetNotification(relatedUserId, userToReasonTexts) &&
stillFollowingContract(relatedUserId)
) {
if (relatedSourceType === 'comment') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
}
}
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)
}
@ -471,6 +595,32 @@ export const createReferralNotification = async (
await notificationRef.set(removeUndefinedProps(notification))
}
export const createLoanIncomeNotification = async (
toUser: User,
idempotencyKey: string,
income: number
) => {
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: 'loan_income',
createdTime: Date.now(),
isSeen: false,
sourceId: idempotencyKey,
sourceType: 'loan',
sourceUpdateType: 'updated',
sourceUserName: toUser.name,
sourceUserUsername: toUser.username,
sourceUserAvatarUrl: toUser.avatarUrl,
sourceText: income.toString(),
sourceTitle: 'Loan',
}
await notificationRef.set(removeUndefinedProps(notification))
}
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
export const createChallengeAcceptedNotification = async (

View File

@ -2,15 +2,8 @@ import * as admin from 'firebase-admin'
import { z } from 'zod'
import { uniq } from 'lodash'
import {
MANIFOLD_AVATAR_URL,
MANIFOLD_USERNAME,
PrivateUser,
STARTING_BALANCE,
SUS_STARTING_BALANCE,
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,
@ -25,10 +18,7 @@ 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'
const bodySchema = z.object({
deviceToken: z.string().optional(),
@ -75,6 +65,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
createdTime: Date.now(),
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
nextLoanCached: 0,
followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,
@ -144,24 +135,5 @@ const addUserToDefaultGroups = async (user: User) => {
.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,
})
}
}
}

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

@ -53,10 +53,10 @@ export const sendMarketResolutionEmail = async (
const subject = `Resolved ${outcome}: ${contract.question}`
const creatorPayoutText =
userId === creator.id
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
: ''
// const creatorPayoutText =
// userId === creator.id
// ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
// : ''
const emailType = 'market-resolved'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
@ -68,7 +68,7 @@ export const sendMarketResolutionEmail = async (
question: contract.question,
outcome,
investment: `${Math.floor(investment)}`,
payout: `${Math.floor(payout)}${creatorPayoutText}`,
payout: `${Math.floor(payout)}`,
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
unsubscribeUrl,
}
@ -116,7 +116,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)

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

@ -11,6 +11,7 @@ export * from './on-create-comment-on-contract'
export * from './on-view'
export * from './update-metrics'
export * from './update-stats'
export * from './update-loans'
export * from './backup-db'
export * from './market-close-notifications'
export * from './on-create-answer'
@ -28,6 +29,8 @@ 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'
// v2
export * from './health'

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

@ -17,7 +17,7 @@ import {
BETTING_STREAK_BONUS_MAX,
BETTING_STREAK_RESET_HOUR,
UNIQUE_BETTOR_BONUS_AMOUNT,
} from '../../common/numeric-constants'
} from '../../common/economy'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,

View File

@ -6,8 +6,9 @@ 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 } from './create-notification'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { addUserToContractFollowers } from './follow-market'
const firestore = admin.firestore()
@ -35,6 +36,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)
@ -77,18 +80,19 @@ 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([

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,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,45 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
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

@ -1,13 +1,14 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { REFERRAL_AMOUNT, User } from '../../common/user'
import { User } from '../../common/user'
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 { 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(),
@ -59,7 +60,6 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const user = userSnap.data() as User
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
const loanAmount = 0
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract
if (closeTime && Date.now() > closeTime)
@ -119,7 +119,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const answerDoc = contractDoc.collection('answers').doc(outcome)
const answerSnap = await trans.get(answerDoc)
if (!answerSnap.exists) throw new APIError(400, 'Invalid answer')
return getNewMultiBetInfo(outcome, amount, contract, loanAmount)
return getNewMultiBetInfo(outcome, amount, contract)
} else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') {
const { outcome, value } = validate(numericSchema, req.body)
return getNumericBetsInfo(value, outcome, amount, contract)
@ -168,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

@ -4,12 +4,12 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants'
import { BETTING_STREAK_RESET_HOUR } from '../../common/economy'
const firestore = admin.firestore()
export const resetBettingStreaksForUsers = functions.pubsub
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
.timeZone('utc')
.timeZone('Etc/UTC')
.onRun(async () => {
await resetBettingStreaksInternal()
})

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

@ -3,7 +3,8 @@ import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { PrivateUser, STARTING_BALANCE, User } from '../../../common/user'
import { PrivateUser, User } from 'common/user'
import { STARTING_BALANCE } from 'common/economy'
const firestore = admin.firestore()

View File

@ -50,11 +50,12 @@ export const sellbet = newEndpoint({}, async (req, auth) => {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
const saleAmount = newBet.sale!.amount
const newBalance = user.balance + saleAmount - (bet.loanAmount ?? 0)
const newBalance = user.balance + saleAmount + (newBet.loanAmount ?? 0)
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
transaction.update(userDoc, { balance: newBalance })
transaction.update(betDoc, { isSold: true })
// Note: id should have been newBetDoc.id! But leaving it for now so it's consistent.
transaction.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet })
transaction.update(
contractDoc,

View File

@ -7,12 +7,13 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user'
import { getCpmmSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { getValues, log } from './utils'
import { log } from './utils'
import { Bet } from '../../common/bet'
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(),
@ -28,12 +29,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [[contractSnap, userSnap], userBets] = await Promise.all([
transaction.getAll(contractDoc, userDoc),
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
])
const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
await Promise.all([
transaction.getAll(contractDoc, userDoc),
transaction.get(betsQ),
transaction.get(getUnfilledBetsQuery(contractDoc)),
])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const contract = contractSnap.data() as Contract
const user = userSnap.data() as User
@ -45,7 +50,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
if (closeTime && Date.now() > closeTime)
throw new APIError(400, 'Trading is closed.')
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const loanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
sumBy(bets, (b) => b.shares)
@ -77,18 +82,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
const soldShares = Math.min(sharesToSell, maxShares)
const unfilledBetsSnap = await transaction.get(
getUnfilledBetsQuery(contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const saleFrac = soldShares / maxShares
let loanPaid = saleFrac * loanAmount
if (!isFinite(loanPaid)) loanPaid = 0
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares,
chosenOutcome,
contract,
prevLoanAmount,
unfilledBets
unfilledBets,
loanPaid
)
if (
@ -104,7 +107,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
updateMakers(makers, newBetDoc.id, contractDoc, transaction)
transaction.update(userDoc, {
balance: FieldValue.increment(-newBet.amount),
balance: FieldValue.increment(-newBet.amount + (newBet.loanAmount ?? 0)),
})
transaction.create(newBetDoc, {
id: newBetDoc.id,
@ -121,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

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

@ -0,0 +1,92 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, keyBy } from 'lodash'
import { getValues, log, payUser, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { getLoanUpdates } from '../../common/loans'
import { createLoanIncomeNotification } from './create-notification'
const firestore = admin.firestore()
export const updateLoans = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
// Run every day at midnight.
.pubsub.schedule('0 0 * * *')
.timeZone('America/Los_Angeles')
.onRun(updateLoansCore)
async function updateLoansCore() {
log('Updating loans...')
const [users, contracts, bets] = await Promise.all([
getValues<User>(firestore.collection('users')),
getValues<Contract>(
firestore.collection('contracts').where('isResolved', '==', false)
),
getValues<Bet>(firestore.collectionGroup('bets')),
])
log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
)
const userPortfolios = await Promise.all(
users.map(async (user) => {
const portfolio = await getValues<PortfolioMetrics>(
firestore
.collection(`users/${user.id}/portfolioHistory`)
.orderBy('timestamp', 'desc')
.limit(1)
)
return portfolio[0]
})
)
log(`Loaded ${userPortfolios.length} portfolios`)
const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId)
const contractsById = Object.fromEntries(
contracts.map((contract) => [contract.id, contract])
)
const betsByUser = groupBy(bets, (bet) => bet.userId)
const { betUpdates, userPayouts } = getLoanUpdates(
users,
contractsById,
portfolioByUser,
betsByUser
)
log(`${betUpdates.length} bet updates.`)
const betDocUpdates = betUpdates.map((update) => ({
doc: firestore
.collection('contracts')
.doc(update.contractId)
.collection('bets')
.doc(update.betId),
fields: {
loanAmount: update.loanTotal,
},
}))
await writeAsync(firestore, betDocUpdates)
log(`${userPayouts.length} user payouts`)
await Promise.all(
userPayouts.map(({ user, payout }) => payUser(user.id, payout))
)
const today = new Date().toDateString().replace(' ', '-')
const key = `loan-notifications-${today}`
await Promise.all(
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,6 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, isEmpty, sum, sumBy } from 'lodash'
import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash'
import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
@ -8,6 +8,7 @@ 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'
const firestore = admin.firestore()
@ -21,7 +22,9 @@ const computeInvestmentValue = (
if (bet.sale || bet.isSold) return 0
const payout = calculatePayout(contract, bet, 'MKT')
return payout - (bet.loanAmount ?? 0)
const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0
return value
})
}
@ -71,7 +74,8 @@ export const updateMetricsCore = async () => {
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
const betsByUser = groupBy(bets, (bet) => bet.userId)
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
const userUpdates = users.map((user) => {
const userMetrics = users.map((user) => {
const currentBets = betsByUser[user.id] ?? []
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
const userContracts = contractsByUser[user.id] ?? []
@ -93,32 +97,56 @@ export const updateMetricsCore = async () => {
newPortfolio,
didProfitChange
)
return {
fieldUpdates: {
doc: firestore.collection('users').doc(user.id),
fields: {
creatorVolumeCached: newCreatorVolume,
...(didProfitChange && {
profitCached: newProfit,
}),
},
},
subcollectionUpdates: {
doc: firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.doc(),
fields: {
...(didProfitChange && {
...newPortfolio,
}),
},
},
user,
newCreatorVolume,
newPortfolio,
newProfit,
didProfitChange,
}
})
const portfolioByUser = Object.fromEntries(
userMetrics.map(({ user, newPortfolio }) => [user.id, newPortfolio])
)
const { userPayouts } = getLoanUpdates(
users,
contractsById,
portfolioByUser,
betsByUser
)
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map(
({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return {
fieldUpdates: {
doc: firestore.collection('users').doc(user.id),
fields: {
creatorVolumeCached: newCreatorVolume,
...(didProfitChange && {
profitCached: newProfit,
}),
nextLoanCached,
},
},
subcollectionUpdates: {
doc: firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.doc(),
fields: {
...(didProfitChange && {
...newPortfolio,
}),
},
},
}
}
)
await writeAsync(
firestore,
userUpdates.map((u) => u.fieldUpdates)
@ -234,6 +262,6 @@ const calculateNewProfit = (
}
export const updateMetrics = functions
.runWith({ memory: '1GB', timeoutSeconds: 540 })
.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

@ -9,9 +9,9 @@ import { DAY_MS } from '../../common/util/time'
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()
})
@ -37,17 +37,33 @@ async function sendTrendingMarketsEmailsToAllUsers() {
const privateUsers = await getAllPrivateUsers()
// 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 +86,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

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

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

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 { firebaseLogin } from 'web/lib/firebase/users'
/** Button that opens BetPanel in a new modal */
export default function BetButton(props: {
@ -30,23 +32,27 @@ 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)}
<Button
size={'lg'}
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
onClick={() => {
!user ? firebaseLogin() : setOpen(true)
}}
>
Bet
</button>
{user ? 'Bet' : 'Sign up to Bet'}
</Button>
<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

@ -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}
@ -113,7 +113,12 @@ export function BetInline(props: {
</Button>
)}
<SignUpPrompt size="xs" />
<button onClick={onClose}>
<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 { clamp, partition, sumBy } from 'lodash'
import { useUser } from 'web/hooks/use-user'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
@ -9,7 +9,6 @@ import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
import {
formatMoney,
formatMoneyWithDecimals,
formatPercent,
formatWithCommas,
} from 'common/util/format'
@ -18,7 +17,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,
@ -261,8 +259,6 @@ function BuyPanel(props: {
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn)
const totalFees = sum(Object.values(newBet.fees))
const format = getFormattedMappedValue(contract)
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
@ -346,9 +342,9 @@ function BuyPanel(props: {
</>
)}
</div>
<InfoTooltip
{/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
/>
/> */}
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
@ -665,9 +661,9 @@ function LimitOrderPanel(props: {
</>
)}
</div>
<InfoTooltip
{/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
/>
/> */}
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
@ -689,9 +685,9 @@ function LimitOrderPanel(props: {
</>
)}
</div>
<InfoTooltip
{/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
/>
/> */}
</Row>
<div>
<span className="mr-2 whitespace-nowrap">

View File

@ -21,7 +21,7 @@ import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/antes'
import { FIXED_ANTE } from 'common/economy'
import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator'

View File

@ -91,6 +91,8 @@ export function ContractSearch(props: {
useQuerySortLocalStorage?: boolean
useQuerySortUrlParams?: boolean
isWholePage?: boolean
maxItems?: number
noControls?: boolean
}) {
const {
user,
@ -105,6 +107,8 @@ export function ContractSearch(props: {
useQuerySortLocalStorage,
useQuerySortUrlParams,
isWholePage,
maxItems,
noControls,
} = props
const [numPages, setNumPages] = useState(1)
@ -158,6 +162,8 @@ export function ContractSearch(props: {
const contracts = pages
.flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
const renderedContracts =
pages.length === 0 ? undefined : contracts.slice(0, maxItems)
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return <ContractSearchFirestore additionalFilter={additionalFilter} />
@ -175,10 +181,11 @@ export function ContractSearch(props: {
useQuerySortUrlParams={useQuerySortUrlParams}
user={user}
onSearchParametersChanged={onSearchParametersChanged}
noControls={noControls}
/>
<ContractsGrid
contracts={pages.length === 0 ? undefined : contracts}
loadMore={performQuery}
contracts={renderedContracts}
loadMore={noControls ? undefined : performQuery}
showTime={showTime}
onContractClick={onContractClick}
highlightOptions={highlightOptions}
@ -198,6 +205,7 @@ function ContractSearchControls(props: {
useQuerySortLocalStorage?: boolean
useQuerySortUrlParams?: boolean
user?: User | null
noControls?: boolean
}) {
const {
className,
@ -209,6 +217,7 @@ function ContractSearchControls(props: {
useQuerySortLocalStorage,
useQuerySortUrlParams,
user,
noControls,
} = props
const savedSort = useQuerySortLocalStorage ? getSavedSort() : null
@ -329,6 +338,10 @@ function ContractSearchControls(props: {
})
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)])
if (noControls) {
return <></>
}
return (
<Col
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}

View File

@ -0,0 +1,9 @@
import { SparklesIcon } from '@heroicons/react/solid'
export function FeaturedContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-0.5 text-sm font-medium text-blue-800">
<SparklesIcon className="h-4 w-4" aria-hidden="true" /> Featured
</span>
)
}

View File

@ -32,6 +32,8 @@ import { groupPath } from 'web/lib/firebase/groups'
import { insertContent } from '../editor/utils'
import clsx from 'clsx'
import { contractMetrics } from 'common/contract-details'
import { User } from 'common/user'
import { FeaturedContractBadge } from 'web/components/contract/FeaturedContractBadge'
export type ShowTime = 'resolve-date' | 'close-date'
@ -72,6 +74,8 @@ export function MiscDetails(props: {
{'Resolved '}
{fromNow(resolutionTime || 0)}
</Row>
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
<FeaturedContractBadge />
) : volume > 0 || !isNew ? (
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row>
) : (
@ -134,6 +138,7 @@ export function AbbrContractDetails(props: {
export function ContractDetails(props: {
contract: Contract
bets: Bet[]
user: User | null | undefined
isCreator?: boolean
disabled?: boolean
}) {
@ -157,7 +162,7 @@ export function ContractDetails(props: {
)
return (
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
<Row className="flex-1 flex-wrap items-center gap-2 text-sm text-gray-500 md:gap-x-4 md:gap-y-2">
<Row className="items-center gap-2">
<Avatar
username={creatorUsername}
@ -179,6 +184,8 @@ export function ContractDetails(props: {
<Row>
{disabled ? (
groupInfo
) : !groupToDisplay && !user ? (
<div />
) : (
<Button
size={'xs'}
@ -206,10 +213,9 @@ export function ContractDetails(props: {
{(!!closeTime || !!resolvedDate) && (
<Row className="items-center gap-1">
<ClockIcon className="h-5 w-5" />
{resolvedDate && contract.resolutionTime ? (
<>
<ClockIcon className="h-5 w-5" />
<DateTimeTooltip
text="Market resolved:"
time={dayjs(contract.resolutionTime)}
@ -219,8 +225,9 @@ export function ContractDetails(props: {
</>
) : null}
{!resolvedDate && closeTime && (
{!resolvedDate && closeTime && user && (
<>
<ClockIcon className="h-5 w-5" />
<EditableCloseDate
closeTime={closeTime}
contract={contract}
@ -230,14 +237,15 @@ export function ContractDetails(props: {
)}
</Row>
)}
<Row className="items-center gap-1">
<DatabaseIcon className="h-5 w-5" />
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
{user && (
<>
<Row className="items-center gap-1">
<DatabaseIcon className="h-5 w-5" />
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
</>
)}
</Row>
)
}

View File

@ -7,12 +7,16 @@ import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { contractPool } from 'web/lib/firebase/contracts'
import { contractPool, updateContract } from 'web/lib/firebase/contracts'
import { LiquidityPanel } from '../liquidity-panel'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Title } from '../title'
import { InfoTooltip } from '../info-tooltip'
import { useAdmin, useDev } from 'web/hooks/use-admin'
import { SiteLink } from '../site-link'
import { firestoreConsolePath } from 'common/envs/constants'
import { deleteField } from 'firebase/firestore'
export const contractDetailsButtonClassName =
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
@ -21,10 +25,15 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const [open, setOpen] = useState(false)
const [featured, setFeatured] = useState(
(contract?.featuredOnHomeRank ?? 0) > 0
)
const isDev = useDev()
const isAdmin = useAdmin()
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z')
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType } =
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
contract
const tradersCount = uniqBy(
@ -105,10 +114,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<td>{formatMoney(contract.volume)}</td>
</tr>
<tr>
{/* <tr>
<td>Creator earnings</td>
<td>{formatMoney(contract.collectedFees.creatorFee)}</td>
</tr>
</tr> */}
<tr>
<td>Traders</td>
@ -121,6 +130,60 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
</td>
<td>{contractPool(contract)}</td>
</tr>
{/* Show a path to Firebase if user is an admin, or we're on localhost */}
{(isAdmin || isDev) && (
<tr>
<td>[DEV] Firestore</td>
<td>
<SiteLink href={firestoreConsolePath(id)}>
Console link
</SiteLink>
</td>
</tr>
)}
{isAdmin && (
<tr>
<td>Set featured</td>
<td>
<select
className="select select-bordered"
value={featured ? 'true' : 'false'}
onChange={(e) => {
const newVal = e.target.value === 'true'
if (
newVal &&
(contract.featuredOnHomeRank === 0 ||
!contract?.featuredOnHomeRank)
)
updateContract(id, {
featuredOnHomeRank: 1,
})
.then(() => {
setFeatured(true)
})
.catch(console.error)
else if (
!newVal &&
(contract?.featuredOnHomeRank ?? 0) > 0
)
updateContract(id, {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
featuredOnHomeRank: deleteField(),
})
.then(() => {
setFeatured(false)
})
.catch(console.error)
}}
>
<option value="false">false</option>
<option value="true">true</option>
</select>
</td>
</tr>
)}
</tbody>
</table>

View File

@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top traders"
title="🏅 Top bettors"
users={users || []}
columns={[
{

View File

@ -3,7 +3,6 @@ import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col'
import { Spacer } from '../layout/spacer'
import { ContractProbGraph } from './contract-prob-graph'
import { useUser } from 'web/hooks/use-user'
import { Row } from '../layout/row'
@ -39,52 +38,63 @@ export const ContractOverview = (props: {
return (
<Col className={clsx('mb-6', className)}>
<Col className="gap-4 px-2">
<Col className="gap-3 px-2 sm:gap-4">
<Row className="justify-between gap-4">
<div className="text-2xl text-indigo-700 md:text-3xl">
<Linkify text={question} />
</div>
<Row className={'hidden gap-3 xl:flex'}>
{isBinary && (
<BinaryResolutionOrChance
className="items-end"
contract={contract}
large
/>
)}
{isBinary && (
<BinaryResolutionOrChance
className="hidden items-end xl:flex"
contract={contract}
large
/>
)}
{isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation
contract={contract}
className="items-end"
/>
)}
{isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation
contract={contract}
className="hidden items-end xl:flex"
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
contract={contract}
className="hidden items-end xl:flex"
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
contract={contract}
className="items-end"
/>
)}
</Row>
</Row>
{isBinary ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
{tradingAllowed(contract) && (
<BetButton contract={contract as CPMMBinaryContract} />
<Col>
<BetButton contract={contract as CPMMBinaryContract} />
{!user && (
<div className="mt-1 text-center text-sm text-gray-500">
(with play money!)
</div>
)}
</Col>
)}
</Row>
) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && <BetButton contract={contract} />}
</Row>
) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && <BetButton contract={contract} />}
{tradingAllowed(contract) && (
<Col>
<BetButton contract={contract} />
{!user && (
<div className="mt-1 text-center text-sm text-gray-500">
(with play money!)
</div>
)}
</Col>
)}
</Row>
) : (
(outcomeType === 'FREE_RESPONSE' ||
@ -107,9 +117,10 @@ export const ContractOverview = (props: {
contract={contract}
bets={bets}
isCreator={isCreator}
user={user}
/>
</Col>
<Spacer h={4} />
<div className={'my-1 md:my-2'}></div>
{(isBinary || isPseudoNumeric) && (
<ContractProbGraph contract={contract} bets={bets} />
)}{' '}

View File

@ -58,7 +58,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const { width } = useWindowSize()
const numXTickValues = !width || width < 800 ? 2 : 5
const hoursAgo = latestTime.subtract(5, 'hours')
const hoursAgo = latestTime.subtract(1, 'hours')
const startDate = dayjs(times[0]).isBefore(hoursAgo)
? times[0]
: hoursAgo.toDate()

View File

@ -86,10 +86,12 @@ export function ContractsGrid(props: {
/>
))}
</Masonry>
<VisibilityObserver
onVisibilityUpdated={onVisibilityUpdated}
className="relative -top-96 h-1"
/>
{loadMore && (
<VisibilityObserver
onVisibilityUpdated={onVisibilityUpdated}
className="relative -top-96 h-1"
/>
)}
</Col>
)
}

View File

@ -0,0 +1,40 @@
import { Col } from 'web/components/layout/col'
import { Modal } from 'web/components/layout/modal'
import { EyeIcon } from '@heroicons/react/outline'
import React from 'react'
import clsx from 'clsx'
export const FollowMarketModal = (props: {
open: boolean
setOpen: (b: boolean) => void
title?: string
}) => {
const { open, setOpen, title } = props
return (
<Modal open={open} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<EyeIcon className={clsx('h-20 w-20')} aria-hidden="true" />
<span className="text-xl">{title ? title : 'Watching questions'}</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What is watching?</span>
<span className={'ml-2'}>
You can receive notifications on questions you're interested in by
clicking the
<EyeIcon
className={clsx('ml-1 inline h-6 w-6 align-top')}
aria-hidden="true"
/>
button on a question.
</span>
<span className={'text-indigo-700'}>
What types of notifications will I receive?
</span>
<span className={'ml-2'}>
You'll receive in-app notifications for new comments, answers, and
updates to the question.
</span>
</Col>
</Col>
</Modal>
)
}

View File

@ -14,9 +14,10 @@ import { Button } from '../button'
import { copyToClipboard } from 'web/lib/util/copy'
import { track } from 'web/lib/service/analytics'
import { ENV_CONFIG } from 'common/envs/constants'
import { REFERRAL_AMOUNT, User } from 'common/user'
import { User } from 'common/user'
import { SiteLink } from '../site-link'
import { formatMoney } from 'common/util/format'
import { REFERRAL_AMOUNT } from 'common/economy'
export function ShareModal(props: {
contract: Contract

View File

@ -10,6 +10,7 @@ import { User } from 'common/user'
import { CHALLENGES_ENABLED } from 'common/challenge'
import { ShareModal } from './share-modal'
import { withTracking } from 'web/lib/service/analytics'
import { FollowMarketButton } from 'web/components/follow-market-button'
export function ShareRow(props: {
contract: Contract
@ -25,7 +26,7 @@ export function ShareRow(props: {
const [isShareOpen, setShareOpen] = useState(false)
return (
<Row className="mt-2">
<Row className="mt-0.5 sm:mt-2">
<Button
size="lg"
color="gray-white"
@ -62,6 +63,7 @@ export function ShareRow(props: {
/>
</Button>
)}
<FollowMarketButton contract={contract} user={user} />
</Row>
)
}

View File

@ -1,9 +1,7 @@
import { DuplicateIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { Contract } from 'common/contract'
import { ENV_CONFIG } from 'common/envs/constants'
import { getMappedValue } from 'common/pseudo-numeric'
import { contractPath } from 'web/lib/firebase/contracts'
import { trackCallback } from 'web/lib/service/analytics'
export function DuplicateContractButton(props: {
@ -33,22 +31,29 @@ export function DuplicateContractButton(props: {
// Pass along the Uri to create a new contract
function duplicateContractHref(contract: Contract) {
const descriptionString = JSON.stringify(contract.description)
// Don't set a closeTime that's in the past
const closeTime =
(contract?.closeTime ?? 0) <= Date.now() ? 0 : contract.closeTime
const params = {
q: contract.question,
closeTime: contract.closeTime || 0,
description:
(contract.description ? `${contract.description}\n\n` : '') +
`(Copied from https://${ENV_CONFIG.domain}${contractPath(contract)})`,
closeTime,
description: descriptionString,
outcomeType: contract.outcomeType,
} as Record<string, any>
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
params.min = contract.min
params.max = contract.max
params.isLogScale = contract.isLogScale
if (contract.isLogScale) {
// Conditional, because `?isLogScale=false` evaluates to `true`
params.isLogScale = true
}
params.initValue = getMappedValue(contract)(contract.initialProbability)
}
// TODO: Support multiple choice markets?
if (contract.groupLinks && contract.groupLinks.length > 0) {
params.groupId = contract.groupLinks[0].groupId
}

View File

@ -6,6 +6,7 @@ import {
JSONContent,
Content,
Editor,
mergeAttributes,
} from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Image } from '@tiptap/extension-image'
@ -38,7 +39,16 @@ const DisplayImage = Image.configure({
},
})
const DisplayLink = Link.configure({
const DisplayLink = Link.extend({
renderHTML({ HTMLAttributes }) {
delete HTMLAttributes.class // only use our classes (don't duplicate on paste)
return [
'a',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
]
},
}).configure({
HTMLAttributes: {
class: clsx('no-underline !text-indigo-700', linkClass),
},

View File

@ -0,0 +1,77 @@
import { Button } from 'web/components/button'
import {
Contract,
followContract,
unFollowContract,
} from 'web/lib/firebase/contracts'
import toast from 'react-hot-toast'
import { CheckIcon, EyeIcon, EyeOffIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { User } from 'common/user'
import { useContractFollows } from 'web/hooks/use-follows'
import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
import { useState } from 'react'
import { Row } from 'web/components/layout/row'
export const FollowMarketButton = (props: {
contract: Contract
user: User | undefined | null
}) => {
const { contract, user } = props
const followers = useContractFollows(contract.id)
const [open, setOpen] = useState(false)
return (
<Button
size={'lg'}
color={'gray-white'}
onClick={async () => {
if (!user) return firebaseLogin()
if (followers?.includes(user.id)) {
await unFollowContract(contract.id, user.id)
toast("You'll no longer receive notifications from this market", {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
track('Unwatch Market', {
slug: contract.slug,
})
} else {
await followContract(contract.id, user.id)
toast("You'll now receive notifications from this market!", {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
track('Watch Market', {
slug: contract.slug,
})
}
if (!user.hasSeenContractFollowModal) {
await updateUser(user.id, {
hasSeenContractFollowModal: true,
})
setOpen(true)
}
}}
>
{followers?.includes(user?.id ?? 'nope') ? (
<Row className={'gap-2'}>
<EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" />
Unwatch
</Row>
) : (
<Row className={'gap-2'}>
<EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" />
Watch
</Row>
)}
<FollowMarketModal
open={open}
setOpen={setOpen}
title={`You ${
followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched'
} a question!`}
/>
</Button>
)
}

View File

@ -163,13 +163,15 @@ export function OrderBookButton(props: {
const { limitBets, contract, className } = props
const [open, setOpen] = useState(false)
const yesBets = sortBy(
limitBets.filter((bet) => bet.outcome === 'YES'),
const sortedBets = sortBy(
limitBets,
(bet) => -1 * bet.limitProb,
(bet) => bet.createdTime
)
const yesBets = sortedBets.filter((bet) => bet.outcome === 'YES')
const noBets = sortBy(
limitBets.filter((bet) => bet.outcome === 'NO'),
sortedBets.filter((bet) => bet.outcome === 'NO'),
(bet) => bet.limitProb,
(bet) => bet.createdTime
)
@ -202,7 +204,7 @@ export function OrderBookButton(props: {
</Row>
<Col className="md:hidden">
<LimitOrderTable
limitBets={limitBets}
limitBets={sortedBets}
contract={contract}
isYou={false}
/>

View File

@ -9,7 +9,7 @@ import {
import { Transition, Dialog } from '@headlessui/react'
import { useState, Fragment } from 'react'
import Sidebar, { Item } from './sidebar'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar'
import clsx from 'clsx'
@ -17,8 +17,6 @@ import { useRouter } from 'next/router'
import NotificationsIcon from 'web/components/notifications-icon'
import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { PrivateUser } from 'common/user'
function getNavigation() {
return [
@ -44,7 +42,6 @@ export function BottomNavBar() {
const currentPage = router.pathname
const user = useUser()
const privateUser = usePrivateUser()
const isIframe = useIsIframe()
if (isIframe) {
@ -85,11 +82,7 @@ export function BottomNavBar() {
onClick={() => setSidebarOpen(true)}
>
<MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" />
{privateUser ? (
<MoreMenuWithGroupNotifications privateUser={privateUser} />
) : (
'More'
)}
More
</div>
<MobileSidebar
@ -100,22 +93,6 @@ export function BottomNavBar() {
)
}
function MoreMenuWithGroupNotifications(props: { privateUser: PrivateUser }) {
const { privateUser } = props
const preferredNotifications = useUnseenPreferredNotifications(privateUser, {
customHref: '/group/',
})
return (
<span
className={
preferredNotifications.length > 0 ? 'font-bold' : 'font-normal'
}
>
More
</span>
)
}
function NavBarItem(props: { item: Item; currentPage: string }) {
const { item, currentPage } = props
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)

View File

@ -12,22 +12,20 @@ import {
import clsx from 'clsx'
import Link from 'next/link'
import Router, { useRouter } from 'next/router'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { useUser } from 'web/hooks/use-user'
import { firebaseLogout, User } from 'web/lib/firebase/users'
import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon'
import React, { useMemo, useState } from 'react'
import React, { useState } from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button'
import { useMemberGroups } from 'web/hooks/use-group'
import { groupPath } from 'web/lib/firebase/groups'
import { trackCallback, withTracking } from 'web/lib/service/analytics'
import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Group } from 'common/group'
import { Spacer } from '../layout/spacer'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { PrivateUser } from 'common/user'
import { useWindowSize } from 'web/hooks/use-window-size'
import { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array'
@ -58,7 +56,14 @@ function getNavigation() {
function getMoreNavigation(user?: User | null) {
if (IS_PRIVATE_MANIFOLD) {
return [{ name: 'Leaderboards', href: '/leaderboards' }]
return [
{ name: 'Leaderboards', href: '/leaderboards' },
{
name: 'Sign out',
href: '#',
onClick: logout,
},
]
}
if (!user) {
@ -88,7 +93,7 @@ function getMoreNavigation(user?: User | null) {
href: 'https://salemcenter.manifold.markets/',
},
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{ name: 'Help & About', href: 'https://help.manifold.markets/' },
{
name: 'Sign out',
href: '#',
@ -102,16 +107,16 @@ const signedOutNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/home', icon: SearchIcon },
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
name: 'Help & About',
href: 'https://help.manifold.markets/',
icon: BookOpenIcon,
},
]
const signedOutMobileNavigation = [
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
name: 'Help & About',
href: 'https://help.manifold.markets/',
icon: BookOpenIcon,
},
{ name: 'Charity', href: '/charity', icon: HeartIcon },
@ -125,8 +130,8 @@ const signedInMobileNavigation = [
? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
name: 'Help & About',
href: 'https://help.manifold.markets/',
icon: BookOpenIcon,
},
]
@ -221,8 +226,6 @@ export default function Sidebar(props: { className?: string }) {
const currentPage = router.pathname
const user = useUser()
const privateUser = usePrivateUser()
// usePing(user?.id)
const navigationOptions = !user ? signedOutNavigation : getNavigation()
const mobileNavigationOptions = !user
@ -230,11 +233,9 @@ export default function Sidebar(props: { className?: string }) {
: signedInMobileNavigation
const memberItems = (
useMemberGroups(
user?.id,
{ withChatEnabled: true },
{ by: 'mostRecentChatActivityTime' }
) ?? []
useMemberGroups(user?.id, undefined, {
by: 'mostRecentContractAddedTime',
}) ?? []
).map((group: Group) => ({
name: group.name,
href: `${groupPath(group.slug)}`,
@ -268,13 +269,7 @@ export default function Sidebar(props: { className?: string }) {
{memberItems.length > 0 && (
<hr className="!my-4 mr-2 border-gray-300" />
)}
{privateUser && (
<GroupsList
currentPage={router.asPath}
memberItems={memberItems}
privateUser={privateUser}
/>
)}
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
</div>
{/* Desktop navigation */}
@ -289,46 +284,36 @@ export default function Sidebar(props: { className?: string }) {
{/* Spacer if there are any groups */}
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
{privateUser && (
<GroupsList
currentPage={router.asPath}
memberItems={memberItems}
privateUser={privateUser}
/>
)}
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
</div>
</nav>
)
}
function GroupsList(props: {
currentPage: string
memberItems: Item[]
privateUser: PrivateUser
}) {
const { currentPage, memberItems, privateUser } = props
const preferredNotifications = useUnseenPreferredNotifications(
privateUser,
{
customHref: '/group/',
},
memberItems.length > 0 ? memberItems.length : undefined
)
function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
const { currentPage, memberItems } = props
const { height } = useWindowSize()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
const notifIsForThisItem = useMemo(
() => (itemHref: string) =>
preferredNotifications.some(
(n) =>
!n.isSeen &&
(n.isSeenOnHref === itemHref ||
n.isSeenOnHref?.replace('/chat', '') === itemHref)
),
[preferredNotifications]
)
// const preferredNotifications = useUnseenPreferredNotifications(
// privateUser,
// {
// customHref: '/group/',
// },
// memberItems.length > 0 ? memberItems.length : undefined
// )
// const notifIsForThisItem = useMemo(
// () => (itemHref: string) =>
// preferredNotifications.some(
// (n) =>
// !n.isSeen &&
// (n.isSeenOnHref === itemHref ||
// n.isSeenOnHref?.replace('/chat', '') === itemHref)
// ),
// [preferredNotifications]
// )
return (
<>
@ -344,16 +329,12 @@ function GroupsList(props: {
>
{memberItems.map((item) => (
<a
href={
item.href +
(notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '')
}
href={item.href}
key={item.name}
onClick={trackCallback('sidebar: ' + item.name)}
onClick={trackCallback('click sidebar group', { name: item.name })}
className={clsx(
'cursor-pointer truncate',
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900',
notifIsForThisItem(item.href) && 'font-bold'
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900'
)}
>
{item.name}

View File

@ -14,7 +14,7 @@ export const PortfolioValueSection = memo(
}) {
const { disableSelector, userId } = props
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
const [portfolioHistory, setUsersPortfolioHistory] = useState<
PortfolioMetrics[]
>([])
@ -53,13 +53,15 @@ export const PortfolioValueSection = memo(
{!disableSelector && (
<select
className="select select-bordered self-start"
value={portfolioPeriod}
onChange={(e) => {
setPortfolioPeriod(e.target.value as Period)
}}
>
<option value="allTime">{allTimeLabel}</option>
<option value="weekly">7 days</option>
<option value="daily">24 hours</option>
<option value="weekly">Last 7d</option>
{/* Note: 'daily' seems to be broken? */}
{/* <option value="daily">Last 24h</option> */}
</select>
)}
</Row>

View File

@ -3,7 +3,7 @@ import { Col } from 'web/components/layout/col'
import {
BETTING_STREAK_BONUS_AMOUNT,
BETTING_STREAK_BONUS_MAX,
} from 'common/numeric-constants'
} from 'common/economy'
import { formatMoney } from 'common/util/format'
export function BettingStreakModal(props: {
@ -16,7 +16,7 @@ export function BettingStreakModal(props: {
<Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🔥</span>
<span>Daily betting streaks</span>
<span className="text-xl">Daily betting streaks</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are they?</span>
<span className={'ml-2'}>

View File

@ -0,0 +1,48 @@
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
export function LoansModal(props: {
isOpen: boolean
setOpen: (open: boolean) => void
}) {
const { isOpen, setOpen } = props
return (
<Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🏦</span>
<span className="text-xl">Daily loans on your bets</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are daily loans?</span>
<span className={'ml-2'}>
Every day at midnight PT, get 1% of your total bet amount back as a
loan.
</span>
<span className={'text-indigo-700'}>
Do I have to pay back a loan?
</span>
<span className={'ml-2'}>
Yes, don't worry! You will automatically pay back loans when the
market resolves or you sell your bet.
</span>
<span className={'text-indigo-700'}>
What is the purpose of loans?
</span>
<span className={'ml-2'}>
Loans make it worthwhile to bet on markets that won't resolve for
months or years, because your investment won't be locked up as long.
</span>
<span className={'text-indigo-700'}> What is an example?</span>
<span className={'ml-2'}>
For example, if you bet M$1000 on "Will I become a millionare?" on
Monday, you will get M$10 back on Tuesday.
</span>
<span className={'ml-2'}>
Previous loans count against your total bet amount. So on Wednesday,
you would get back 1% of M$990 = M$9.9.
</span>
</Col>
</Col>
</Modal>
)
}

View File

@ -8,10 +8,8 @@ import { Spacer } from './layout/spacer'
import { ResolveConfirmationButton } from './confirmation-button'
import { APIError, resolveMarket } from 'web/lib/firebase/api'
import { ProbabilitySelector } from './probability-selector'
import { DPM_CREATOR_FEE } from 'common/fees'
import { getProbability } from 'common/calculate'
import { BinaryContract, resolution } from 'common/contract'
import { formatMoney } from 'common/util/format'
export function ResolutionPanel(props: {
creator: User
@ -20,10 +18,10 @@ export function ResolutionPanel(props: {
}) {
const { contract, className } = props
const earnedFees =
contract.mechanism === 'dpm-2'
? `${DPM_CREATOR_FEE * 100}% of trader profits`
: `${formatMoney(contract.collectedFees.creatorFee)} in fees`
// const earnedFees =
// contract.mechanism === 'dpm-2'
// ? `${DPM_CREATOR_FEE * 100}% of trader profits`
// : `${formatMoney(contract.collectedFees.creatorFee)} in fees`
const [outcome, setOutcome] = useState<resolution | undefined>()
@ -86,16 +84,16 @@ export function ResolutionPanel(props: {
{outcome === 'YES' ? (
<>
Winnings will be paid out to YES bettors.
{/* <br />
<br />
<br />
You will earn {earnedFees}.
You will earn {earnedFees}. */}
</>
) : outcome === 'NO' ? (
<>
Winnings will be paid out to NO bettors.
{/* <br />
<br />
<br />
You will earn {earnedFees}.
You will earn {earnedFees}. */}
</>
) : outcome === 'CANCEL' ? (
<>All trades will be returned with no fees.</>
@ -106,7 +104,7 @@ export function ResolutionPanel(props: {
probabilityInt={Math.round(prob)}
setProbabilityInt={setProb}
/>
You will earn {earnedFees}.
{/* You will earn {earnedFees}. */}
</Col>
) : (
<>Resolving this market will immediately pay out traders.</>

View File

@ -29,6 +29,8 @@ import { formatMoney } from 'common/util/format'
import { ShareIconButton } from 'web/components/share-icon-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
import { REFERRAL_AMOUNT } from 'common/economy'
import { LoansModal } from './profile/loans-modal'
export function UserLink(props: {
name: string
@ -67,6 +69,7 @@ export function UserPage(props: { user: User }) {
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const [showConfetti, setShowConfetti] = useState(false)
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
const [showLoansModal, setShowLoansModal] = useState(false)
useEffect(() => {
const claimedMana = router.query['claimed-mana'] === 'yes'
@ -74,6 +77,9 @@ export function UserPage(props: { user: User }) {
setShowBettingStreakModal(showBettingStreak)
setShowConfetti(claimedMana || showBettingStreak)
const showLoansModel = router.query['show'] === 'loans'
setShowLoansModal(showLoansModel)
const query = { ...router.query }
if (query.claimedMana || query.show) {
delete query['claimed-mana']
@ -106,6 +112,9 @@ export function UserPage(props: { user: User }) {
isOpen={showBettingStreakModal}
setOpen={setShowBettingStreakModal}
/>
{showLoansModal && (
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
)}
{/* Banner image up top, with an circle avatar overlaid */}
<div
className="h-32 w-full bg-cover bg-center sm:h-40"
@ -127,7 +136,7 @@ export function UserPage(props: { user: User }) {
<div className="absolute right-0 top-0 mt-2 mr-4">
{!isCurrentUser && <UserFollowButton userId={user.id} />}
{isCurrentUser && (
<SiteLink className="sm:btn-md btn-sm btn" href="/profile">
<SiteLink className="btn-sm btn" href="/profile">
<PencilIcon className="h-5 w-5" />{' '}
<div className="ml-2">Edit</div>
</SiteLink>
@ -137,9 +146,14 @@ export function UserPage(props: { user: User }) {
{/* Profile details: name, username, bio, and link to twitter/discord */}
<Col className="mx-4 -mt-6">
<Row className={'justify-between'}>
<Row className={'flex-wrap justify-between gap-y-2'}>
<Col>
<span className="text-2xl font-bold">{user.name}</span>
<span
className="text-2xl font-bold"
style={{ wordBreak: 'break-word' }}
>
{user.name}
</span>
<span className="text-gray-500">@{user.username}</span>
</Col>
<Col className={'justify-center'}>
@ -159,9 +173,20 @@ export function UserPage(props: { user: User }) {
className={'cursor-pointer items-center text-gray-500'}
onClick={() => setShowBettingStreakModal(true)}
>
<span>🔥{user.currentBettingStreak ?? 0}</span>
<span>🔥 {user.currentBettingStreak ?? 0}</span>
<span>streak</span>
</Col>
<Col
className={
'flex-shrink-0 cursor-pointer items-center text-gray-500'
}
onClick={() => setShowLoansModal(true)}
>
<span className="text-green-600">
🏦 {formatMoney(user.nextLoanCached ?? 0)}
</span>
<span>next loan</span>
</Col>
</Row>
</Col>
</Row>
@ -226,7 +251,7 @@ export function UserPage(props: { user: User }) {
)}
</Row>
<Spacer h={5} />
{currentUser?.id === user.id && (
{currentUser?.id === user.id && REFERRAL_AMOUNT > 0 && (
<Row
className={
'w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
@ -234,7 +259,7 @@ export function UserPage(props: { user: User }) {
>
<span>
<SiteLink href="/referrals">
Earn {formatMoney(500)} when you refer a friend!
Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend!
</SiteLink>{' '}
You have <ReferralsButton user={user} currentUser={currentUser} />
</span>
@ -278,10 +303,7 @@ export function UserPage(props: { user: User }) {
>
<FollowingButton user={user} />
<FollowersButton user={user} />
{currentUser &&
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
currentUser.username
) && <ReferralsButton user={user} />}
<ReferralsButton user={user} />
<GroupsButton user={user} />
</Row>
),

View File

@ -5,3 +5,7 @@ export const useAdmin = () => {
const privateUser = usePrivateUser()
return isAdmin(privateUser?.email || '')
}
export const useDev = () => {
return process.env.NODE_ENV === 'development'
}

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts'
export const useFollows = (userId: string | null | undefined) => {
const [followIds, setFollowIds] = useState<string[] | undefined>()
@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => {
return followerIds
}
export const useContractFollows = (contractId: string) => {
const [followIds, setFollowIds] = useState<string[] | undefined>()
useEffect(() => {
return listenForContractFollows(contractId, setFollowIds)
}, [contractId])
return followIds
}

View File

@ -5,7 +5,7 @@ import {
getNotificationsQuery,
listenForNotifications,
} from 'web/lib/firebase/notifications'
import { groupBy, map } from 'lodash'
import { groupBy, map, partition } from 'lodash'
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
@ -67,19 +67,14 @@ export function groupNotifications(notifications: Notification[]) {
const notificationGroupsByDay = groupBy(notifications, (notification) =>
new Date(notification.createdTime).toDateString()
)
const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus']
Object.keys(notificationGroupsByDay).forEach((day) => {
const notificationsGroupedByDay = notificationGroupsByDay[day]
const incomeNotifications = notificationsGroupedByDay.filter(
const [incomeNotifications, normalNotificationsGroupedByDay] = partition(
notificationsGroupedByDay,
(notification) =>
notification.sourceType === 'bonus' ||
notification.sourceType === 'tip' ||
notification.sourceType === 'betting_streak_bonus'
)
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
(notification) =>
notification.sourceType !== 'bonus' &&
notification.sourceType !== 'tip' &&
notification.sourceType !== 'betting_streak_bonus'
incomeSourceTypes.includes(notification.sourceType ?? '')
)
if (incomeNotifications.length > 0) {
notificationGroups = notificationGroups.concat({
@ -152,6 +147,7 @@ export function useUnseenPreferredNotifications(
const lessPriorityReasons = [
'on_contract_with_users_comment',
'on_contract_with_users_answer',
// Notifications not currently generated for users who've sold their shares
'on_contract_with_users_shares_out',
// Not sure if users will want to see these w/ less:
// 'on_contract_with_users_shares_in',

View File

@ -125,9 +125,10 @@ export async function listTaggedContractsCaseInsensitive(
export async function listAllContracts(
n: number,
before?: string
before?: string,
sortDescBy = 'createdTime'
): Promise<Contract[]> {
let q = query(contracts, orderBy('createdTime', 'desc'), limit(n))
let q = query(contracts, orderBy(sortDescBy, 'desc'), limit(n))
if (before != null) {
const snap = await getDoc(doc(contracts, before))
q = query(q, startAfter(snap))
@ -211,6 +212,29 @@ export function listenForContract(
return listenForValue<Contract>(contractRef, setContract)
}
export function listenForContractFollows(
contractId: string,
setFollowIds: (followIds: string[]) => void
) {
const follows = collection(contracts, contractId, 'follows')
return listenForValues<{ id: string }>(follows, (docs) =>
setFollowIds(docs.map(({ id }) => id))
)
}
export async function followContract(contractId: string, userId: string) {
const followDoc = doc(collection(contracts, contractId, 'follows'), userId)
return await setDoc(followDoc, {
id: userId,
createdTime: Date.now(),
})
}
export async function unFollowContract(contractId: string, userId: string) {
const followDoc = doc(collection(contracts, contractId, 'follows'), userId)
await deleteDoc(followDoc)
}
function chooseRandomSubset(contracts: Contract[], count: number) {
const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString()
@ -271,6 +295,26 @@ export async function getClosingSoonContracts() {
return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime)
}
export const getRandTopCreatorContracts = async (
creatorId: string,
count: number,
excluding: string[] = []
) => {
const creatorContractsQuery = query(
contracts,
where('isResolved', '==', false),
where('creatorId', '==', creatorId),
orderBy('popularityScore', 'desc'),
limit(Math.max(count * 2, 15))
)
const data = await getValues<Contract>(creatorContractsQuery)
const open = data
.filter((c) => c.closeTime && c.closeTime > Date.now())
.filter((c) => !excluding.includes(c.id))
return chooseRandomSubset(open, count)
}
export async function getRecentBetsAndComments(contract: Contract) {
const contractDoc = doc(contracts, contract.id)

View File

@ -1,15 +0,0 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.SITE_URL || 'https://manifold.markets',
changefreq: 'hourly',
priority: 0.7, // Set high priority by default
exclude: ['/admin', '/server-sitemap.xml'],
generateRobotsTxt: true,
robotsTxtOptions: {
additionalSitemaps: [
'https://manifold.markets/server-sitemap.xml', // <==== Add here
],
},
// Other options: https://github.com/iamvishnusankar/next-sitemap#configuration-options
}

View File

@ -15,7 +15,6 @@
"start": "next start",
"lint": "next lint",
"format": "npx prettier --write .",
"postbuild": "next-sitemap",
"verify": "(cd .. && yarn verify)",
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
},

View File

@ -1,17 +1,17 @@
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useState } from 'react'
import { ArrowLeftIcon } from '@heroicons/react/outline'
import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { ContractOverview } from 'web/components/contract/contract-overview'
import { BetPanel } from 'web/components/bet-panel'
import { Col } from 'web/components/layout/col'
import { useUser, useUserById } from 'web/hooks/use-user'
import { useUser } from 'web/hooks/use-user'
import { ResolutionPanel } from 'web/components/resolution-panel'
import { Spacer } from 'web/components/layout/spacer'
import {
Contract,
getContractFromSlug,
getRandTopCreatorContracts,
tradingAllowed,
} from 'web/lib/firebase/contracts'
import { SEO } from 'web/components/SEO'
@ -21,9 +21,6 @@ import { listAllComments } from 'web/lib/firebase/comments'
import Custom404 from '../404'
import { AnswersPanel } from 'web/components/answers/answers-panel'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Leaderboard } from 'web/components/leaderboard'
import { resolvedPayout } from 'common/calculate'
import { formatMoney } from 'common/util/format'
import { ContractTabs } from 'web/components/contract/contract-tabs'
import { FullscreenConfetti } from 'web/components/fullscreen-confetti'
import { NumericBetPanel } from 'web/components/numeric-bet-panel'
@ -34,15 +31,17 @@ import { useBets } from 'web/hooks/use-bets'
import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { User } from 'common/user'
import { ContractComment } from 'common/comment'
import { listUsers } from 'web/lib/firebase/users'
import { FeedComment } from 'web/components/feed/feed-comments'
import { Title } from 'web/components/title'
import { FeedBet } from 'web/components/feed/feed-bets'
import { getOpenGraphProps } from 'common/contract-details'
import {
ContractLeaderboard,
ContractTopTrades,
} from 'web/components/contract/contract-leaderboard'
import { Subtitle } from 'web/components/subtitle'
import { ContractsGrid } from 'web/components/contract/contracts-grid'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -52,9 +51,12 @@ export async function getStaticPropz(props: {
const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id
const [bets, comments] = await Promise.all([
const [bets, comments, recommendedContracts] = await Promise.all([
contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [],
contract
? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id])
: [],
])
return {
@ -65,6 +67,7 @@ export async function getStaticPropz(props: {
// Limit the data sent to the client. Client will still load all bets and comments directly.
bets: bets.slice(0, 5000),
comments: comments.slice(0, 1000),
recommendedContracts,
},
revalidate: 60, // regenerate after a minute
@ -81,6 +84,7 @@ export default function ContractPage(props: {
bets: Bet[]
comments: ContractComment[]
slug: string
recommendedContracts: Contract[]
backToHome?: () => void
}) {
props = usePropz(props, getStaticPropz) ?? {
@ -88,6 +92,7 @@ export default function ContractPage(props: {
username: '',
comments: [],
bets: [],
recommendedContracts: [],
slug: '',
}
@ -149,7 +154,7 @@ export function ContractPageContent(
user?: User | null
}
) {
const { backToHome, comments, user } = props
const { backToHome, comments, user, recommendedContracts } = props
const contract = useContractWithPreload(props.contract) ?? props.contract
@ -263,128 +268,13 @@ export function ContractPageContent(
comments={comments}
/>
</Col>
{recommendedContracts.length > 0 && (
<Col className="gap-2 px-2 sm:px-0">
<Subtitle text="Recommended" />
<ContractsGrid contracts={recommendedContracts} />
</Col>
)}
</Page>
)
}
function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const [users, setUsers] = useState<User[]>()
const { userProfits, top5Ids } = useMemo(() => {
// Create a map of userIds to total profits (including sales)
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
const betsByUser = groupBy(openBets, 'userId')
const userProfits = mapValues(betsByUser, (bets) =>
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
)
// Find the 5 users with the most profits
const top5Ids = Object.entries(userProfits)
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
.filter(([, p]) => p > 0)
.slice(0, 5)
.map(([id]) => id)
return { userProfits, top5Ids }
}, [contract, bets])
useEffect(() => {
if (top5Ids.length > 0) {
listUsers(top5Ids).then((users) => {
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
setUsers(sortedUsers)
})
}
}, [userProfits, top5Ids])
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top bettors"
users={users || []}
columns={[
{
header: 'Total profit',
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
},
]}
className="mt-12 max-w-sm"
/>
) : null
}
function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, bets, comments, tips } = props
const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
// Otherwise, we record the profit at resolution time
const profitById: Record<string, number> = {}
for (const bet of bets) {
if (bet.sale) {
const originalBet = betsById[bet.sale.betId]
const profit = bet.sale.amount - originalBet.amount
profitById[bet.id] = profit
profitById[originalBet.id] = profit
} else {
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
}
}
// Now find the betId with the highest profit
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = useUserById(betsById[topBetId]?.userId)
// And also the commentId of the comment with the highest profit
const topCommentId = sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
return (
<div className="mt-12 max-w-sm">
{topCommentId && profitById[topCommentId] > 0 && (
<>
<Title text="💬 Proven correct" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedComment
contract={contract}
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{commentsById[topCommentId].userName} made{' '}
{formatMoney(profitById[topCommentId] || 0)}!
</div>
<Spacer h={16} />
</>
)}
{/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
<>
<Title text="💸 Smartest money" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet
contract={contract}
bet={betsById[topBetId]}
hideOutcome={false}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
</div>
</>
)}
</div>
)
}

View File

@ -10,6 +10,7 @@ import { mapKeys } from 'lodash'
import { useAdmin } from 'web/hooks/use-admin'
import { contractPath } from 'web/lib/firebase/contracts'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { firestoreConsolePath } from 'common/envs/constants'
export const getServerSideProps = redirectIfLoggedOut('/')
@ -198,7 +199,7 @@ function ContractsTable() {
html(`<a
class="hover:underline hover:decoration-indigo-400 hover:decoration-2"
target="_blank"
href="https://console.firebase.google.com/project/mantic-markets/firestore/data/~2Fcontracts~2F${cell}">${cell}</a>`),
href="${firestoreConsolePath(cell as string)}">${cell}</a>`),
},
]}
search={true}

View File

@ -22,8 +22,6 @@ export type LiteMarket = {
// Market attributes. All times are in milliseconds since epoch
closeTime?: number
question: string
description: string | JSONContent
textDescription: string // string version of description
tags: string[]
url: string
outcomeType: string
@ -54,6 +52,8 @@ export type FullMarket = LiteMarket & {
bets: Bet[]
comments: Comment[]
answers?: ApiAnswer[]
description: string | JSONContent
textDescription: string // string version of description
}
export type ApiError = {
@ -81,7 +81,6 @@ export function toLiteMarket(contract: Contract): LiteMarket {
creatorAvatarUrl,
closeTime,
question,
description,
tags,
slug,
pool,
@ -118,11 +117,6 @@ export function toLiteMarket(contract: Contract): LiteMarket {
? Math.min(resolutionTime, closeTime)
: closeTime,
question,
description,
textDescription:
typeof description === 'string'
? description
: richTextToString(description),
tags,
url: `https://manifold.markets/${creatorUsername}/${slug}`,
pool,
@ -158,11 +152,18 @@ export function toFullMarket(
)
: undefined
const { description } = contract
return {
...liteMarket,
answers,
comments,
bets,
description,
textDescription:
typeof description === 'string'
? description
: richTextToString(description),
}
}

View File

@ -10,7 +10,7 @@ const queryParams = z
.object({
limit: z
.number()
.default(1000)
.default(500)
.or(z.string().regex(/\d+/).transform(Number))
.refine((n) => n >= 0 && n <= 1000, 'Limit must be between 0 and 1000'),
before: z.string().optional(),

View File

@ -39,8 +39,8 @@ export async function getStaticProps() {
])
const matches = quadraticMatches(txns, totalRaised)
const numDonors = uniqBy(txns, (txn) => txn.fromId).length
const mostRecentDonor = await getUser(txns[0].fromId)
const mostRecentCharity = txns[0].toId
const mostRecentDonor = txns[0] ? await getUser(txns[0].fromId) : null
const mostRecentCharity = txns[0]?.toId ?? ''
return {
props: {
@ -94,8 +94,8 @@ export default function Charity(props: {
matches: { [charityId: string]: number }
txns: Txn[]
numDonors: number
mostRecentDonor: User
mostRecentCharity: string
mostRecentDonor?: User | null
mostRecentCharity?: string
}) {
const {
totalRaised,
@ -159,8 +159,8 @@ export default function Charity(props: {
},
{
name: 'Most recent donor',
stat: mostRecentDonor.name ?? 'Nobody',
url: `/${mostRecentDonor.username}`,
stat: mostRecentDonor?.name ?? 'Nobody',
url: `/${mostRecentDonor?.username}`,
},
{
name: 'Most recent donation',

View File

@ -7,7 +7,7 @@ import { Spacer } from 'web/components/layout/spacer'
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { Contract, contractPath } from 'web/lib/firebase/contracts'
import { createMarket } from 'web/lib/firebase/api'
import { FIXED_ANTE } from 'common/antes'
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from 'common/economy'
import { InfoTooltip } from 'web/components/info-tooltip'
import { Page } from 'web/components/page'
import { Row } from 'web/components/layout/row'
@ -158,6 +158,8 @@ export function NewContract(props: {
: undefined
const balance = creator.balance || 0
const deservesFreeMarket =
(creator.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
const min = minString ? parseFloat(minString) : undefined
const max = maxString ? parseFloat(maxString) : undefined
@ -177,7 +179,7 @@ export function NewContract(props: {
question.length > 0 &&
ante !== undefined &&
ante !== null &&
ante <= balance &&
(ante <= balance || deservesFreeMarket) &&
// closeTime must be in the future
closeTime &&
closeTime > Date.now() &&
@ -207,6 +209,7 @@ export function NewContract(props: {
max: MAX_DESCRIPTION_LENGTH,
placeholder: descriptionPlaceholder,
disabled: isSubmitting,
defaultValue: JSON.parse(params?.description ?? '{}'),
})
const isEditorFilled = editor != null && !editor.isEmpty
@ -460,12 +463,25 @@ export function NewContract(props: {
text={`Cost to create your question. This amount is used to subsidize betting.`}
/>
</label>
{!deservesFreeMarket ? (
<div className="label-text text-neutral pl-1">
{formatMoney(ante)}
</div>
) : (
<div>
<div className="label-text text-primary pl-1">
FREE{' '}
<span className="label-text pl-1 text-gray-500">
(You have{' '}
{FREE_MARKETS_PER_USER_MAX -
(creator?.freeMarketsCreated ?? 0)}{' '}
free markets left)
</span>
</div>
</div>
)}
<div className="label-text text-neutral pl-1">
{formatMoney(ante)}
</div>
{ante > balance && (
{ante > balance && !deservesFreeMarket && (
<div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide">
<span className="mr-2 text-red-500">Insufficient balance</span>
<button

View File

@ -109,6 +109,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
contract={contract}
bets={bets}
isCreator={false}
user={null}
disabled
/>

View File

@ -0,0 +1,118 @@
import React from 'react'
import { useRouter } from 'next/router'
import { PlusSmIcon } from '@heroicons/react/solid'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { ContractSearch } from 'web/components/contract-search'
import { User } from 'common/user'
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { GetServerSideProps } from 'next'
import { Sort } from 'web/hooks/use-sort-and-query-params'
import { Button } from 'web/components/button'
import { Spacer } from 'web/components/layout/spacer'
import { useMemberGroups } from 'web/hooks/use-group'
import { Group } from 'common/group'
import { Title } from 'web/components/title'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx)
const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null
return { props: { auth } }
}
const Home = (props: { auth: { user: User } | null }) => {
const user = props.auth ? props.auth.user : null
const router = useRouter()
useTracking('view home')
useSaveReferral()
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
(group) => group.contractIds.length > 0
)
return (
<Page>
<Col className="mx-auto mb-8 w-full">
<SearchSection label="Trending" sort="score" user={user} />
<SearchSection label="Newest" sort="newest" user={user} />
<SearchSection label="Closing soon" sort="close-date" user={user} />
{memberGroups.map((group) => (
<GroupSection key={group.id} group={group} user={user} />
))}
</Col>
<button
type="button"
className="fixed bottom-[70px] right-3 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden"
onClick={() => {
router.push('/create')
track('mobile create button')
}}
>
<PlusSmIcon className="h-8 w-8" aria-hidden="true" />
</button>
</Page>
)
}
function SearchSection(props: {
label: string
user: User | null
sort: Sort
}) {
const { label, user, sort } = props
const router = useRouter()
return (
<Col>
<Title className="mx-2 !text-gray-800 sm:mx-0" text={label} />
<Spacer h={2} />
<ContractSearch user={user} defaultSort={sort} maxItems={4} noControls />
<Button
className="self-end"
color="blue"
size="sm"
onClick={() => router.push(`/home?s=${sort}`)}
>
See more
</Button>
</Col>
)
}
function GroupSection(props: { group: Group; user: User | null }) {
const { group, user } = props
const router = useRouter()
return (
<Col className="">
<Title className="mx-2 !text-gray-800 sm:mx-0" text={group.name} />
<Spacer h={2} />
<ContractSearch
user={user}
defaultSort={'score'}
additionalFilter={{ groupSlug: group.slug }}
maxItems={4}
noControls
/>
<Button
className="mr-2 self-end"
color="blue"
size="sm"
onClick={() => router.push(`/group/${group.slug}`)}
>
See more
</Button>
</Col>
)
}
export default Home

View File

@ -33,12 +33,9 @@ import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast'
import { useCommentsOnGroup } from 'web/hooks/use-comments'
import { REFERRAL_AMOUNT } from 'common/user'
import { ContractSearch } from 'web/components/contract-search'
import { FollowList } from 'web/components/follow-list'
import { SearchIcon } from '@heroicons/react/outline'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse'
import { CopyLinkButton } from 'web/components/copy-link-button'
@ -47,7 +44,7 @@ import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Button } from 'web/components/button'
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
import { GroupComment } from 'common/comment'
import { GroupChat } from 'web/components/groups/group-chat'
import { REFERRAL_AMOUNT } from 'common/economy'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -149,9 +146,6 @@ export default function GroupPage(props: {
const page = slugs?.[1] as typeof groupSubpages[number]
const group = useGroup(props.group?.id) ?? props.group
const tips = useTipTxns({ groupId: group?.id })
const messages = useCommentsOnGroup(group?.id) ?? props.messages
const user = useUser()
@ -201,21 +195,12 @@ export default function GroupPage(props: {
/>
)
const chatTab = (
<GroupChat messages={messages} group={group} user={user} tips={tips} />
)
const tabs = [
{
title: 'Markets',
content: questionsTab,
href: groupPath(group.slug, 'markets'),
},
{
title: 'Chat',
content: chatTab,
href: groupPath(group.slug, 'chat'),
},
{
title: 'Leaderboards',
content: leaderboard,

View File

@ -71,6 +71,7 @@ const Home = (props: { auth: { user: User } | null }) => {
backToHome={() => {
history.back()
}}
recommendedContracts={[]}
/>
)}
</>

View File

@ -25,8 +25,8 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { ManalinkCardFromView } from 'web/components/manalink-card'
import { Pagination } from 'web/components/pagination'
import { Manalink } from 'common/manalink'
import { REFERRAL_AMOUNT } from 'common/user'
import { SiteLink } from 'web/components/site-link'
import { REFERRAL_AMOUNT } from 'common/economy'
const LINKS_PER_PAGE = 24

View File

@ -34,7 +34,7 @@ import { groupPath } from 'web/lib/firebase/groups'
import {
BETTING_STREAK_BONUS_AMOUNT,
UNIQUE_BETTOR_BONUS_AMOUNT,
} from 'common/numeric-constants'
} from 'common/economy'
import { groupBy, sum, uniq } from 'lodash'
import { track } from '@amplitude/analytics-browser'
import { Pagination } from 'web/components/pagination'
@ -44,6 +44,7 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/NotificationSettings'
import { SEO } from 'web/components/SEO'
import { useUser } from 'web/hooks/use-user'
export const NOTIFICATIONS_PER_PAGE = 30
const MULTIPLE_USERS_KEY = 'multipleUsers'
@ -165,7 +166,7 @@ function NotificationsList(props: {
if (!paginatedGroupedNotifications || !allGroupedNotifications) return <div />
return (
<div className={'min-h-[100vh]'}>
<div className={'min-h-[100vh] text-sm'}>
{paginatedGroupedNotifications.length === 0 && (
<div className={'mt-2'}>
You don't have any notifications. Try changing your settings to see
@ -271,9 +272,17 @@ function IncomeNotificationGroupItem(props: {
}
return newNotifications
}
const combinedNotifs =
combineNotificationsByAddingNumericSourceTexts(notifications)
const combinedNotifs = combineNotificationsByAddingNumericSourceTexts(
notifications.filter((n) => n.sourceType !== 'betting_streak_bonus')
)
// Because the server's reset time will never align with the client's, we may
// erroneously sum 2 betting streak bonuses, therefore just show the most recent
const mostRecentBettingStreakBonus = notifications
.filter((n) => n.sourceType === 'betting_streak_bonus')
.sort((a, b) => a.createdTime - b.createdTime)
.pop()
if (mostRecentBettingStreakBonus)
combinedNotifs.unshift(mostRecentBettingStreakBonus)
return (
<div
@ -370,6 +379,8 @@ function IncomeNotificationItem(props: {
const [highlighted] = useState(!notification.isSeen)
const { width } = useWindowSize()
const isMobile = (width && width < 768) || false
const user = useUser()
useEffect(() => {
setNotificationsAsSeen([notification])
}, [notification])
@ -388,20 +399,30 @@ function IncomeNotificationItem(props: {
reasonText = !simple ? `tipped you on` : `in tips on`
} else if (sourceType === 'betting_streak_bonus') {
reasonText = 'for your'
} else if (sourceType === 'loan' && sourceText) {
reasonText = `of your invested bets returned as a`
}
const streakInDays =
Date.now() - notification.createdTime > 24 * 60 * 60 * 1000
? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT
: user?.currentBettingStreak ?? 0
const bettingStreakText =
sourceType === 'betting_streak_bonus' &&
(sourceText
? `🔥 ${
parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT
} day Betting Streak`
: 'Betting Streak')
(sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak')
return (
<>
{reasonText}
{sourceType === 'betting_streak_bonus' ? (
{sourceType === 'loan' ? (
simple ? (
<span className={'ml-1 font-bold'}>🏦 Loan</span>
) : (
<SiteLink className={'ml-1 font-bold'} href={'/loans'}>
🏦 Loan
</SiteLink>
)
) : sourceType === 'betting_streak_bonus' ? (
simple ? (
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
) : (
@ -445,6 +466,7 @@ function IncomeNotificationItem(props: {
if (sourceType === 'challenge') return `${sourceSlug}`
if (sourceType === 'betting_streak_bonus')
return `/${sourceUserUsername}/?show=betting-streak`
if (sourceType === 'loan') return `/${sourceUserUsername}/?show=loans`
if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? '',

View File

@ -5,11 +5,11 @@ import { useUser } from 'web/hooks/use-user'
import { Page } from 'web/components/page'
import { useTracking } from 'web/hooks/use-tracking'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { REFERRAL_AMOUNT } from 'common/user'
import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { InfoBox } from 'web/components/info-box'
import { QRCode } from 'web/components/qr-code'
import { REFERRAL_AMOUNT } from 'common/economy'
export const getServerSideProps = redirectIfLoggedOut('/')

View File

@ -1,24 +1,21 @@
import { sortBy } from 'lodash'
import { GetServerSideProps } from 'next'
import { getServerSideSitemap, ISitemapField } from 'next-sitemap'
import { DOMAIN } from 'common/envs/constants'
import { LiteMarket } from './api/v0/_types'
import { listAllContracts } from 'web/lib/firebase/contracts'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
// Fetching data from https://manifold.markets/api
const response = await fetch(`https://${DOMAIN}/api/v0/markets`)
const contracts = await listAllContracts(1000, undefined, 'popularityScore')
const liteMarkets = (await response.json()) as LiteMarket[]
const sortedMarkets = sortBy(liteMarkets, (m) => -m.volume24Hours)
const score = (popularity: number) => Math.tanh(Math.log10(popularity + 1))
const fields = sortedMarkets.map((market) => ({
// See https://www.sitemaps.org/protocol.html
loc: market.url,
changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily',
priority: market.volume24Hours + market.volume7Days > 100 ? 0.7 : 0.1,
// TODO: Add `lastmod` aka last modified time
})) as ISitemapField[]
const fields = contracts
.sort((x) => x.popularityScore ?? 0)
.map((market) => ({
loc: `https://manifold.markets/${market.creatorUsername}/${market.slug}`,
changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily',
priority: score(market.popularityScore ?? 0),
lastmod: market.lastUpdatedTime,
})) as ISitemapField[]
return await getServerSideSitemap(ctx, fields)
}

View File

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

View File

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