Merge branch 'manifoldmarkets:main' into main
This commit is contained in:
commit
127e6930f9
|
@ -11,11 +11,8 @@ import {
|
||||||
import { User } from './user'
|
import { User } from './user'
|
||||||
import { LiquidityProvision } from './liquidity-provision'
|
import { LiquidityProvision } from './liquidity-provision'
|
||||||
import { noFees } from './fees'
|
import { noFees } from './fees'
|
||||||
import { ENV_CONFIG } from './envs/constants'
|
|
||||||
import { Answer } from './answer'
|
import { Answer } from './answer'
|
||||||
|
|
||||||
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
|
|
||||||
|
|
||||||
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
|
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
|
||||||
export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id
|
export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id
|
||||||
|
|
||||||
|
|
|
@ -61,5 +61,3 @@ export type fill = {
|
||||||
// I.e. -fill.shares === matchedBet.shares
|
// I.e. -fill.shares === matchedBet.shares
|
||||||
isSale?: boolean
|
isSale?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_LOAN_PER_CONTRACT = 20
|
|
||||||
|
|
|
@ -57,6 +57,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
uniqueBettorIds?: string[]
|
uniqueBettorIds?: string[]
|
||||||
uniqueBettorCount?: number
|
uniqueBettorCount?: number
|
||||||
popularityScore?: number
|
popularityScore?: number
|
||||||
|
followerCount?: number
|
||||||
|
featuredOnHomeRank?: number
|
||||||
} & T
|
} & T
|
||||||
|
|
||||||
export type BinaryContract = Contract & Binary
|
export type BinaryContract = Contract & Binary
|
||||||
|
|
17
common/economy.ts
Normal file
17
common/economy.ts
Normal 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
|
|
@ -44,3 +44,7 @@ export const CORS_ORIGIN_VERCEL = new RegExp(
|
||||||
)
|
)
|
||||||
// Any localhost server on any port
|
// Any localhost server on any port
|
||||||
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/
|
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}`
|
||||||
|
}
|
||||||
|
|
|
@ -19,10 +19,23 @@ export type EnvConfig = {
|
||||||
navbarLogoPath?: string
|
navbarLogoPath?: string
|
||||||
newQuestionPlaceholders: string[]
|
newQuestionPlaceholders: string[]
|
||||||
|
|
||||||
// Currency controls
|
economy?: Economy
|
||||||
fixedAnte?: number
|
}
|
||||||
startingBalance?: number
|
|
||||||
referralBonus?: number
|
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 = {
|
type FirebaseConfig = {
|
||||||
|
@ -58,6 +71,8 @@ export const PROD_CONFIG: EnvConfig = {
|
||||||
'taowell@gmail.com', // Stephen
|
'taowell@gmail.com', // Stephen
|
||||||
'abc.sinclair@gmail.com', // Sinclair
|
'abc.sinclair@gmail.com', // Sinclair
|
||||||
'manticmarkets@gmail.com', // Manifold
|
'manticmarkets@gmail.com', // Manifold
|
||||||
|
'iansphilips@gmail.com', // Ian
|
||||||
|
'd4vidchee@gmail.com', // D4vid
|
||||||
],
|
],
|
||||||
visibility: 'PUBLIC',
|
visibility: 'PUBLIC',
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
export const PLATFORM_FEE = 0
|
export const PLATFORM_FEE = 0
|
||||||
export const CREATOR_FEE = 0.1
|
export const CREATOR_FEE = 0
|
||||||
export const LIQUIDITY_FEE = 0
|
export const LIQUIDITY_FEE = 0
|
||||||
|
|
||||||
export const DPM_PLATFORM_FEE = 0.01
|
export const DPM_PLATFORM_FEE = 0.0
|
||||||
export const DPM_CREATOR_FEE = 0.04
|
export const DPM_CREATOR_FEE = 0.0
|
||||||
export const DPM_FEES = DPM_PLATFORM_FEE + DPM_CREATOR_FEE
|
export const DPM_FEES = DPM_PLATFORM_FEE + DPM_CREATOR_FEE
|
||||||
|
|
||||||
export type Fees = {
|
export type Fees = {
|
||||||
|
|
138
common/loans.ts
Normal file
138
common/loans.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import { sortBy, sum, sumBy } from 'lodash'
|
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 {
|
import {
|
||||||
calculateDpmShares,
|
calculateDpmShares,
|
||||||
getDpmProbability,
|
getDpmProbability,
|
||||||
|
@ -276,8 +276,7 @@ export const getBinaryBetStats = (
|
||||||
export const getNewBinaryDpmBetInfo = (
|
export const getNewBinaryDpmBetInfo = (
|
||||||
outcome: 'YES' | 'NO',
|
outcome: 'YES' | 'NO',
|
||||||
amount: number,
|
amount: number,
|
||||||
contract: DPMBinaryContract,
|
contract: DPMBinaryContract
|
||||||
loanAmount: number
|
|
||||||
) => {
|
) => {
|
||||||
const { YES: yesPool, NO: noPool } = contract.pool
|
const { YES: yesPool, NO: noPool } = contract.pool
|
||||||
|
|
||||||
|
@ -308,7 +307,7 @@ export const getNewBinaryDpmBetInfo = (
|
||||||
const newBet: CandidateBet = {
|
const newBet: CandidateBet = {
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
amount,
|
amount,
|
||||||
loanAmount,
|
loanAmount: 0,
|
||||||
shares,
|
shares,
|
||||||
outcome,
|
outcome,
|
||||||
probBefore,
|
probBefore,
|
||||||
|
@ -324,7 +323,6 @@ export const getNewMultiBetInfo = (
|
||||||
outcome: string,
|
outcome: string,
|
||||||
amount: number,
|
amount: number,
|
||||||
contract: FreeResponseContract | MultipleChoiceContract,
|
contract: FreeResponseContract | MultipleChoiceContract,
|
||||||
loanAmount: number
|
|
||||||
) => {
|
) => {
|
||||||
const { pool, totalShares, totalBets } = contract
|
const { pool, totalShares, totalBets } = contract
|
||||||
|
|
||||||
|
@ -345,7 +343,7 @@ export const getNewMultiBetInfo = (
|
||||||
const newBet: CandidateBet = {
|
const newBet: CandidateBet = {
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
amount,
|
amount,
|
||||||
loanAmount,
|
loanAmount: 0,
|
||||||
shares,
|
shares,
|
||||||
outcome,
|
outcome,
|
||||||
probBefore,
|
probBefore,
|
||||||
|
@ -399,13 +397,3 @@ export const getNumericBetsInfo = (
|
||||||
|
|
||||||
return { newBet, newPool, newTotalShares, newTotalBets }
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -39,6 +39,7 @@ export type notification_source_types =
|
||||||
| 'bonus'
|
| 'bonus'
|
||||||
| 'challenge'
|
| 'challenge'
|
||||||
| 'betting_streak_bonus'
|
| 'betting_streak_bonus'
|
||||||
|
| 'loan'
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_source_update_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
@ -68,3 +69,5 @@ export type notification_reason_types =
|
||||||
| 'user_joined_from_your_group_invite'
|
| 'user_joined_from_your_group_invite'
|
||||||
| 'challenge_accepted'
|
| 'challenge_accepted'
|
||||||
| 'betting_streak_incremented'
|
| 'betting_streak_incremented'
|
||||||
|
| 'loan_income'
|
||||||
|
| 'you_follow_contract'
|
||||||
|
|
|
@ -3,7 +3,3 @@ export const NUMERIC_FIXED_VAR = 0.005
|
||||||
|
|
||||||
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
||||||
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
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
|
|
||||||
|
|
|
@ -13,8 +13,9 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
|
||||||
const yesShares = sumBy(yesBets, (b) => b.shares)
|
const yesShares = sumBy(yesBets, (b) => b.shares)
|
||||||
const noShares = sumBy(noBets, (b) => b.shares)
|
const noShares = sumBy(noBets, (b) => b.shares)
|
||||||
const shares = Math.max(Math.min(yesShares, noShares), 0)
|
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 loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
||||||
const loanPayment = Math.min(loanAmount, shares)
|
const loanPayment = loanAmount * soldFrac
|
||||||
const netAmount = shares - loanPayment
|
const netAmount = shares - loanPayment
|
||||||
return { shares, loanPayment, netAmount }
|
return { shares, loanPayment, netAmount }
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
|
||||||
|
|
||||||
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
||||||
const { pool, totalShares, totalBets } = contract
|
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)
|
const adjShareValue = calculateDpmShareValue(contract, bet)
|
||||||
|
|
||||||
|
@ -64,6 +64,7 @@ export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
||||||
betId,
|
betId,
|
||||||
},
|
},
|
||||||
fees,
|
fees,
|
||||||
|
loanAmount: -(loanAmount ?? 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -79,8 +80,8 @@ export const getCpmmSellBetInfo = (
|
||||||
shares: number,
|
shares: number,
|
||||||
outcome: 'YES' | 'NO',
|
outcome: 'YES' | 'NO',
|
||||||
contract: CPMMContract,
|
contract: CPMMContract,
|
||||||
prevLoanAmount: number,
|
unfilledBets: LimitBet[],
|
||||||
unfilledBets: LimitBet[]
|
loanPaid: number
|
||||||
) => {
|
) => {
|
||||||
const { pool, p } = contract
|
const { pool, p } = contract
|
||||||
|
|
||||||
|
@ -91,7 +92,6 @@ export const getCpmmSellBetInfo = (
|
||||||
unfilledBets
|
unfilledBets
|
||||||
)
|
)
|
||||||
|
|
||||||
const loanPaid = Math.min(prevLoanAmount, saleValue)
|
|
||||||
const probBefore = getCpmmProbability(pool, p)
|
const probBefore = getCpmmProbability(pool, p)
|
||||||
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { ENV_CONFIG } from './envs/constants'
|
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
createdTime: number
|
createdTime: number
|
||||||
|
@ -32,6 +30,7 @@ export type User = {
|
||||||
allTime: number
|
allTime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextLoanCached: number
|
||||||
followerCountCached: number
|
followerCountCached: number
|
||||||
|
|
||||||
followedCategories?: string[]
|
followedCategories?: string[]
|
||||||
|
@ -43,13 +42,10 @@ export type User = {
|
||||||
shouldShowWelcome?: boolean
|
shouldShowWelcome?: boolean
|
||||||
lastBetTime?: number
|
lastBetTime?: number
|
||||||
currentBettingStreak?: 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 = {
|
export type PrivateUser = {
|
||||||
id: string // same as User.id
|
id: string // same as User.id
|
||||||
username: string // denormalized from User
|
username: string // denormalized from User
|
||||||
|
@ -60,6 +56,7 @@ export type PrivateUser = {
|
||||||
unsubscribedFromAnswerEmails?: boolean
|
unsubscribedFromAnswerEmails?: boolean
|
||||||
unsubscribedFromGenericEmails?: boolean
|
unsubscribedFromGenericEmails?: boolean
|
||||||
unsubscribedFromWeeklyTrendingEmails?: boolean
|
unsubscribedFromWeeklyTrendingEmails?: boolean
|
||||||
|
weeklyTrendingEmailSent?: boolean
|
||||||
manaBonusEmailSent?: boolean
|
manaBonusEmailSent?: boolean
|
||||||
initialDeviceToken?: string
|
initialDeviceToken?: string
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
|
|
2
dev.sh
2
dev.sh
|
@ -24,7 +24,7 @@ then
|
||||||
npx concurrently \
|
npx concurrently \
|
||||||
-n FIRESTORE,FUNCTIONS,NEXT,TS \
|
-n FIRESTORE,FUNCTIONS,NEXT,TS \
|
||||||
-c green,white,magenta,cyan \
|
-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 FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
|
||||||
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
||||||
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
|
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
|
||||||
|
|
|
@ -97,7 +97,6 @@ Requires no authorization.
|
||||||
"creatorAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
|
"creatorAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
|
||||||
"closeTime":1653893940000,
|
"closeTime":1653893940000,
|
||||||
"question":"Will I write a new blog post today?",
|
"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":[
|
"tags":[
|
||||||
"personal",
|
"personal",
|
||||||
"commitments"
|
"commitments"
|
||||||
|
@ -135,8 +134,6 @@ Requires no authorization.
|
||||||
// Market attributes. All times are in milliseconds since epoch
|
// Market attributes. All times are in milliseconds since epoch
|
||||||
closeTime?: number // Min of creator's chosen date, and resolutionTime
|
closeTime?: number // Min of creator's chosen date, and resolutionTime
|
||||||
question: string
|
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.
|
// 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.
|
// This list also includes the predefined categories shown as filters on the home page.
|
||||||
|
@ -398,6 +395,8 @@ Requires no authorization.
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
answers?: Answer[]
|
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 = {
|
type Bet = {
|
||||||
|
|
|
@ -10,7 +10,9 @@ service cloud.firestore {
|
||||||
'akrolsmir@gmail.com',
|
'akrolsmir@gmail.com',
|
||||||
'jahooma@gmail.com',
|
'jahooma@gmail.com',
|
||||||
'taowell@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 read;
|
||||||
allow update: if userId == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& 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
|
// User referral rules
|
||||||
allow update: if userId == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
|
@ -43,6 +45,11 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match /contracts/{contractId}/follows/{userId} {
|
||||||
|
allow read;
|
||||||
|
allow create, delete: if userId == request.auth.uid;
|
||||||
|
}
|
||||||
|
|
||||||
match /contracts/{contractId}/challenges/{challengeId}{
|
match /contracts/{contractId}/challenges/{challengeId}{
|
||||||
allow read;
|
allow read;
|
||||||
allow create: if request.auth.uid == request.resource.data.creatorId;
|
allow create: if request.auth.uid == request.resource.data.creatorId;
|
||||||
|
|
|
@ -13,8 +13,8 @@
|
||||||
"deploy": "firebase deploy --only functions",
|
"deploy": "firebase deploy --only functions",
|
||||||
"logs": "firebase functions:log",
|
"logs": "firebase functions:log",
|
||||||
"dev": "nodemon src/serve.ts",
|
"dev": "nodemon src/serve.ts",
|
||||||
"firestore": "firebase emulators:start --only 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 --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: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: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)",
|
"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)",
|
||||||
|
|
|
@ -75,10 +75,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
|
||||||
}
|
}
|
||||||
transaction.create(newAnswerDoc, answer)
|
transaction.create(newAnswerDoc, answer)
|
||||||
|
|
||||||
const loanAmount = 0
|
|
||||||
|
|
||||||
const { newBet, newPool, newTotalShares, newTotalBets } =
|
const { newBet, newPool, newTotalShares, newTotalBets } =
|
||||||
getNewMultiBetInfo(answerId, amount, contract, loanAmount)
|
getNewMultiBetInfo(answerId, amount, contract)
|
||||||
|
|
||||||
const newBalance = user.balance - amount
|
const newBalance = user.balance - amount
|
||||||
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
||||||
|
|
|
@ -15,15 +15,17 @@ import {
|
||||||
import { slugify } from '../../common/util/slugify'
|
import { slugify } from '../../common/util/slugify'
|
||||||
import { randomString } from '../../common/util/random'
|
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 { APIError, newEndpoint, validate, zTimestamp } from './api'
|
||||||
|
|
||||||
|
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
|
||||||
import {
|
import {
|
||||||
FIXED_ANTE,
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
getCpmmInitialLiquidity,
|
getCpmmInitialLiquidity,
|
||||||
getFreeAnswerAnte,
|
getFreeAnswerAnte,
|
||||||
getMultipleChoiceAntes,
|
getMultipleChoiceAntes,
|
||||||
getNumericAnte,
|
getNumericAnte,
|
||||||
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
} from '../../common/antes'
|
} from '../../common/antes'
|
||||||
import { Answer, getNoneAnswer } from '../../common/answer'
|
import { Answer, getNoneAnswer } from '../../common/answer'
|
||||||
import { getNewContract } from '../../common/new-contract'
|
import { getNewContract } from '../../common/new-contract'
|
||||||
|
@ -34,6 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
import { uniq, zip } from 'lodash'
|
import { uniq, zip } from 'lodash'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
|
import { FieldValue } from 'firebase-admin/firestore'
|
||||||
|
|
||||||
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||||
z.intersection(
|
z.intersection(
|
||||||
|
@ -137,9 +140,10 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
const user = userDoc.data() as User
|
const user = userDoc.data() as User
|
||||||
|
|
||||||
const ante = FIXED_ANTE
|
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
|
// 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}.`)
|
throw new APIError(400, `Balance must be at least ${ante}.`)
|
||||||
|
|
||||||
let group: Group | null = null
|
let group: Group | null = null
|
||||||
|
@ -207,7 +211,18 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
visibility
|
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)
|
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') {
|
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||||
const liquidityDoc = firestore
|
const liquidityDoc = firestore
|
||||||
.collection(`contracts/${contract.id}/liquidity`)
|
.collection(`contracts/${contract.id}/liquidity`)
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
} from '../../common/notification'
|
} from '../../common/notification'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getValues } from './utils'
|
import { getValues, log } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Bet, LimitBet } from '../../common/bet'
|
import { Bet, LimitBet } from '../../common/bet'
|
||||||
|
@ -33,19 +33,12 @@ export const createNotification = async (
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
miscData?: {
|
miscData?: {
|
||||||
contract?: Contract
|
contract?: Contract
|
||||||
relatedSourceType?: notification_source_types
|
|
||||||
recipients?: string[]
|
recipients?: string[]
|
||||||
slug?: string
|
slug?: string
|
||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const {
|
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
||||||
contract: sourceContract,
|
|
||||||
relatedSourceType,
|
|
||||||
recipients,
|
|
||||||
slug,
|
|
||||||
title,
|
|
||||||
} = miscData ?? {}
|
|
||||||
|
|
||||||
const shouldGetNotification = (
|
const shouldGetNotification = (
|
||||||
userId: string,
|
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 (
|
const notifyUsersFollowers = async (
|
||||||
userToReasonTexts: user_to_reason_texts
|
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 = (
|
const notifyFollowedUser = (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
followedUserId: string
|
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 = (
|
const notifyUserAddedToGroup = (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
relatedUserId: string
|
relatedUserId: string
|
||||||
|
@ -266,58 +159,289 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUsersToNotify = async () => {
|
const userToReasonTexts: user_to_reason_texts = {}
|
||||||
const userToReasonTexts: user_to_reason_texts = {}
|
// The following functions modify the userToReasonTexts object in place.
|
||||||
// The following functions modify the userToReasonTexts object in place.
|
|
||||||
if (sourceType === 'follow' && recipients?.[0]) {
|
|
||||||
notifyFollowedUser(userToReasonTexts, recipients[0])
|
|
||||||
} else if (
|
|
||||||
sourceType === 'group' &&
|
|
||||||
sourceUpdateType === 'created' &&
|
|
||||||
recipients
|
|
||||||
) {
|
|
||||||
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The following functions need sourceContract to be defined.
|
if (sourceType === 'follow' && recipients?.[0]) {
|
||||||
if (!sourceContract) return userToReasonTexts
|
notifyFollowedUser(userToReasonTexts, recipients[0])
|
||||||
|
} else if (
|
||||||
if (
|
sourceType === 'group' &&
|
||||||
sourceType === 'comment' ||
|
sourceUpdateType === 'created' &&
|
||||||
sourceType === 'answer' ||
|
recipients
|
||||||
(sourceType === 'contract' &&
|
) {
|
||||||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
||||||
) {
|
} else if (
|
||||||
if (sourceType === 'comment') {
|
sourceType === 'contract' &&
|
||||||
if (recipients?.[0] && relatedSourceType)
|
sourceUpdateType === 'created' &&
|
||||||
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
|
sourceContract
|
||||||
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
) {
|
||||||
}
|
await notifyUsersFollowers(userToReasonTexts)
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
} else if (
|
||||||
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
|
sourceType === 'contract' &&
|
||||||
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
|
sourceUpdateType === 'closed' &&
|
||||||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
sourceContract
|
||||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
) {
|
||||||
await notifyUsersFollowers(userToReasonTexts)
|
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||||
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
force: true,
|
||||||
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
})
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
} else if (
|
||||||
force: true,
|
sourceType === 'liquidity' &&
|
||||||
})
|
sourceUpdateType === 'created' &&
|
||||||
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
|
sourceContract
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
) {
|
||||||
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
|
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||||
// Note: the daily bonus won't have a contract attached to it
|
} else if (
|
||||||
await notifyContractCreatorOfUniqueBettorsBonus(
|
sourceType === 'bonus' &&
|
||||||
userToReasonTexts,
|
sourceUpdateType === 'created' &&
|
||||||
sourceContract.creatorId
|
sourceContract
|
||||||
)
|
) {
|
||||||
}
|
// Note: the daily bonus won't have a contract attached to it
|
||||||
return userToReasonTexts
|
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)
|
await createUsersNotifications(userToReasonTexts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -471,6 +595,32 @@ export const createReferralNotification = async (
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
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}`
|
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
|
||||||
|
|
||||||
export const createChallengeAcceptedNotification = async (
|
export const createChallengeAcceptedNotification = async (
|
||||||
|
|
|
@ -2,15 +2,8 @@ import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
|
|
||||||
import {
|
import { PrivateUser, User } from '../../common/user'
|
||||||
MANIFOLD_AVATAR_URL,
|
import { getUser, getUserByUsername, getValues } from './utils'
|
||||||
MANIFOLD_USERNAME,
|
|
||||||
PrivateUser,
|
|
||||||
STARTING_BALANCE,
|
|
||||||
SUS_STARTING_BALANCE,
|
|
||||||
User,
|
|
||||||
} from '../../common/user'
|
|
||||||
import { getUser, getUserByUsername, getValues, isProd } from './utils'
|
|
||||||
import { randomString } from '../../common/util/random'
|
import { randomString } from '../../common/util/random'
|
||||||
import {
|
import {
|
||||||
cleanDisplayName,
|
cleanDisplayName,
|
||||||
|
@ -25,10 +18,7 @@ import {
|
||||||
import { track } from './analytics'
|
import { track } from './analytics'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
||||||
import {
|
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
|
||||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
|
||||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
|
||||||
} from '../../common/antes'
|
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
deviceToken: z.string().optional(),
|
deviceToken: z.string().optional(),
|
||||||
|
@ -75,6 +65,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||||
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||||
|
nextLoanCached: 0,
|
||||||
followerCountCached: 0,
|
followerCountCached: 0,
|
||||||
followedCategories: DEFAULT_CATEGORIES,
|
followedCategories: DEFAULT_CATEGORIES,
|
||||||
shouldShowWelcome: true,
|
shouldShowWelcome: true,
|
||||||
|
@ -144,24 +135,5 @@ const addUserToDefaultGroups = async (user: User) => {
|
||||||
.update({
|
.update({
|
||||||
memberIds: uniq(group.memberIds.concat(user.id)),
|
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -444,7 +444,7 @@
|
||||||
style="
|
style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to unsubscribe</a>.
|
" target="_blank">click here to unsubscribe</a> from future recommended markets.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -53,10 +53,10 @@ export const sendMarketResolutionEmail = async (
|
||||||
|
|
||||||
const subject = `Resolved ${outcome}: ${contract.question}`
|
const subject = `Resolved ${outcome}: ${contract.question}`
|
||||||
|
|
||||||
const creatorPayoutText =
|
// const creatorPayoutText =
|
||||||
userId === creator.id
|
// userId === creator.id
|
||||||
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
// ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
||||||
: ''
|
// : ''
|
||||||
|
|
||||||
const emailType = 'market-resolved'
|
const emailType = 'market-resolved'
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||||
|
@ -68,7 +68,7 @@ export const sendMarketResolutionEmail = async (
|
||||||
question: contract.question,
|
question: contract.question,
|
||||||
outcome,
|
outcome,
|
||||||
investment: `${Math.floor(investment)}`,
|
investment: `${Math.floor(investment)}`,
|
||||||
payout: `${Math.floor(payout)}${creatorPayoutText}`,
|
payout: `${Math.floor(payout)}`,
|
||||||
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
|
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
|
||||||
unsubscribeUrl,
|
unsubscribeUrl,
|
||||||
}
|
}
|
||||||
|
@ -116,7 +116,9 @@ const toDisplayResolution = (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
||||||
const { resolutionValue } = contract
|
const { resolution, resolutionValue } = contract
|
||||||
|
|
||||||
|
if (resolution === 'CANCEL') return 'N/A'
|
||||||
|
|
||||||
return resolutionValue
|
return resolutionValue
|
||||||
? formatLargeNumber(resolutionValue)
|
? formatLargeNumber(resolutionValue)
|
||||||
|
|
36
functions/src/follow-market.ts
Normal file
36
functions/src/follow-market.ts
Normal 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()
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ export * from './on-create-comment-on-contract'
|
||||||
export * from './on-view'
|
export * from './on-view'
|
||||||
export * from './update-metrics'
|
export * from './update-metrics'
|
||||||
export * from './update-stats'
|
export * from './update-stats'
|
||||||
|
export * from './update-loans'
|
||||||
export * from './backup-db'
|
export * from './backup-db'
|
||||||
export * from './market-close-notifications'
|
export * from './market-close-notifications'
|
||||||
export * from './on-create-answer'
|
export * from './on-create-answer'
|
||||||
|
@ -28,6 +29,8 @@ export * from './on-delete-group'
|
||||||
export * from './score-contracts'
|
export * from './score-contracts'
|
||||||
export * from './weekly-markets-emails'
|
export * from './weekly-markets-emails'
|
||||||
export * from './reset-betting-streaks'
|
export * from './reset-betting-streaks'
|
||||||
|
export * from './reset-weekly-emails-flag'
|
||||||
|
export * from './on-update-contract-follow'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { getContract, getUser } from './utils'
|
import { getContract, getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
|
|
||||||
export const onCreateAnswer = functions.firestore
|
export const onCreateAnswer = functions.firestore
|
||||||
|
@ -20,14 +20,13 @@ export const onCreateAnswer = functions.firestore
|
||||||
|
|
||||||
const answerCreator = await getUser(answer.userId)
|
const answerCreator = await getUser(answer.userId)
|
||||||
if (!answerCreator) throw new Error('Could not find answer creator')
|
if (!answerCreator) throw new Error('Could not find answer creator')
|
||||||
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
await createNotification(
|
|
||||||
answer.id,
|
answer.id,
|
||||||
'answer',
|
'answer',
|
||||||
'created',
|
'created',
|
||||||
answerCreator,
|
answerCreator,
|
||||||
eventId,
|
eventId,
|
||||||
answer.text,
|
answer.text,
|
||||||
{ contract }
|
contract
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {
|
||||||
BETTING_STREAK_BONUS_MAX,
|
BETTING_STREAK_BONUS_MAX,
|
||||||
BETTING_STREAK_RESET_HOUR,
|
BETTING_STREAK_RESET_HOUR,
|
||||||
UNIQUE_BETTOR_BONUS_AMOUNT,
|
UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||||
} from '../../common/numeric-constants'
|
} from '../../common/economy'
|
||||||
import {
|
import {
|
||||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
|
|
@ -6,8 +6,9 @@ import { ContractComment } from '../../common/comment'
|
||||||
import { sendNewCommentEmail } from './emails'
|
import { sendNewCommentEmail } from './emails'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
import { createNotification } from './create-notification'
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
@ -35,6 +36,8 @@ export const onCreateCommentOnContract = functions
|
||||||
const commentCreator = await getUser(comment.userId)
|
const commentCreator = await getUser(comment.userId)
|
||||||
if (!commentCreator) throw new Error('Could not find comment creator')
|
if (!commentCreator) throw new Error('Could not find comment creator')
|
||||||
|
|
||||||
|
await addUserToContractFollowers(contract.id, commentCreator.id)
|
||||||
|
|
||||||
await firestore
|
await firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.doc(contract.id)
|
.doc(contract.id)
|
||||||
|
@ -77,18 +80,19 @@ export const onCreateCommentOnContract = functions
|
||||||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||||
: answer?.userId
|
: answer?.userId
|
||||||
|
|
||||||
const recipients = uniq(
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
compact([...parseMentions(comment.content), repliedUserId])
|
|
||||||
)
|
|
||||||
|
|
||||||
await createNotification(
|
|
||||||
comment.id,
|
comment.id,
|
||||||
'comment',
|
'comment',
|
||||||
'created',
|
'created',
|
||||||
commentCreator,
|
commentCreator,
|
||||||
eventId,
|
eventId,
|
||||||
richTextToString(comment.content),
|
richTextToString(comment.content),
|
||||||
{ contract, relatedSourceType, recipients }
|
contract,
|
||||||
|
{
|
||||||
|
relatedSourceType,
|
||||||
|
repliedUserId,
|
||||||
|
taggedUserIds: compact(parseMentions(comment.content)),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const recipientUserIds = uniq([
|
const recipientUserIds = uniq([
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { createNotification } from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
|
||||||
export const onCreateContract = functions
|
export const onCreateContract = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
@ -18,6 +19,7 @@ export const onCreateContract = functions
|
||||||
|
|
||||||
const desc = contract.description as JSONContent
|
const desc = contract.description as JSONContent
|
||||||
const mentioned = parseMentions(desc)
|
const mentioned = parseMentions(desc)
|
||||||
|
await addUserToContractFollowers(contract.id, contractCreator.id)
|
||||||
|
|
||||||
await createNotification(
|
await createNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
|
|
|
@ -1,7 +1,13 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { getContract, getUser } from './utils'
|
import { getContract, getUser, log } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
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
|
export const onCreateLiquidityProvision = functions.firestore
|
||||||
.document('contracts/{contractId}/liquidity/{liquidityId}')
|
.document('contracts/{contractId}/liquidity/{liquidityId}')
|
||||||
|
@ -10,7 +16,14 @@ export const onCreateLiquidityProvision = functions.firestore
|
||||||
const { eventId } = context
|
const { eventId } = context
|
||||||
|
|
||||||
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
|
// 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)
|
const contract = await getContract(liquidity.contractId)
|
||||||
if (!contract)
|
if (!contract)
|
||||||
|
@ -18,6 +31,7 @@ export const onCreateLiquidityProvision = functions.firestore
|
||||||
|
|
||||||
const liquidityProvider = await getUser(liquidity.userId)
|
const liquidityProvider = await getUser(liquidity.userId)
|
||||||
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
||||||
|
await addUserToContractFollowers(contract.id, liquidityProvider.id)
|
||||||
|
|
||||||
await createNotification(
|
await createNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
|
|
45
functions/src/on-update-contract-follow.ts
Normal file
45
functions/src/on-update-contract-follow.ts
Normal 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),
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,6 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
|
|
||||||
export const onUpdateContract = functions.firestore
|
export const onUpdateContract = functions.firestore
|
||||||
|
@ -29,40 +29,37 @@ export const onUpdateContract = functions.firestore
|
||||||
resolutionText = `${contract.resolutionValue}`
|
resolutionText = `${contract.resolutionValue}`
|
||||||
}
|
}
|
||||||
|
|
||||||
await createNotification(
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
'contract',
|
'contract',
|
||||||
'resolved',
|
'resolved',
|
||||||
contractUpdater,
|
contractUpdater,
|
||||||
eventId,
|
eventId,
|
||||||
resolutionText,
|
resolutionText,
|
||||||
{ contract }
|
contract
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
previousValue.closeTime !== contract.closeTime ||
|
previousValue.closeTime !== contract.closeTime ||
|
||||||
previousValue.description !== contract.description
|
previousValue.question !== contract.question
|
||||||
) {
|
) {
|
||||||
let sourceText = ''
|
let sourceText = ''
|
||||||
if (previousValue.closeTime !== contract.closeTime && contract.closeTime)
|
if (
|
||||||
|
previousValue.closeTime !== contract.closeTime &&
|
||||||
|
contract.closeTime
|
||||||
|
) {
|
||||||
sourceText = contract.closeTime.toString()
|
sourceText = contract.closeTime.toString()
|
||||||
else {
|
} else if (previousValue.question !== contract.question) {
|
||||||
const oldTrimmedDescription = previousValue.description.trim()
|
sourceText = contract.question
|
||||||
const newTrimmedDescription = contract.description.trim()
|
|
||||||
if (oldTrimmedDescription === '') sourceText = newTrimmedDescription
|
|
||||||
else
|
|
||||||
sourceText = newTrimmedDescription
|
|
||||||
.split(oldTrimmedDescription)[1]
|
|
||||||
.trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await createNotification(
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
'contract',
|
'contract',
|
||||||
'updated',
|
'updated',
|
||||||
contractUpdater,
|
contractUpdater,
|
||||||
eventId,
|
eventId,
|
||||||
sourceText,
|
sourceText,
|
||||||
{ contract }
|
contract
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
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 { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
||||||
import { createReferralNotification } from './create-notification'
|
import { createReferralNotification } from './create-notification'
|
||||||
import { ReferralTxn } from '../../common/txn'
|
import { ReferralTxn } from '../../common/txn'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { LimitBet } from 'common/bet'
|
import { LimitBet } from '../../common/bet'
|
||||||
import { QuerySnapshot } from 'firebase-admin/firestore'
|
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()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const onUpdateUser = functions.firestore
|
export const onUpdateUser = functions.firestore
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { LimitBet } from '../../common/bet'
|
||||||
import { floatingEqual } from '../../common/util/math'
|
import { floatingEqual } from '../../common/util/math'
|
||||||
import { redeemShares } from './redeem-shares'
|
import { redeemShares } from './redeem-shares'
|
||||||
import { log } from './utils'
|
import { log } from './utils'
|
||||||
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -59,7 +60,6 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
const user = userSnap.data() as User
|
const user = userSnap.data() as User
|
||||||
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
|
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
|
||||||
|
|
||||||
const loanAmount = 0
|
|
||||||
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
|
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
|
||||||
contract
|
contract
|
||||||
if (closeTime && Date.now() > closeTime)
|
if (closeTime && Date.now() > closeTime)
|
||||||
|
@ -119,7 +119,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
const answerDoc = contractDoc.collection('answers').doc(outcome)
|
const answerDoc = contractDoc.collection('answers').doc(outcome)
|
||||||
const answerSnap = await trans.get(answerDoc)
|
const answerSnap = await trans.get(answerDoc)
|
||||||
if (!answerSnap.exists) throw new APIError(400, 'Invalid answer')
|
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') {
|
} else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') {
|
||||||
const { outcome, value } = validate(numericSchema, req.body)
|
const { outcome, value } = validate(numericSchema, req.body)
|
||||||
return getNumericBetsInfo(value, outcome, amount, contract)
|
return getNumericBetsInfo(value, outcome, amount, contract)
|
||||||
|
@ -168,6 +168,8 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
return { betId: betDoc.id, makers, newBet }
|
return { betId: betDoc.id, makers, newBet }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await addUserToContractFollowers(contractId, auth.uid)
|
||||||
|
|
||||||
log('Main transaction finished.')
|
log('Main transaction finished.')
|
||||||
|
|
||||||
if (result.newBet.amount !== 0) {
|
if (result.newBet.amount !== 0) {
|
||||||
|
|
|
@ -4,12 +4,12 @@ import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
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()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const resetBettingStreaksForUsers = functions.pubsub
|
export const resetBettingStreaksForUsers = functions.pubsub
|
||||||
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
|
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
|
||||||
.timeZone('utc')
|
.timeZone('Etc/UTC')
|
||||||
.onRun(async () => {
|
.onRun(async () => {
|
||||||
await resetBettingStreaksInternal()
|
await resetBettingStreaksInternal()
|
||||||
})
|
})
|
||||||
|
|
24
functions/src/reset-weekly-emails-flag.ts
Normal file
24
functions/src/reset-weekly-emails-flag.ts
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
75
functions/src/scripts/backfill-contract-followers.ts
Normal file
75
functions/src/scripts/backfill-contract-followers.ts
Normal 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)
|
||||||
|
}
|
|
@ -3,7 +3,8 @@ import * as admin from 'firebase-admin'
|
||||||
import { initAdmin } from './script-init'
|
import { initAdmin } from './script-init'
|
||||||
initAdmin()
|
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()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
|
|
@ -50,11 +50,12 @@ export const sellbet = newEndpoint({}, async (req, auth) => {
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||||
const saleAmount = newBet.sale!.amount
|
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()
|
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
||||||
|
|
||||||
transaction.update(userDoc, { balance: newBalance })
|
transaction.update(userDoc, { balance: newBalance })
|
||||||
transaction.update(betDoc, { isSold: true })
|
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.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet })
|
||||||
transaction.update(
|
transaction.update(
|
||||||
contractDoc,
|
contractDoc,
|
||||||
|
|
|
@ -7,12 +7,13 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
||||||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||||
import { getValues, log } from './utils'
|
import { log } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
||||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||||
import { FieldValue } from 'firebase-admin/firestore'
|
import { FieldValue } from 'firebase-admin/firestore'
|
||||||
import { redeemShares } from './redeem-shares'
|
import { redeemShares } from './redeem-shares'
|
||||||
|
import { removeUserFromContractFollowers } from './follow-market'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -28,12 +29,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
||||||
const [[contractSnap, userSnap], userBets] = await Promise.all([
|
const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
|
||||||
transaction.getAll(contractDoc, userDoc),
|
await Promise.all([
|
||||||
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
|
transaction.getAll(contractDoc, userDoc),
|
||||||
])
|
transaction.get(betsQ),
|
||||||
|
transaction.get(getUnfilledBetsQuery(contractDoc)),
|
||||||
|
])
|
||||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||||
if (!userSnap.exists) throw new APIError(400, 'User 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 contract = contractSnap.data() as Contract
|
||||||
const user = userSnap.data() as User
|
const user = userSnap.data() as User
|
||||||
|
@ -45,7 +50,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
if (closeTime && Date.now() > closeTime)
|
if (closeTime && Date.now() > closeTime)
|
||||||
throw new APIError(400, 'Trading is closed.')
|
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 betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
|
||||||
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
|
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
|
||||||
sumBy(bets, (b) => b.shares)
|
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.`)
|
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
||||||
|
|
||||||
const soldShares = Math.min(sharesToSell, maxShares)
|
const soldShares = Math.min(sharesToSell, maxShares)
|
||||||
|
const saleFrac = soldShares / maxShares
|
||||||
const unfilledBetsSnap = await transaction.get(
|
let loanPaid = saleFrac * loanAmount
|
||||||
getUnfilledBetsQuery(contractDoc)
|
if (!isFinite(loanPaid)) loanPaid = 0
|
||||||
)
|
|
||||||
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
|
|
||||||
|
|
||||||
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
||||||
soldShares,
|
soldShares,
|
||||||
chosenOutcome,
|
chosenOutcome,
|
||||||
contract,
|
contract,
|
||||||
prevLoanAmount,
|
unfilledBets,
|
||||||
unfilledBets
|
loanPaid
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -104,7 +107,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
updateMakers(makers, newBetDoc.id, contractDoc, transaction)
|
updateMakers(makers, newBetDoc.id, contractDoc, transaction)
|
||||||
|
|
||||||
transaction.update(userDoc, {
|
transaction.update(userDoc, {
|
||||||
balance: FieldValue.increment(-newBet.amount),
|
balance: FieldValue.increment(-newBet.amount + (newBet.loanAmount ?? 0)),
|
||||||
})
|
})
|
||||||
transaction.create(newBetDoc, {
|
transaction.create(newBetDoc, {
|
||||||
id: newBetDoc.id,
|
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))
|
const userIds = uniq(result.makers.map((maker) => maker.bet.userId))
|
||||||
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
|
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
|
||||||
log('Share redemption transaction finished.')
|
log('Share redemption transaction finished.')
|
||||||
|
|
|
@ -69,6 +69,10 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
res.send(
|
res.send(
|
||||||
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
|
`${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.`)
|
else res.send(`${name}, you have been unsubscribed.`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
92
functions/src/update-loans.ts
Normal file
92
functions/src/update-loans.ts
Normal 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!')
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
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 { getValues, log, logMemory, writeAsync } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
|
@ -8,6 +8,7 @@ import { PortfolioMetrics, User } from '../../common/user'
|
||||||
import { calculatePayout } from '../../common/calculate'
|
import { calculatePayout } from '../../common/calculate'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
import { last } from 'lodash'
|
import { last } from 'lodash'
|
||||||
|
import { getLoanUpdates } from '../../common/loans'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
@ -21,7 +22,9 @@ const computeInvestmentValue = (
|
||||||
if (bet.sale || bet.isSold) return 0
|
if (bet.sale || bet.isSold) return 0
|
||||||
|
|
||||||
const payout = calculatePayout(contract, bet, 'MKT')
|
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 contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
|
||||||
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||||
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
|
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
|
||||||
const userUpdates = users.map((user) => {
|
|
||||||
|
const userMetrics = users.map((user) => {
|
||||||
const currentBets = betsByUser[user.id] ?? []
|
const currentBets = betsByUser[user.id] ?? []
|
||||||
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
|
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
|
||||||
const userContracts = contractsByUser[user.id] ?? []
|
const userContracts = contractsByUser[user.id] ?? []
|
||||||
|
@ -93,32 +97,56 @@ export const updateMetricsCore = async () => {
|
||||||
newPortfolio,
|
newPortfolio,
|
||||||
didProfitChange
|
didProfitChange
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fieldUpdates: {
|
user,
|
||||||
doc: firestore.collection('users').doc(user.id),
|
newCreatorVolume,
|
||||||
fields: {
|
newPortfolio,
|
||||||
creatorVolumeCached: newCreatorVolume,
|
newProfit,
|
||||||
...(didProfitChange && {
|
didProfitChange,
|
||||||
profitCached: newProfit,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
subcollectionUpdates: {
|
|
||||||
doc: firestore
|
|
||||||
.collection('users')
|
|
||||||
.doc(user.id)
|
|
||||||
.collection('portfolioHistory')
|
|
||||||
.doc(),
|
|
||||||
fields: {
|
|
||||||
...(didProfitChange && {
|
|
||||||
...newPortfolio,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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(
|
await writeAsync(
|
||||||
firestore,
|
firestore,
|
||||||
userUpdates.map((u) => u.fieldUpdates)
|
userUpdates.map((u) => u.fieldUpdates)
|
||||||
|
@ -234,6 +262,6 @@ const calculateNewProfit = (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateMetrics = functions
|
export const updateMetrics = functions
|
||||||
.runWith({ memory: '1GB', timeoutSeconds: 540 })
|
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||||
.pubsub.schedule('every 15 minutes')
|
.pubsub.schedule('every 15 minutes')
|
||||||
.onRun(updateMetricsCore)
|
.onRun(updateMetricsCore)
|
||||||
|
|
|
@ -311,6 +311,6 @@ export const updateStatsCore = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateStats = functions
|
export const updateStats = functions
|
||||||
.runWith({ memory: '1GB', timeoutSeconds: 540 })
|
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||||
.pubsub.schedule('every 60 minutes')
|
.pubsub.schedule('every 60 minutes')
|
||||||
.onRun(updateStatsCore)
|
.onRun(updateStatsCore)
|
||||||
|
|
|
@ -9,9 +9,9 @@ import { DAY_MS } from '../../common/util/time'
|
||||||
|
|
||||||
export const weeklyMarketsEmails = functions
|
export const weeklyMarketsEmails = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
// every Monday at 12pm PT (UTC -07:00)
|
// every minute on Monday for an hour at 12pm PT (UTC -07:00)
|
||||||
.pubsub.schedule('0 19 * * 1')
|
.pubsub.schedule('* 19 * * 1')
|
||||||
.timeZone('utc')
|
.timeZone('Etc/UTC')
|
||||||
.onRun(async () => {
|
.onRun(async () => {
|
||||||
await sendTrendingMarketsEmailsToAllUsers()
|
await sendTrendingMarketsEmailsToAllUsers()
|
||||||
})
|
})
|
||||||
|
@ -37,17 +37,33 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
||||||
const privateUsers = await getAllPrivateUsers()
|
const privateUsers = await getAllPrivateUsers()
|
||||||
// get all users that haven't unsubscribed from weekly emails
|
// get all users that haven't unsubscribed from weekly emails
|
||||||
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
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())
|
const trendingContracts = (await getTrendingContracts())
|
||||||
.filter(
|
.filter(
|
||||||
(contract) =>
|
(contract) =>
|
||||||
!(
|
!(
|
||||||
contract.question.toLowerCase().includes('trump') &&
|
contract.question.toLowerCase().includes('trump') &&
|
||||||
contract.question.toLowerCase().includes('president')
|
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)
|
.slice(0, 20)
|
||||||
|
log(
|
||||||
|
`Found ${trendingContracts.length} trending contracts:\n`,
|
||||||
|
trendingContracts.map((c) => c.question).join('\n ')
|
||||||
|
)
|
||||||
|
|
||||||
for (const privateUser of privateUsersToSendEmailsTo) {
|
for (const privateUser of privateUsersToSendEmailsTo) {
|
||||||
if (!privateUser.email) {
|
if (!privateUser.email) {
|
||||||
log(`No email for ${privateUser.username}`)
|
log(`No email for ${privateUser.username}`)
|
||||||
|
@ -70,12 +86,17 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
||||||
if (!user) continue
|
if (!user) continue
|
||||||
|
|
||||||
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
|
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) {
|
function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||||
const fiveMinutes = 5 * 60 * 1000
|
shuffle(contracts, rng)
|
||||||
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
|
||||||
shuffle(contracts, createRNG(seed))
|
|
||||||
return contracts.slice(0, count)
|
return contracts.slice(0, count)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { Row } from 'web/components/layout/row'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
||||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
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() {
|
export function NotificationSettings() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
@ -17,6 +19,7 @@ export function NotificationSettings() {
|
||||||
const [emailNotificationSettings, setEmailNotificationSettings] =
|
const [emailNotificationSettings, setEmailNotificationSettings] =
|
||||||
useState<notification_subscribe_types>('all')
|
useState<notification_subscribe_types>('all')
|
||||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
||||||
|
@ -121,12 +124,20 @@ export function NotificationSettings() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationSettingLine(props: {
|
function NotificationSettingLine(props: {
|
||||||
label: string
|
label: string | React.ReactNode
|
||||||
highlight: boolean
|
highlight: boolean
|
||||||
|
onClick?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { label, highlight } = props
|
const { label, highlight, onClick } = props
|
||||||
return (
|
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} />}
|
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
|
||||||
{label}
|
{label}
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -148,31 +159,45 @@ export function NotificationSettings() {
|
||||||
toggleClassName={'w-24'}
|
toggleClassName={'w-24'}
|
||||||
/>
|
/>
|
||||||
<div className={'mt-4 text-sm'}>
|
<div className={'mt-4 text-sm'}>
|
||||||
<div>
|
<Col className={''}>
|
||||||
<div className={''}>
|
<Row className={'my-1'}>
|
||||||
You will receive notifications for:
|
You will receive notifications for these general events:
|
||||||
<NotificationSettingLine
|
</Row>
|
||||||
label={"Resolution of questions you've interacted with"}
|
<NotificationSettingLine
|
||||||
highlight={notificationSettings !== 'none'}
|
highlight={notificationSettings !== 'none'}
|
||||||
/>
|
label={"Income & referral bonuses you've received"}
|
||||||
<NotificationSettingLine
|
/>
|
||||||
highlight={notificationSettings !== 'none'}
|
<Row className={'my-1'}>
|
||||||
label={'Activity on your own questions, comments, & answers'}
|
You will receive new comment, answer, & resolution notifications on
|
||||||
/>
|
questions:
|
||||||
<NotificationSettingLine
|
</Row>
|
||||||
highlight={notificationSettings !== 'none'}
|
<NotificationSettingLine
|
||||||
label={"Activity on questions you're betting on"}
|
highlight={notificationSettings !== 'none'}
|
||||||
/>
|
label={
|
||||||
<NotificationSettingLine
|
<span>
|
||||||
highlight={notificationSettings !== 'none'}
|
That <span className={'font-bold'}>you watch </span>- you
|
||||||
label={"Income & referral bonuses you've received"}
|
auto-watch questions if:
|
||||||
/>
|
</span>
|
||||||
<NotificationSettingLine
|
}
|
||||||
label={"Activity on questions you've ever bet or commented on"}
|
onClick={() => setShowModal(true)}
|
||||||
highlight={notificationSettings === 'all'}
|
/>
|
||||||
/>
|
<Col
|
||||||
</div>
|
className={clsx(
|
||||||
</div>
|
'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>
|
||||||
<div className={'mt-4'}>Email Notifications</div>
|
<div className={'mt-4'}>Email Notifications</div>
|
||||||
<ChoicesToggleGroup
|
<ChoicesToggleGroup
|
||||||
|
@ -205,6 +230,7 @@ export function NotificationSettings() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FollowMarketModal setOpen={setShowModal} open={showModal} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { formatMoney } from 'common/util/format'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { SiteLink } from './site-link'
|
import { SiteLink } from './site-link'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
|
||||||
export function AmountInput(props: {
|
export function AmountInput(props: {
|
||||||
amount: number | undefined
|
amount: number | undefined
|
||||||
|
@ -33,7 +34,8 @@ export function AmountInput(props: {
|
||||||
const isInvalid = !str || isNaN(amount)
|
const isInvalid = !str || isNaN(amount)
|
||||||
onChange(isInvalid ? undefined : amount)
|
onChange(isInvalid ? undefined : amount)
|
||||||
}
|
}
|
||||||
|
const { width } = useWindowSize()
|
||||||
|
const isMobile = (width ?? 0) < 768
|
||||||
return (
|
return (
|
||||||
<Col className={className}>
|
<Col className={className}>
|
||||||
<label className="input-group mb-4">
|
<label className="input-group mb-4">
|
||||||
|
@ -50,6 +52,7 @@ export function AmountInput(props: {
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
|
autoFocus={!isMobile}
|
||||||
value={amount ?? ''}
|
value={amount ?? ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => onAmountChange(e.target.value)}
|
onChange={(e) => onAmountChange(e.target.value)}
|
||||||
|
|
|
@ -8,6 +8,8 @@ import { useUser } from 'web/hooks/use-user'
|
||||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||||
import { Col } from './layout/col'
|
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 */
|
/** Button that opens BetPanel in a new modal */
|
||||||
export default function BetButton(props: {
|
export default function BetButton(props: {
|
||||||
|
@ -30,23 +32,27 @@ export default function BetButton(props: {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Col className={clsx('items-center', className)}>
|
<Col className={clsx('items-center', className)}>
|
||||||
<button
|
<Button
|
||||||
className={clsx(
|
size={'lg'}
|
||||||
'btn btn-lg btn-outline my-auto inline-flex h-10 min-h-0 w-24',
|
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
|
||||||
btnClassName
|
onClick={() => {
|
||||||
)}
|
!user ? firebaseLogin() : setOpen(true)
|
||||||
onClick={() => setOpen(true)}
|
}}
|
||||||
>
|
>
|
||||||
Bet
|
{user ? 'Bet' : 'Sign up to Bet'}
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
|
{user && (
|
||||||
{hasYesShares
|
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
|
||||||
? `(${Math.floor(yesShares)} ${isPseudoNumeric ? 'HIGHER' : 'YES'})`
|
{hasYesShares
|
||||||
: hasNoShares
|
? `(${Math.floor(yesShares)} ${
|
||||||
? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})`
|
isPseudoNumeric ? 'HIGHER' : 'YES'
|
||||||
: ''}
|
})`
|
||||||
</div>
|
: hasNoShares
|
||||||
|
? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Modal open={open} setOpen={setOpen}>
|
<Modal open={open} setOpen={setOpen}>
|
||||||
|
|
|
@ -22,7 +22,7 @@ import { formatMoney } from 'common/util/format'
|
||||||
export function BetInline(props: {
|
export function BetInline(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
className?: string
|
className?: string
|
||||||
setProbAfter: (probAfter: number) => void
|
setProbAfter: (probAfter: number | undefined) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const { contract, className, setProbAfter, onClose } = props
|
const { contract, className, setProbAfter, onClose } = props
|
||||||
|
@ -82,7 +82,7 @@ export function BetInline(props: {
|
||||||
<div className="text-xl">Bet</div>
|
<div className="text-xl">Bet</div>
|
||||||
<YesNoSelector
|
<YesNoSelector
|
||||||
className="space-x-0"
|
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}
|
selected={outcome}
|
||||||
onSelect={setOutcome}
|
onSelect={setOutcome}
|
||||||
isPseudoNumeric={isPseudoNumeric}
|
isPseudoNumeric={isPseudoNumeric}
|
||||||
|
@ -113,7 +113,12 @@ export function BetInline(props: {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<SignUpPrompt size="xs" />
|
<SignUpPrompt size="xs" />
|
||||||
<button onClick={onClose}>
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setProbAfter(undefined)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
<XIcon className="ml-1 h-6 w-6" />
|
<XIcon className="ml-1 h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import React, { useEffect, useState } from 'react'
|
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 { useUser } from 'web/hooks/use-user'
|
||||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
|
@ -9,7 +9,6 @@ import { Row } from './layout/row'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
import {
|
import {
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatMoneyWithDecimals,
|
|
||||||
formatPercent,
|
formatPercent,
|
||||||
formatWithCommas,
|
formatWithCommas,
|
||||||
} from 'common/util/format'
|
} from 'common/util/format'
|
||||||
|
@ -18,7 +17,6 @@ import { User } from 'web/lib/firebase/users'
|
||||||
import { Bet, LimitBet } from 'common/bet'
|
import { Bet, LimitBet } from 'common/bet'
|
||||||
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
|
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
|
||||||
import { AmountInput, BuyAmountInput } from './amount-input'
|
import { AmountInput, BuyAmountInput } from './amount-input'
|
||||||
import { InfoTooltip } from './info-tooltip'
|
|
||||||
import {
|
import {
|
||||||
BinaryOutcomeLabel,
|
BinaryOutcomeLabel,
|
||||||
HigherLabel,
|
HigherLabel,
|
||||||
|
@ -261,8 +259,6 @@ function BuyPanel(props: {
|
||||||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||||
const currentReturnPercent = formatPercent(currentReturn)
|
const currentReturnPercent = formatPercent(currentReturn)
|
||||||
|
|
||||||
const totalFees = sum(Object.values(newBet.fees))
|
|
||||||
|
|
||||||
const format = getFormattedMappedValue(contract)
|
const format = getFormattedMappedValue(contract)
|
||||||
|
|
||||||
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||||
|
@ -346,9 +342,9 @@ function BuyPanel(props: {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<InfoTooltip
|
{/* <InfoTooltip
|
||||||
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
|
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
|
||||||
/>
|
/> */}
|
||||||
</Row>
|
</Row>
|
||||||
<div>
|
<div>
|
||||||
<span className="mr-2 whitespace-nowrap">
|
<span className="mr-2 whitespace-nowrap">
|
||||||
|
@ -665,9 +661,9 @@ function LimitOrderPanel(props: {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<InfoTooltip
|
{/* <InfoTooltip
|
||||||
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
|
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
|
||||||
/>
|
/> */}
|
||||||
</Row>
|
</Row>
|
||||||
<div>
|
<div>
|
||||||
<span className="mr-2 whitespace-nowrap">
|
<span className="mr-2 whitespace-nowrap">
|
||||||
|
@ -689,9 +685,9 @@ function LimitOrderPanel(props: {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<InfoTooltip
|
{/* <InfoTooltip
|
||||||
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
|
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
|
||||||
/>
|
/> */}
|
||||||
</Row>
|
</Row>
|
||||||
<div>
|
<div>
|
||||||
<span className="mr-2 whitespace-nowrap">
|
<span className="mr-2 whitespace-nowrap">
|
||||||
|
|
|
@ -21,7 +21,7 @@ import { AmountInput } from '../amount-input'
|
||||||
import { getProbability } from 'common/calculate'
|
import { getProbability } from 'common/calculate'
|
||||||
import { createMarket } from 'web/lib/firebase/api'
|
import { createMarket } from 'web/lib/firebase/api'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
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 Textarea from 'react-expanding-textarea'
|
||||||
import { useTextEditor } from 'web/components/editor'
|
import { useTextEditor } from 'web/components/editor'
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
|
|
|
@ -91,6 +91,8 @@ export function ContractSearch(props: {
|
||||||
useQuerySortLocalStorage?: boolean
|
useQuerySortLocalStorage?: boolean
|
||||||
useQuerySortUrlParams?: boolean
|
useQuerySortUrlParams?: boolean
|
||||||
isWholePage?: boolean
|
isWholePage?: boolean
|
||||||
|
maxItems?: number
|
||||||
|
noControls?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
|
@ -105,6 +107,8 @@ export function ContractSearch(props: {
|
||||||
useQuerySortLocalStorage,
|
useQuerySortLocalStorage,
|
||||||
useQuerySortUrlParams,
|
useQuerySortUrlParams,
|
||||||
isWholePage,
|
isWholePage,
|
||||||
|
maxItems,
|
||||||
|
noControls,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const [numPages, setNumPages] = useState(1)
|
const [numPages, setNumPages] = useState(1)
|
||||||
|
@ -158,6 +162,8 @@ export function ContractSearch(props: {
|
||||||
const contracts = pages
|
const contracts = pages
|
||||||
.flat()
|
.flat()
|
||||||
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
|
.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) {
|
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||||
return <ContractSearchFirestore additionalFilter={additionalFilter} />
|
return <ContractSearchFirestore additionalFilter={additionalFilter} />
|
||||||
|
@ -175,10 +181,11 @@ export function ContractSearch(props: {
|
||||||
useQuerySortUrlParams={useQuerySortUrlParams}
|
useQuerySortUrlParams={useQuerySortUrlParams}
|
||||||
user={user}
|
user={user}
|
||||||
onSearchParametersChanged={onSearchParametersChanged}
|
onSearchParametersChanged={onSearchParametersChanged}
|
||||||
|
noControls={noControls}
|
||||||
/>
|
/>
|
||||||
<ContractsGrid
|
<ContractsGrid
|
||||||
contracts={pages.length === 0 ? undefined : contracts}
|
contracts={renderedContracts}
|
||||||
loadMore={performQuery}
|
loadMore={noControls ? undefined : performQuery}
|
||||||
showTime={showTime}
|
showTime={showTime}
|
||||||
onContractClick={onContractClick}
|
onContractClick={onContractClick}
|
||||||
highlightOptions={highlightOptions}
|
highlightOptions={highlightOptions}
|
||||||
|
@ -198,6 +205,7 @@ function ContractSearchControls(props: {
|
||||||
useQuerySortLocalStorage?: boolean
|
useQuerySortLocalStorage?: boolean
|
||||||
useQuerySortUrlParams?: boolean
|
useQuerySortUrlParams?: boolean
|
||||||
user?: User | null
|
user?: User | null
|
||||||
|
noControls?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
className,
|
className,
|
||||||
|
@ -209,6 +217,7 @@ function ContractSearchControls(props: {
|
||||||
useQuerySortLocalStorage,
|
useQuerySortLocalStorage,
|
||||||
useQuerySortUrlParams,
|
useQuerySortUrlParams,
|
||||||
user,
|
user,
|
||||||
|
noControls,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const savedSort = useQuerySortLocalStorage ? getSavedSort() : null
|
const savedSort = useQuerySortLocalStorage ? getSavedSort() : null
|
||||||
|
@ -329,6 +338,10 @@ function ContractSearchControls(props: {
|
||||||
})
|
})
|
||||||
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)])
|
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)])
|
||||||
|
|
||||||
|
if (noControls) {
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col
|
<Col
|
||||||
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}
|
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}
|
||||||
|
|
9
web/components/contract/FeaturedContractBadge.tsx
Normal file
9
web/components/contract/FeaturedContractBadge.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -32,6 +32,8 @@ import { groupPath } from 'web/lib/firebase/groups'
|
||||||
import { insertContent } from '../editor/utils'
|
import { insertContent } from '../editor/utils'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { contractMetrics } from 'common/contract-details'
|
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'
|
export type ShowTime = 'resolve-date' | 'close-date'
|
||||||
|
|
||||||
|
@ -72,6 +74,8 @@ export function MiscDetails(props: {
|
||||||
{'Resolved '}
|
{'Resolved '}
|
||||||
{fromNow(resolutionTime || 0)}
|
{fromNow(resolutionTime || 0)}
|
||||||
</Row>
|
</Row>
|
||||||
|
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
|
||||||
|
<FeaturedContractBadge />
|
||||||
) : volume > 0 || !isNew ? (
|
) : volume > 0 || !isNew ? (
|
||||||
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row>
|
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row>
|
||||||
) : (
|
) : (
|
||||||
|
@ -134,6 +138,7 @@ export function AbbrContractDetails(props: {
|
||||||
export function ContractDetails(props: {
|
export function ContractDetails(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
|
user: User | null | undefined
|
||||||
isCreator?: boolean
|
isCreator?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
@ -157,7 +162,7 @@ export function ContractDetails(props: {
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
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">
|
<Row className="items-center gap-2">
|
||||||
<Avatar
|
<Avatar
|
||||||
username={creatorUsername}
|
username={creatorUsername}
|
||||||
|
@ -179,6 +184,8 @@ export function ContractDetails(props: {
|
||||||
<Row>
|
<Row>
|
||||||
{disabled ? (
|
{disabled ? (
|
||||||
groupInfo
|
groupInfo
|
||||||
|
) : !groupToDisplay && !user ? (
|
||||||
|
<div />
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size={'xs'}
|
size={'xs'}
|
||||||
|
@ -206,10 +213,9 @@ export function ContractDetails(props: {
|
||||||
|
|
||||||
{(!!closeTime || !!resolvedDate) && (
|
{(!!closeTime || !!resolvedDate) && (
|
||||||
<Row className="items-center gap-1">
|
<Row className="items-center gap-1">
|
||||||
<ClockIcon className="h-5 w-5" />
|
|
||||||
|
|
||||||
{resolvedDate && contract.resolutionTime ? (
|
{resolvedDate && contract.resolutionTime ? (
|
||||||
<>
|
<>
|
||||||
|
<ClockIcon className="h-5 w-5" />
|
||||||
<DateTimeTooltip
|
<DateTimeTooltip
|
||||||
text="Market resolved:"
|
text="Market resolved:"
|
||||||
time={dayjs(contract.resolutionTime)}
|
time={dayjs(contract.resolutionTime)}
|
||||||
|
@ -219,8 +225,9 @@ export function ContractDetails(props: {
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!resolvedDate && closeTime && (
|
{!resolvedDate && closeTime && user && (
|
||||||
<>
|
<>
|
||||||
|
<ClockIcon className="h-5 w-5" />
|
||||||
<EditableCloseDate
|
<EditableCloseDate
|
||||||
closeTime={closeTime}
|
closeTime={closeTime}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -230,14 +237,15 @@ export function ContractDetails(props: {
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
{user && (
|
||||||
<Row className="items-center gap-1">
|
<>
|
||||||
<DatabaseIcon className="h-5 w-5" />
|
<Row className="items-center gap-1">
|
||||||
|
<DatabaseIcon className="h-5 w-5" />
|
||||||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
<div className="whitespace-nowrap">{volumeLabel}</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
||||||
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
</>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,12 +7,16 @@ import { Bet } from 'common/bet'
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { formatMoney } from 'common/util/format'
|
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 { LiquidityPanel } from '../liquidity-panel'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { Modal } from '../layout/modal'
|
import { Modal } from '../layout/modal'
|
||||||
import { Title } from '../title'
|
import { Title } from '../title'
|
||||||
import { InfoTooltip } from '../info-tooltip'
|
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 =
|
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'
|
'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 { contract, bets } = props
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
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 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
|
contract
|
||||||
|
|
||||||
const tradersCount = uniqBy(
|
const tradersCount = uniqBy(
|
||||||
|
@ -105,10 +114,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
<td>{formatMoney(contract.volume)}</td>
|
<td>{formatMoney(contract.volume)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
{/* <tr>
|
||||||
<td>Creator earnings</td>
|
<td>Creator earnings</td>
|
||||||
<td>{formatMoney(contract.collectedFees.creatorFee)}</td>
|
<td>{formatMoney(contract.collectedFees.creatorFee)}</td>
|
||||||
</tr>
|
</tr> */}
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>Traders</td>
|
<td>Traders</td>
|
||||||
|
@ -121,6 +130,60 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
</td>
|
</td>
|
||||||
<td>{contractPool(contract)}</td>
|
<td>{contractPool(contract)}</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
|
@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
|
||||||
|
|
||||||
return users && users.length > 0 ? (
|
return users && users.length > 0 ? (
|
||||||
<Leaderboard
|
<Leaderboard
|
||||||
title="🏅 Top traders"
|
title="🏅 Top bettors"
|
||||||
users={users || []}
|
users={users || []}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
|
|
|
@ -3,7 +3,6 @@ import clsx from 'clsx'
|
||||||
|
|
||||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { Spacer } from '../layout/spacer'
|
|
||||||
import { ContractProbGraph } from './contract-prob-graph'
|
import { ContractProbGraph } from './contract-prob-graph'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
|
@ -39,52 +38,63 @@ export const ContractOverview = (props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={clsx('mb-6', className)}>
|
<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">
|
<Row className="justify-between gap-4">
|
||||||
<div className="text-2xl text-indigo-700 md:text-3xl">
|
<div className="text-2xl text-indigo-700 md:text-3xl">
|
||||||
<Linkify text={question} />
|
<Linkify text={question} />
|
||||||
</div>
|
</div>
|
||||||
|
<Row className={'hidden gap-3 xl:flex'}>
|
||||||
|
{isBinary && (
|
||||||
|
<BinaryResolutionOrChance
|
||||||
|
className="items-end"
|
||||||
|
contract={contract}
|
||||||
|
large
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isBinary && (
|
{isPseudoNumeric && (
|
||||||
<BinaryResolutionOrChance
|
<PseudoNumericResolutionOrExpectation
|
||||||
className="hidden items-end xl:flex"
|
contract={contract}
|
||||||
contract={contract}
|
className="items-end"
|
||||||
large
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{isPseudoNumeric && (
|
{outcomeType === 'NUMERIC' && (
|
||||||
<PseudoNumericResolutionOrExpectation
|
<NumericResolutionOrExpectation
|
||||||
contract={contract}
|
contract={contract}
|
||||||
className="hidden items-end xl:flex"
|
className="items-end"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</Row>
|
||||||
{outcomeType === 'NUMERIC' && (
|
|
||||||
<NumericResolutionOrExpectation
|
|
||||||
contract={contract}
|
|
||||||
className="hidden items-end xl:flex"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{isBinary ? (
|
{isBinary ? (
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
<BinaryResolutionOrChance contract={contract} />
|
<BinaryResolutionOrChance contract={contract} />
|
||||||
|
|
||||||
{tradingAllowed(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>
|
</Row>
|
||||||
) : isPseudoNumeric ? (
|
) : isPseudoNumeric ? (
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
{tradingAllowed(contract) && (
|
||||||
</Row>
|
<Col>
|
||||||
) : isPseudoNumeric ? (
|
<BetButton contract={contract} />
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
{!user && (
|
||||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
<div className="mt-1 text-center text-sm text-gray-500">
|
||||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
(with play money!)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
) : (
|
) : (
|
||||||
(outcomeType === 'FREE_RESPONSE' ||
|
(outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
@ -107,9 +117,10 @@ export const ContractOverview = (props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={bets}
|
bets={bets}
|
||||||
isCreator={isCreator}
|
isCreator={isCreator}
|
||||||
|
user={user}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Spacer h={4} />
|
<div className={'my-1 md:my-2'}></div>
|
||||||
{(isBinary || isPseudoNumeric) && (
|
{(isBinary || isPseudoNumeric) && (
|
||||||
<ContractProbGraph contract={contract} bets={bets} />
|
<ContractProbGraph contract={contract} bets={bets} />
|
||||||
)}{' '}
|
)}{' '}
|
||||||
|
|
|
@ -58,7 +58,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
|
|
||||||
const numXTickValues = !width || width < 800 ? 2 : 5
|
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)
|
const startDate = dayjs(times[0]).isBefore(hoursAgo)
|
||||||
? times[0]
|
? times[0]
|
||||||
: hoursAgo.toDate()
|
: hoursAgo.toDate()
|
||||||
|
|
|
@ -86,10 +86,12 @@ export function ContractsGrid(props: {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Masonry>
|
</Masonry>
|
||||||
<VisibilityObserver
|
{loadMore && (
|
||||||
onVisibilityUpdated={onVisibilityUpdated}
|
<VisibilityObserver
|
||||||
className="relative -top-96 h-1"
|
onVisibilityUpdated={onVisibilityUpdated}
|
||||||
/>
|
className="relative -top-96 h-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
40
web/components/contract/follow-market-modal.tsx
Normal file
40
web/components/contract/follow-market-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -14,9 +14,10 @@ import { Button } from '../button'
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
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 { SiteLink } from '../site-link'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||||
|
|
||||||
export function ShareModal(props: {
|
export function ShareModal(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { User } from 'common/user'
|
||||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||||
import { ShareModal } from './share-modal'
|
import { ShareModal } from './share-modal'
|
||||||
import { withTracking } from 'web/lib/service/analytics'
|
import { withTracking } from 'web/lib/service/analytics'
|
||||||
|
import { FollowMarketButton } from 'web/components/follow-market-button'
|
||||||
|
|
||||||
export function ShareRow(props: {
|
export function ShareRow(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -25,7 +26,7 @@ export function ShareRow(props: {
|
||||||
const [isShareOpen, setShareOpen] = useState(false)
|
const [isShareOpen, setShareOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="mt-2">
|
<Row className="mt-0.5 sm:mt-2">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
color="gray-white"
|
color="gray-white"
|
||||||
|
@ -62,6 +63,7 @@ export function ShareRow(props: {
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<FollowMarketButton contract={contract} user={user} />
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
import { DuplicateIcon } from '@heroicons/react/outline'
|
import { DuplicateIcon } from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
|
||||||
import { getMappedValue } from 'common/pseudo-numeric'
|
import { getMappedValue } from 'common/pseudo-numeric'
|
||||||
import { contractPath } from 'web/lib/firebase/contracts'
|
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
|
|
||||||
export function DuplicateContractButton(props: {
|
export function DuplicateContractButton(props: {
|
||||||
|
@ -33,22 +31,29 @@ export function DuplicateContractButton(props: {
|
||||||
|
|
||||||
// Pass along the Uri to create a new contract
|
// Pass along the Uri to create a new contract
|
||||||
function duplicateContractHref(contract: 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 = {
|
const params = {
|
||||||
q: contract.question,
|
q: contract.question,
|
||||||
closeTime: contract.closeTime || 0,
|
closeTime,
|
||||||
description:
|
description: descriptionString,
|
||||||
(contract.description ? `${contract.description}\n\n` : '') +
|
|
||||||
`(Copied from https://${ENV_CONFIG.domain}${contractPath(contract)})`,
|
|
||||||
outcomeType: contract.outcomeType,
|
outcomeType: contract.outcomeType,
|
||||||
} as Record<string, any>
|
} as Record<string, any>
|
||||||
|
|
||||||
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
||||||
params.min = contract.min
|
params.min = contract.min
|
||||||
params.max = contract.max
|
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)
|
params.initValue = getMappedValue(contract)(contract.initialProbability)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Support multiple choice markets?
|
||||||
|
|
||||||
if (contract.groupLinks && contract.groupLinks.length > 0) {
|
if (contract.groupLinks && contract.groupLinks.length > 0) {
|
||||||
params.groupId = contract.groupLinks[0].groupId
|
params.groupId = contract.groupLinks[0].groupId
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
JSONContent,
|
JSONContent,
|
||||||
Content,
|
Content,
|
||||||
Editor,
|
Editor,
|
||||||
|
mergeAttributes,
|
||||||
} from '@tiptap/react'
|
} from '@tiptap/react'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import { Image } from '@tiptap/extension-image'
|
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: {
|
HTMLAttributes: {
|
||||||
class: clsx('no-underline !text-indigo-700', linkClass),
|
class: clsx('no-underline !text-indigo-700', linkClass),
|
||||||
},
|
},
|
||||||
|
|
77
web/components/follow-market-button.tsx
Normal file
77
web/components/follow-market-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -163,13 +163,15 @@ export function OrderBookButton(props: {
|
||||||
const { limitBets, contract, className } = props
|
const { limitBets, contract, className } = props
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const yesBets = sortBy(
|
const sortedBets = sortBy(
|
||||||
limitBets.filter((bet) => bet.outcome === 'YES'),
|
limitBets,
|
||||||
(bet) => -1 * bet.limitProb,
|
(bet) => -1 * bet.limitProb,
|
||||||
(bet) => bet.createdTime
|
(bet) => bet.createdTime
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const yesBets = sortedBets.filter((bet) => bet.outcome === 'YES')
|
||||||
const noBets = sortBy(
|
const noBets = sortBy(
|
||||||
limitBets.filter((bet) => bet.outcome === 'NO'),
|
sortedBets.filter((bet) => bet.outcome === 'NO'),
|
||||||
(bet) => bet.limitProb,
|
(bet) => bet.limitProb,
|
||||||
(bet) => bet.createdTime
|
(bet) => bet.createdTime
|
||||||
)
|
)
|
||||||
|
@ -202,7 +204,7 @@ export function OrderBookButton(props: {
|
||||||
</Row>
|
</Row>
|
||||||
<Col className="md:hidden">
|
<Col className="md:hidden">
|
||||||
<LimitOrderTable
|
<LimitOrderTable
|
||||||
limitBets={limitBets}
|
limitBets={sortedBets}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
isYou={false}
|
isYou={false}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
import { Transition, Dialog } from '@headlessui/react'
|
import { Transition, Dialog } from '@headlessui/react'
|
||||||
import { useState, Fragment } from 'react'
|
import { useState, Fragment } from 'react'
|
||||||
import Sidebar, { Item } from './sidebar'
|
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 { formatMoney } from 'common/util/format'
|
||||||
import { Avatar } from '../avatar'
|
import { Avatar } from '../avatar'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
@ -17,8 +17,6 @@ import { useRouter } from 'next/router'
|
||||||
import NotificationsIcon from 'web/components/notifications-icon'
|
import NotificationsIcon from 'web/components/notifications-icon'
|
||||||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
|
||||||
import { PrivateUser } from 'common/user'
|
|
||||||
|
|
||||||
function getNavigation() {
|
function getNavigation() {
|
||||||
return [
|
return [
|
||||||
|
@ -44,7 +42,6 @@ export function BottomNavBar() {
|
||||||
const currentPage = router.pathname
|
const currentPage = router.pathname
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const privateUser = usePrivateUser()
|
|
||||||
|
|
||||||
const isIframe = useIsIframe()
|
const isIframe = useIsIframe()
|
||||||
if (isIframe) {
|
if (isIframe) {
|
||||||
|
@ -85,11 +82,7 @@ export function BottomNavBar() {
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
>
|
>
|
||||||
<MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" />
|
<MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" />
|
||||||
{privateUser ? (
|
More
|
||||||
<MoreMenuWithGroupNotifications privateUser={privateUser} />
|
|
||||||
) : (
|
|
||||||
'More'
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MobileSidebar
|
<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 }) {
|
function NavBarItem(props: { item: Item; currentPage: string }) {
|
||||||
const { item, currentPage } = props
|
const { item, currentPage } = props
|
||||||
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
|
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
|
||||||
|
|
|
@ -12,22 +12,20 @@ import {
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Router, { useRouter } from 'next/router'
|
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 { firebaseLogout, User } from 'web/lib/firebase/users'
|
||||||
import { ManifoldLogo } from './manifold-logo'
|
import { ManifoldLogo } from './manifold-logo'
|
||||||
import { MenuButton } from './menu'
|
import { MenuButton } from './menu'
|
||||||
import { ProfileSummary } from './profile-menu'
|
import { ProfileSummary } from './profile-menu'
|
||||||
import NotificationsIcon from 'web/components/notifications-icon'
|
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 { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||||
import { CreateQuestionButton } from 'web/components/create-question-button'
|
import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
import { groupPath } from 'web/lib/firebase/groups'
|
import { groupPath } from 'web/lib/firebase/groups'
|
||||||
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
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 { 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 { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||||
import { buildArray } from 'common/util/array'
|
import { buildArray } from 'common/util/array'
|
||||||
|
@ -58,7 +56,14 @@ function getNavigation() {
|
||||||
|
|
||||||
function getMoreNavigation(user?: User | null) {
|
function getMoreNavigation(user?: User | null) {
|
||||||
if (IS_PRIVATE_MANIFOLD) {
|
if (IS_PRIVATE_MANIFOLD) {
|
||||||
return [{ name: 'Leaderboards', href: '/leaderboards' }]
|
return [
|
||||||
|
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||||
|
{
|
||||||
|
name: 'Sign out',
|
||||||
|
href: '#',
|
||||||
|
onClick: logout,
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
@ -88,7 +93,7 @@ function getMoreNavigation(user?: User | null) {
|
||||||
href: 'https://salemcenter.manifold.markets/',
|
href: 'https://salemcenter.manifold.markets/',
|
||||||
},
|
},
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ 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',
|
name: 'Sign out',
|
||||||
href: '#',
|
href: '#',
|
||||||
|
@ -102,16 +107,16 @@ const signedOutNavigation = [
|
||||||
{ name: 'Home', href: '/', icon: HomeIcon },
|
{ name: 'Home', href: '/', icon: HomeIcon },
|
||||||
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
||||||
{
|
{
|
||||||
name: 'About',
|
name: 'Help & About',
|
||||||
href: 'https://docs.manifold.markets/$how-to',
|
href: 'https://help.manifold.markets/',
|
||||||
icon: BookOpenIcon,
|
icon: BookOpenIcon,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const signedOutMobileNavigation = [
|
const signedOutMobileNavigation = [
|
||||||
{
|
{
|
||||||
name: 'About',
|
name: 'Help & About',
|
||||||
href: 'https://docs.manifold.markets/$how-to',
|
href: 'https://help.manifold.markets/',
|
||||||
icon: BookOpenIcon,
|
icon: BookOpenIcon,
|
||||||
},
|
},
|
||||||
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
||||||
|
@ -125,8 +130,8 @@ const signedInMobileNavigation = [
|
||||||
? []
|
? []
|
||||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||||
{
|
{
|
||||||
name: 'About',
|
name: 'Help & About',
|
||||||
href: 'https://docs.manifold.markets/$how-to',
|
href: 'https://help.manifold.markets/',
|
||||||
icon: BookOpenIcon,
|
icon: BookOpenIcon,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
@ -221,8 +226,6 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
const currentPage = router.pathname
|
const currentPage = router.pathname
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const privateUser = usePrivateUser()
|
|
||||||
// usePing(user?.id)
|
|
||||||
|
|
||||||
const navigationOptions = !user ? signedOutNavigation : getNavigation()
|
const navigationOptions = !user ? signedOutNavigation : getNavigation()
|
||||||
const mobileNavigationOptions = !user
|
const mobileNavigationOptions = !user
|
||||||
|
@ -230,11 +233,9 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
: signedInMobileNavigation
|
: signedInMobileNavigation
|
||||||
|
|
||||||
const memberItems = (
|
const memberItems = (
|
||||||
useMemberGroups(
|
useMemberGroups(user?.id, undefined, {
|
||||||
user?.id,
|
by: 'mostRecentContractAddedTime',
|
||||||
{ withChatEnabled: true },
|
}) ?? []
|
||||||
{ by: 'mostRecentChatActivityTime' }
|
|
||||||
) ?? []
|
|
||||||
).map((group: Group) => ({
|
).map((group: Group) => ({
|
||||||
name: group.name,
|
name: group.name,
|
||||||
href: `${groupPath(group.slug)}`,
|
href: `${groupPath(group.slug)}`,
|
||||||
|
@ -268,13 +269,7 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
{memberItems.length > 0 && (
|
{memberItems.length > 0 && (
|
||||||
<hr className="!my-4 mr-2 border-gray-300" />
|
<hr className="!my-4 mr-2 border-gray-300" />
|
||||||
)}
|
)}
|
||||||
{privateUser && (
|
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
|
||||||
<GroupsList
|
|
||||||
currentPage={router.asPath}
|
|
||||||
memberItems={memberItems}
|
|
||||||
privateUser={privateUser}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop navigation */}
|
{/* Desktop navigation */}
|
||||||
|
@ -289,46 +284,36 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
|
|
||||||
{/* Spacer if there are any groups */}
|
{/* Spacer if there are any groups */}
|
||||||
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
|
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
|
||||||
{privateUser && (
|
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
|
||||||
<GroupsList
|
|
||||||
currentPage={router.asPath}
|
|
||||||
memberItems={memberItems}
|
|
||||||
privateUser={privateUser}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupsList(props: {
|
function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
|
||||||
currentPage: string
|
const { currentPage, memberItems } = props
|
||||||
memberItems: Item[]
|
|
||||||
privateUser: PrivateUser
|
|
||||||
}) {
|
|
||||||
const { currentPage, memberItems, privateUser } = props
|
|
||||||
const preferredNotifications = useUnseenPreferredNotifications(
|
|
||||||
privateUser,
|
|
||||||
{
|
|
||||||
customHref: '/group/',
|
|
||||||
},
|
|
||||||
memberItems.length > 0 ? memberItems.length : undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
const { height } = useWindowSize()
|
const { height } = useWindowSize()
|
||||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||||
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
|
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
|
||||||
|
|
||||||
const notifIsForThisItem = useMemo(
|
// const preferredNotifications = useUnseenPreferredNotifications(
|
||||||
() => (itemHref: string) =>
|
// privateUser,
|
||||||
preferredNotifications.some(
|
// {
|
||||||
(n) =>
|
// customHref: '/group/',
|
||||||
!n.isSeen &&
|
// },
|
||||||
(n.isSeenOnHref === itemHref ||
|
// memberItems.length > 0 ? memberItems.length : undefined
|
||||||
n.isSeenOnHref?.replace('/chat', '') === itemHref)
|
// )
|
||||||
),
|
// const notifIsForThisItem = useMemo(
|
||||||
[preferredNotifications]
|
// () => (itemHref: string) =>
|
||||||
)
|
// preferredNotifications.some(
|
||||||
|
// (n) =>
|
||||||
|
// !n.isSeen &&
|
||||||
|
// (n.isSeenOnHref === itemHref ||
|
||||||
|
// n.isSeenOnHref?.replace('/chat', '') === itemHref)
|
||||||
|
// ),
|
||||||
|
// [preferredNotifications]
|
||||||
|
// )
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -344,16 +329,12 @@ function GroupsList(props: {
|
||||||
>
|
>
|
||||||
{memberItems.map((item) => (
|
{memberItems.map((item) => (
|
||||||
<a
|
<a
|
||||||
href={
|
href={item.href}
|
||||||
item.href +
|
|
||||||
(notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '')
|
|
||||||
}
|
|
||||||
key={item.name}
|
key={item.name}
|
||||||
onClick={trackCallback('sidebar: ' + item.name)}
|
onClick={trackCallback('click sidebar group', { name: item.name })}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'cursor-pointer truncate',
|
'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',
|
'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'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
|
|
|
@ -14,7 +14,7 @@ export const PortfolioValueSection = memo(
|
||||||
}) {
|
}) {
|
||||||
const { disableSelector, userId } = props
|
const { disableSelector, userId } = props
|
||||||
|
|
||||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
|
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
|
||||||
const [portfolioHistory, setUsersPortfolioHistory] = useState<
|
const [portfolioHistory, setUsersPortfolioHistory] = useState<
|
||||||
PortfolioMetrics[]
|
PortfolioMetrics[]
|
||||||
>([])
|
>([])
|
||||||
|
@ -53,13 +53,15 @@ export const PortfolioValueSection = memo(
|
||||||
{!disableSelector && (
|
{!disableSelector && (
|
||||||
<select
|
<select
|
||||||
className="select select-bordered self-start"
|
className="select select-bordered self-start"
|
||||||
|
value={portfolioPeriod}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPortfolioPeriod(e.target.value as Period)
|
setPortfolioPeriod(e.target.value as Period)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="allTime">{allTimeLabel}</option>
|
<option value="allTime">{allTimeLabel}</option>
|
||||||
<option value="weekly">7 days</option>
|
<option value="weekly">Last 7d</option>
|
||||||
<option value="daily">24 hours</option>
|
{/* Note: 'daily' seems to be broken? */}
|
||||||
|
{/* <option value="daily">Last 24h</option> */}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Col } from 'web/components/layout/col'
|
||||||
import {
|
import {
|
||||||
BETTING_STREAK_BONUS_AMOUNT,
|
BETTING_STREAK_BONUS_AMOUNT,
|
||||||
BETTING_STREAK_BONUS_MAX,
|
BETTING_STREAK_BONUS_MAX,
|
||||||
} from 'common/numeric-constants'
|
} from 'common/economy'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
|
||||||
export function BettingStreakModal(props: {
|
export function BettingStreakModal(props: {
|
||||||
|
@ -16,7 +16,7 @@ export function BettingStreakModal(props: {
|
||||||
<Modal open={isOpen} setOpen={setOpen}>
|
<Modal open={isOpen} setOpen={setOpen}>
|
||||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||||
<span className={'text-8xl'}>🔥</span>
|
<span className={'text-8xl'}>🔥</span>
|
||||||
<span>Daily betting streaks</span>
|
<span className="text-xl">Daily betting streaks</span>
|
||||||
<Col className={'gap-2'}>
|
<Col className={'gap-2'}>
|
||||||
<span className={'text-indigo-700'}>• What are they?</span>
|
<span className={'text-indigo-700'}>• What are they?</span>
|
||||||
<span className={'ml-2'}>
|
<span className={'ml-2'}>
|
||||||
|
|
48
web/components/profile/loans-modal.tsx
Normal file
48
web/components/profile/loans-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -8,10 +8,8 @@ import { Spacer } from './layout/spacer'
|
||||||
import { ResolveConfirmationButton } from './confirmation-button'
|
import { ResolveConfirmationButton } from './confirmation-button'
|
||||||
import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
||||||
import { ProbabilitySelector } from './probability-selector'
|
import { ProbabilitySelector } from './probability-selector'
|
||||||
import { DPM_CREATOR_FEE } from 'common/fees'
|
|
||||||
import { getProbability } from 'common/calculate'
|
import { getProbability } from 'common/calculate'
|
||||||
import { BinaryContract, resolution } from 'common/contract'
|
import { BinaryContract, resolution } from 'common/contract'
|
||||||
import { formatMoney } from 'common/util/format'
|
|
||||||
|
|
||||||
export function ResolutionPanel(props: {
|
export function ResolutionPanel(props: {
|
||||||
creator: User
|
creator: User
|
||||||
|
@ -20,10 +18,10 @@ export function ResolutionPanel(props: {
|
||||||
}) {
|
}) {
|
||||||
const { contract, className } = props
|
const { contract, className } = props
|
||||||
|
|
||||||
const earnedFees =
|
// const earnedFees =
|
||||||
contract.mechanism === 'dpm-2'
|
// contract.mechanism === 'dpm-2'
|
||||||
? `${DPM_CREATOR_FEE * 100}% of trader profits`
|
// ? `${DPM_CREATOR_FEE * 100}% of trader profits`
|
||||||
: `${formatMoney(contract.collectedFees.creatorFee)} in fees`
|
// : `${formatMoney(contract.collectedFees.creatorFee)} in fees`
|
||||||
|
|
||||||
const [outcome, setOutcome] = useState<resolution | undefined>()
|
const [outcome, setOutcome] = useState<resolution | undefined>()
|
||||||
|
|
||||||
|
@ -86,16 +84,16 @@ export function ResolutionPanel(props: {
|
||||||
{outcome === 'YES' ? (
|
{outcome === 'YES' ? (
|
||||||
<>
|
<>
|
||||||
Winnings will be paid out to YES bettors.
|
Winnings will be paid out to YES bettors.
|
||||||
|
{/* <br />
|
||||||
<br />
|
<br />
|
||||||
<br />
|
You will earn {earnedFees}. */}
|
||||||
You will earn {earnedFees}.
|
|
||||||
</>
|
</>
|
||||||
) : outcome === 'NO' ? (
|
) : outcome === 'NO' ? (
|
||||||
<>
|
<>
|
||||||
Winnings will be paid out to NO bettors.
|
Winnings will be paid out to NO bettors.
|
||||||
|
{/* <br />
|
||||||
<br />
|
<br />
|
||||||
<br />
|
You will earn {earnedFees}. */}
|
||||||
You will earn {earnedFees}.
|
|
||||||
</>
|
</>
|
||||||
) : outcome === 'CANCEL' ? (
|
) : outcome === 'CANCEL' ? (
|
||||||
<>All trades will be returned with no fees.</>
|
<>All trades will be returned with no fees.</>
|
||||||
|
@ -106,7 +104,7 @@ export function ResolutionPanel(props: {
|
||||||
probabilityInt={Math.round(prob)}
|
probabilityInt={Math.round(prob)}
|
||||||
setProbabilityInt={setProb}
|
setProbabilityInt={setProb}
|
||||||
/>
|
/>
|
||||||
You will earn {earnedFees}.
|
{/* You will earn {earnedFees}. */}
|
||||||
</Col>
|
</Col>
|
||||||
) : (
|
) : (
|
||||||
<>Resolving this market will immediately pay out traders.</>
|
<>Resolving this market will immediately pay out traders.</>
|
||||||
|
|
|
@ -29,6 +29,8 @@ import { formatMoney } from 'common/util/format'
|
||||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
|
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: {
|
export function UserLink(props: {
|
||||||
name: string
|
name: string
|
||||||
|
@ -67,6 +69,7 @@ export function UserPage(props: { user: User }) {
|
||||||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||||
const [showConfetti, setShowConfetti] = useState(false)
|
const [showConfetti, setShowConfetti] = useState(false)
|
||||||
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
||||||
|
const [showLoansModal, setShowLoansModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const claimedMana = router.query['claimed-mana'] === 'yes'
|
const claimedMana = router.query['claimed-mana'] === 'yes'
|
||||||
|
@ -74,6 +77,9 @@ export function UserPage(props: { user: User }) {
|
||||||
setShowBettingStreakModal(showBettingStreak)
|
setShowBettingStreakModal(showBettingStreak)
|
||||||
setShowConfetti(claimedMana || showBettingStreak)
|
setShowConfetti(claimedMana || showBettingStreak)
|
||||||
|
|
||||||
|
const showLoansModel = router.query['show'] === 'loans'
|
||||||
|
setShowLoansModal(showLoansModel)
|
||||||
|
|
||||||
const query = { ...router.query }
|
const query = { ...router.query }
|
||||||
if (query.claimedMana || query.show) {
|
if (query.claimedMana || query.show) {
|
||||||
delete query['claimed-mana']
|
delete query['claimed-mana']
|
||||||
|
@ -106,6 +112,9 @@ export function UserPage(props: { user: User }) {
|
||||||
isOpen={showBettingStreakModal}
|
isOpen={showBettingStreakModal}
|
||||||
setOpen={setShowBettingStreakModal}
|
setOpen={setShowBettingStreakModal}
|
||||||
/>
|
/>
|
||||||
|
{showLoansModal && (
|
||||||
|
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
||||||
|
)}
|
||||||
{/* Banner image up top, with an circle avatar overlaid */}
|
{/* Banner image up top, with an circle avatar overlaid */}
|
||||||
<div
|
<div
|
||||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
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">
|
<div className="absolute right-0 top-0 mt-2 mr-4">
|
||||||
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
||||||
{isCurrentUser && (
|
{isCurrentUser && (
|
||||||
<SiteLink className="sm:btn-md btn-sm btn" href="/profile">
|
<SiteLink className="btn-sm btn" href="/profile">
|
||||||
<PencilIcon className="h-5 w-5" />{' '}
|
<PencilIcon className="h-5 w-5" />{' '}
|
||||||
<div className="ml-2">Edit</div>
|
<div className="ml-2">Edit</div>
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
|
@ -137,9 +146,14 @@ export function UserPage(props: { user: User }) {
|
||||||
|
|
||||||
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
||||||
<Col className="mx-4 -mt-6">
|
<Col className="mx-4 -mt-6">
|
||||||
<Row className={'justify-between'}>
|
<Row className={'flex-wrap justify-between gap-y-2'}>
|
||||||
<Col>
|
<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>
|
<span className="text-gray-500">@{user.username}</span>
|
||||||
</Col>
|
</Col>
|
||||||
<Col className={'justify-center'}>
|
<Col className={'justify-center'}>
|
||||||
|
@ -159,9 +173,20 @@ export function UserPage(props: { user: User }) {
|
||||||
className={'cursor-pointer items-center text-gray-500'}
|
className={'cursor-pointer items-center text-gray-500'}
|
||||||
onClick={() => setShowBettingStreakModal(true)}
|
onClick={() => setShowBettingStreakModal(true)}
|
||||||
>
|
>
|
||||||
<span>🔥{user.currentBettingStreak ?? 0}</span>
|
<span>🔥 {user.currentBettingStreak ?? 0}</span>
|
||||||
<span>streak</span>
|
<span>streak</span>
|
||||||
</Col>
|
</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>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -226,7 +251,7 @@ export function UserPage(props: { user: User }) {
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
<Spacer h={5} />
|
<Spacer h={5} />
|
||||||
{currentUser?.id === user.id && (
|
{currentUser?.id === user.id && REFERRAL_AMOUNT > 0 && (
|
||||||
<Row
|
<Row
|
||||||
className={
|
className={
|
||||||
'w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
|
'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>
|
<span>
|
||||||
<SiteLink href="/referrals">
|
<SiteLink href="/referrals">
|
||||||
Earn {formatMoney(500)} when you refer a friend!
|
Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend!
|
||||||
</SiteLink>{' '}
|
</SiteLink>{' '}
|
||||||
You have <ReferralsButton user={user} currentUser={currentUser} />
|
You have <ReferralsButton user={user} currentUser={currentUser} />
|
||||||
</span>
|
</span>
|
||||||
|
@ -278,10 +303,7 @@ export function UserPage(props: { user: User }) {
|
||||||
>
|
>
|
||||||
<FollowingButton user={user} />
|
<FollowingButton user={user} />
|
||||||
<FollowersButton user={user} />
|
<FollowersButton user={user} />
|
||||||
{currentUser &&
|
<ReferralsButton user={user} />
|
||||||
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
|
|
||||||
currentUser.username
|
|
||||||
) && <ReferralsButton user={user} />}
|
|
||||||
<GroupsButton user={user} />
|
<GroupsButton user={user} />
|
||||||
</Row>
|
</Row>
|
||||||
),
|
),
|
||||||
|
|
|
@ -5,3 +5,7 @@ export const useAdmin = () => {
|
||||||
const privateUser = usePrivateUser()
|
const privateUser = usePrivateUser()
|
||||||
return isAdmin(privateUser?.email || '')
|
return isAdmin(privateUser?.email || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useDev = () => {
|
||||||
|
return process.env.NODE_ENV === 'development'
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
|
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
|
||||||
|
import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts'
|
||||||
|
|
||||||
export const useFollows = (userId: string | null | undefined) => {
|
export const useFollows = (userId: string | null | undefined) => {
|
||||||
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
||||||
|
@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => {
|
||||||
|
|
||||||
return followerIds
|
return followerIds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useContractFollows = (contractId: string) => {
|
||||||
|
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return listenForContractFollows(contractId, setFollowIds)
|
||||||
|
}, [contractId])
|
||||||
|
|
||||||
|
return followIds
|
||||||
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {
|
||||||
getNotificationsQuery,
|
getNotificationsQuery,
|
||||||
listenForNotifications,
|
listenForNotifications,
|
||||||
} from 'web/lib/firebase/notifications'
|
} from 'web/lib/firebase/notifications'
|
||||||
import { groupBy, map } from 'lodash'
|
import { groupBy, map, partition } from 'lodash'
|
||||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||||
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
||||||
|
|
||||||
|
@ -67,19 +67,14 @@ export function groupNotifications(notifications: Notification[]) {
|
||||||
const notificationGroupsByDay = groupBy(notifications, (notification) =>
|
const notificationGroupsByDay = groupBy(notifications, (notification) =>
|
||||||
new Date(notification.createdTime).toDateString()
|
new Date(notification.createdTime).toDateString()
|
||||||
)
|
)
|
||||||
|
const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus']
|
||||||
|
|
||||||
Object.keys(notificationGroupsByDay).forEach((day) => {
|
Object.keys(notificationGroupsByDay).forEach((day) => {
|
||||||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||||
const incomeNotifications = notificationsGroupedByDay.filter(
|
const [incomeNotifications, normalNotificationsGroupedByDay] = partition(
|
||||||
|
notificationsGroupedByDay,
|
||||||
(notification) =>
|
(notification) =>
|
||||||
notification.sourceType === 'bonus' ||
|
incomeSourceTypes.includes(notification.sourceType ?? '')
|
||||||
notification.sourceType === 'tip' ||
|
|
||||||
notification.sourceType === 'betting_streak_bonus'
|
|
||||||
)
|
|
||||||
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
|
|
||||||
(notification) =>
|
|
||||||
notification.sourceType !== 'bonus' &&
|
|
||||||
notification.sourceType !== 'tip' &&
|
|
||||||
notification.sourceType !== 'betting_streak_bonus'
|
|
||||||
)
|
)
|
||||||
if (incomeNotifications.length > 0) {
|
if (incomeNotifications.length > 0) {
|
||||||
notificationGroups = notificationGroups.concat({
|
notificationGroups = notificationGroups.concat({
|
||||||
|
@ -152,6 +147,7 @@ export function useUnseenPreferredNotifications(
|
||||||
const lessPriorityReasons = [
|
const lessPriorityReasons = [
|
||||||
'on_contract_with_users_comment',
|
'on_contract_with_users_comment',
|
||||||
'on_contract_with_users_answer',
|
'on_contract_with_users_answer',
|
||||||
|
// Notifications not currently generated for users who've sold their shares
|
||||||
'on_contract_with_users_shares_out',
|
'on_contract_with_users_shares_out',
|
||||||
// Not sure if users will want to see these w/ less:
|
// Not sure if users will want to see these w/ less:
|
||||||
// 'on_contract_with_users_shares_in',
|
// 'on_contract_with_users_shares_in',
|
||||||
|
|
|
@ -125,9 +125,10 @@ export async function listTaggedContractsCaseInsensitive(
|
||||||
|
|
||||||
export async function listAllContracts(
|
export async function listAllContracts(
|
||||||
n: number,
|
n: number,
|
||||||
before?: string
|
before?: string,
|
||||||
|
sortDescBy = 'createdTime'
|
||||||
): Promise<Contract[]> {
|
): Promise<Contract[]> {
|
||||||
let q = query(contracts, orderBy('createdTime', 'desc'), limit(n))
|
let q = query(contracts, orderBy(sortDescBy, 'desc'), limit(n))
|
||||||
if (before != null) {
|
if (before != null) {
|
||||||
const snap = await getDoc(doc(contracts, before))
|
const snap = await getDoc(doc(contracts, before))
|
||||||
q = query(q, startAfter(snap))
|
q = query(q, startAfter(snap))
|
||||||
|
@ -211,6 +212,29 @@ export function listenForContract(
|
||||||
return listenForValue<Contract>(contractRef, setContract)
|
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) {
|
function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||||
const fiveMinutes = 5 * 60 * 1000
|
const fiveMinutes = 5 * 60 * 1000
|
||||||
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
||||||
|
@ -271,6 +295,26 @@ export async function getClosingSoonContracts() {
|
||||||
return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime)
|
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) {
|
export async function getRecentBetsAndComments(contract: Contract) {
|
||||||
const contractDoc = doc(contracts, contract.id)
|
const contractDoc = doc(contracts, contract.id)
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -15,7 +15,6 @@
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"format": "npx prettier --write .",
|
"format": "npx prettier --write .",
|
||||||
"postbuild": "next-sitemap",
|
|
||||||
"verify": "(cd .. && yarn verify)",
|
"verify": "(cd .. && yarn verify)",
|
||||||
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
|
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { ArrowLeftIcon } from '@heroicons/react/outline'
|
import { ArrowLeftIcon } from '@heroicons/react/outline'
|
||||||
import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash'
|
|
||||||
|
|
||||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||||
import { ContractOverview } from 'web/components/contract/contract-overview'
|
import { ContractOverview } from 'web/components/contract/contract-overview'
|
||||||
import { BetPanel } from 'web/components/bet-panel'
|
import { BetPanel } from 'web/components/bet-panel'
|
||||||
import { Col } from 'web/components/layout/col'
|
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 { ResolutionPanel } from 'web/components/resolution-panel'
|
||||||
import { Spacer } from 'web/components/layout/spacer'
|
import { Spacer } from 'web/components/layout/spacer'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
getContractFromSlug,
|
getContractFromSlug,
|
||||||
|
getRandTopCreatorContracts,
|
||||||
tradingAllowed,
|
tradingAllowed,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
|
@ -21,9 +21,6 @@ import { listAllComments } from 'web/lib/firebase/comments'
|
||||||
import Custom404 from '../404'
|
import Custom404 from '../404'
|
||||||
import { AnswersPanel } from 'web/components/answers/answers-panel'
|
import { AnswersPanel } from 'web/components/answers/answers-panel'
|
||||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
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 { ContractTabs } from 'web/components/contract/contract-tabs'
|
||||||
import { FullscreenConfetti } from 'web/components/fullscreen-confetti'
|
import { FullscreenConfetti } from 'web/components/fullscreen-confetti'
|
||||||
import { NumericBetPanel } from 'web/components/numeric-bet-panel'
|
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 { CPMMBinaryContract } from 'common/contract'
|
||||||
import { AlertBox } from 'web/components/alert-box'
|
import { AlertBox } from 'web/components/alert-box'
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
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 { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { ContractComment } from 'common/comment'
|
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 { 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 const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz(props: {
|
export async function getStaticPropz(props: {
|
||||||
|
@ -52,9 +51,12 @@ export async function getStaticPropz(props: {
|
||||||
const contract = (await getContractFromSlug(contractSlug)) || null
|
const contract = (await getContractFromSlug(contractSlug)) || null
|
||||||
const contractId = contract?.id
|
const contractId = contract?.id
|
||||||
|
|
||||||
const [bets, comments] = await Promise.all([
|
const [bets, comments, recommendedContracts] = await Promise.all([
|
||||||
contractId ? listAllBets(contractId) : [],
|
contractId ? listAllBets(contractId) : [],
|
||||||
contractId ? listAllComments(contractId) : [],
|
contractId ? listAllComments(contractId) : [],
|
||||||
|
contract
|
||||||
|
? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id])
|
||||||
|
: [],
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
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.
|
// Limit the data sent to the client. Client will still load all bets and comments directly.
|
||||||
bets: bets.slice(0, 5000),
|
bets: bets.slice(0, 5000),
|
||||||
comments: comments.slice(0, 1000),
|
comments: comments.slice(0, 1000),
|
||||||
|
recommendedContracts,
|
||||||
},
|
},
|
||||||
|
|
||||||
revalidate: 60, // regenerate after a minute
|
revalidate: 60, // regenerate after a minute
|
||||||
|
@ -81,6 +84,7 @@ export default function ContractPage(props: {
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: ContractComment[]
|
comments: ContractComment[]
|
||||||
slug: string
|
slug: string
|
||||||
|
recommendedContracts: Contract[]
|
||||||
backToHome?: () => void
|
backToHome?: () => void
|
||||||
}) {
|
}) {
|
||||||
props = usePropz(props, getStaticPropz) ?? {
|
props = usePropz(props, getStaticPropz) ?? {
|
||||||
|
@ -88,6 +92,7 @@ export default function ContractPage(props: {
|
||||||
username: '',
|
username: '',
|
||||||
comments: [],
|
comments: [],
|
||||||
bets: [],
|
bets: [],
|
||||||
|
recommendedContracts: [],
|
||||||
slug: '',
|
slug: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,7 +154,7 @@ export function ContractPageContent(
|
||||||
user?: User | null
|
user?: User | null
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const { backToHome, comments, user } = props
|
const { backToHome, comments, user, recommendedContracts } = props
|
||||||
|
|
||||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||||
|
|
||||||
|
@ -263,128 +268,13 @@ export function ContractPageContent(
|
||||||
comments={comments}
|
comments={comments}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
{recommendedContracts.length > 0 && (
|
||||||
|
<Col className="gap-2 px-2 sm:px-0">
|
||||||
|
<Subtitle text="Recommended" />
|
||||||
|
<ContractsGrid contracts={recommendedContracts} />
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
</Page>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { mapKeys } from 'lodash'
|
||||||
import { useAdmin } from 'web/hooks/use-admin'
|
import { useAdmin } from 'web/hooks/use-admin'
|
||||||
import { contractPath } from 'web/lib/firebase/contracts'
|
import { contractPath } from 'web/lib/firebase/contracts'
|
||||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||||
|
import { firestoreConsolePath } from 'common/envs/constants'
|
||||||
|
|
||||||
export const getServerSideProps = redirectIfLoggedOut('/')
|
export const getServerSideProps = redirectIfLoggedOut('/')
|
||||||
|
|
||||||
|
@ -198,7 +199,7 @@ function ContractsTable() {
|
||||||
html(`<a
|
html(`<a
|
||||||
class="hover:underline hover:decoration-indigo-400 hover:decoration-2"
|
class="hover:underline hover:decoration-indigo-400 hover:decoration-2"
|
||||||
target="_blank"
|
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}
|
search={true}
|
||||||
|
|
|
@ -22,8 +22,6 @@ export type LiteMarket = {
|
||||||
// Market attributes. All times are in milliseconds since epoch
|
// Market attributes. All times are in milliseconds since epoch
|
||||||
closeTime?: number
|
closeTime?: number
|
||||||
question: string
|
question: string
|
||||||
description: string | JSONContent
|
|
||||||
textDescription: string // string version of description
|
|
||||||
tags: string[]
|
tags: string[]
|
||||||
url: string
|
url: string
|
||||||
outcomeType: string
|
outcomeType: string
|
||||||
|
@ -54,6 +52,8 @@ export type FullMarket = LiteMarket & {
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
answers?: ApiAnswer[]
|
answers?: ApiAnswer[]
|
||||||
|
description: string | JSONContent
|
||||||
|
textDescription: string // string version of description
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApiError = {
|
export type ApiError = {
|
||||||
|
@ -81,7 +81,6 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
||||||
creatorAvatarUrl,
|
creatorAvatarUrl,
|
||||||
closeTime,
|
closeTime,
|
||||||
question,
|
question,
|
||||||
description,
|
|
||||||
tags,
|
tags,
|
||||||
slug,
|
slug,
|
||||||
pool,
|
pool,
|
||||||
|
@ -118,11 +117,6 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
||||||
? Math.min(resolutionTime, closeTime)
|
? Math.min(resolutionTime, closeTime)
|
||||||
: closeTime,
|
: closeTime,
|
||||||
question,
|
question,
|
||||||
description,
|
|
||||||
textDescription:
|
|
||||||
typeof description === 'string'
|
|
||||||
? description
|
|
||||||
: richTextToString(description),
|
|
||||||
tags,
|
tags,
|
||||||
url: `https://manifold.markets/${creatorUsername}/${slug}`,
|
url: `https://manifold.markets/${creatorUsername}/${slug}`,
|
||||||
pool,
|
pool,
|
||||||
|
@ -158,11 +152,18 @@ export function toFullMarket(
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
const { description } = contract
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...liteMarket,
|
...liteMarket,
|
||||||
answers,
|
answers,
|
||||||
comments,
|
comments,
|
||||||
bets,
|
bets,
|
||||||
|
description,
|
||||||
|
textDescription:
|
||||||
|
typeof description === 'string'
|
||||||
|
? description
|
||||||
|
: richTextToString(description),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ const queryParams = z
|
||||||
.object({
|
.object({
|
||||||
limit: z
|
limit: z
|
||||||
.number()
|
.number()
|
||||||
.default(1000)
|
.default(500)
|
||||||
.or(z.string().regex(/\d+/).transform(Number))
|
.or(z.string().regex(/\d+/).transform(Number))
|
||||||
.refine((n) => n >= 0 && n <= 1000, 'Limit must be between 0 and 1000'),
|
.refine((n) => n >= 0 && n <= 1000, 'Limit must be between 0 and 1000'),
|
||||||
before: z.string().optional(),
|
before: z.string().optional(),
|
||||||
|
|
|
@ -39,8 +39,8 @@ export async function getStaticProps() {
|
||||||
])
|
])
|
||||||
const matches = quadraticMatches(txns, totalRaised)
|
const matches = quadraticMatches(txns, totalRaised)
|
||||||
const numDonors = uniqBy(txns, (txn) => txn.fromId).length
|
const numDonors = uniqBy(txns, (txn) => txn.fromId).length
|
||||||
const mostRecentDonor = await getUser(txns[0].fromId)
|
const mostRecentDonor = txns[0] ? await getUser(txns[0].fromId) : null
|
||||||
const mostRecentCharity = txns[0].toId
|
const mostRecentCharity = txns[0]?.toId ?? ''
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
@ -94,8 +94,8 @@ export default function Charity(props: {
|
||||||
matches: { [charityId: string]: number }
|
matches: { [charityId: string]: number }
|
||||||
txns: Txn[]
|
txns: Txn[]
|
||||||
numDonors: number
|
numDonors: number
|
||||||
mostRecentDonor: User
|
mostRecentDonor?: User | null
|
||||||
mostRecentCharity: string
|
mostRecentCharity?: string
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
totalRaised,
|
totalRaised,
|
||||||
|
@ -159,8 +159,8 @@ export default function Charity(props: {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Most recent donor',
|
name: 'Most recent donor',
|
||||||
stat: mostRecentDonor.name ?? 'Nobody',
|
stat: mostRecentDonor?.name ?? 'Nobody',
|
||||||
url: `/${mostRecentDonor.username}`,
|
url: `/${mostRecentDonor?.username}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Most recent donation',
|
name: 'Most recent donation',
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { Spacer } from 'web/components/layout/spacer'
|
||||||
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
|
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
|
||||||
import { Contract, contractPath } from 'web/lib/firebase/contracts'
|
import { Contract, contractPath } from 'web/lib/firebase/contracts'
|
||||||
import { createMarket } from 'web/lib/firebase/api'
|
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 { InfoTooltip } from 'web/components/info-tooltip'
|
||||||
import { Page } from 'web/components/page'
|
import { Page } from 'web/components/page'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
|
@ -158,6 +158,8 @@ export function NewContract(props: {
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const balance = creator.balance || 0
|
const balance = creator.balance || 0
|
||||||
|
const deservesFreeMarket =
|
||||||
|
(creator.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
|
||||||
|
|
||||||
const min = minString ? parseFloat(minString) : undefined
|
const min = minString ? parseFloat(minString) : undefined
|
||||||
const max = maxString ? parseFloat(maxString) : undefined
|
const max = maxString ? parseFloat(maxString) : undefined
|
||||||
|
@ -177,7 +179,7 @@ export function NewContract(props: {
|
||||||
question.length > 0 &&
|
question.length > 0 &&
|
||||||
ante !== undefined &&
|
ante !== undefined &&
|
||||||
ante !== null &&
|
ante !== null &&
|
||||||
ante <= balance &&
|
(ante <= balance || deservesFreeMarket) &&
|
||||||
// closeTime must be in the future
|
// closeTime must be in the future
|
||||||
closeTime &&
|
closeTime &&
|
||||||
closeTime > Date.now() &&
|
closeTime > Date.now() &&
|
||||||
|
@ -207,6 +209,7 @@ export function NewContract(props: {
|
||||||
max: MAX_DESCRIPTION_LENGTH,
|
max: MAX_DESCRIPTION_LENGTH,
|
||||||
placeholder: descriptionPlaceholder,
|
placeholder: descriptionPlaceholder,
|
||||||
disabled: isSubmitting,
|
disabled: isSubmitting,
|
||||||
|
defaultValue: JSON.parse(params?.description ?? '{}'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const isEditorFilled = editor != null && !editor.isEmpty
|
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.`}
|
text={`Cost to create your question. This amount is used to subsidize betting.`}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
{ante > balance && !deservesFreeMarket && (
|
||||||
{formatMoney(ante)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ante > balance && (
|
|
||||||
<div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide">
|
<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>
|
<span className="mr-2 text-red-500">Insufficient balance</span>
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -109,6 +109,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={bets}
|
bets={bets}
|
||||||
isCreator={false}
|
isCreator={false}
|
||||||
|
user={null}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
118
web/pages/experimental/home.tsx
Normal file
118
web/pages/experimental/home.tsx
Normal 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
|
|
@ -33,12 +33,9 @@ import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||||
import { toast } from 'react-hot-toast'
|
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 { ContractSearch } from 'web/components/contract-search'
|
||||||
import { FollowList } from 'web/components/follow-list'
|
import { FollowList } from 'web/components/follow-list'
|
||||||
import { SearchIcon } from '@heroicons/react/outline'
|
import { SearchIcon } from '@heroicons/react/outline'
|
||||||
import { useTipTxns } from 'web/hooks/use-tip-txns'
|
|
||||||
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
||||||
import { searchInAny } from 'common/util/parse'
|
import { searchInAny } from 'common/util/parse'
|
||||||
import { CopyLinkButton } from 'web/components/copy-link-button'
|
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 { Button } from 'web/components/button'
|
||||||
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
|
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
|
||||||
import { GroupComment } from 'common/comment'
|
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 const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
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 page = slugs?.[1] as typeof groupSubpages[number]
|
||||||
|
|
||||||
const group = useGroup(props.group?.id) ?? props.group
|
const group = useGroup(props.group?.id) ?? props.group
|
||||||
const tips = useTipTxns({ groupId: group?.id })
|
|
||||||
|
|
||||||
const messages = useCommentsOnGroup(group?.id) ?? props.messages
|
|
||||||
|
|
||||||
const user = useUser()
|
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 = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
title: 'Markets',
|
title: 'Markets',
|
||||||
content: questionsTab,
|
content: questionsTab,
|
||||||
href: groupPath(group.slug, 'markets'),
|
href: groupPath(group.slug, 'markets'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Chat',
|
|
||||||
content: chatTab,
|
|
||||||
href: groupPath(group.slug, 'chat'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Leaderboards',
|
title: 'Leaderboards',
|
||||||
content: leaderboard,
|
content: leaderboard,
|
||||||
|
|
|
@ -71,6 +71,7 @@ const Home = (props: { auth: { user: User } | null }) => {
|
||||||
backToHome={() => {
|
backToHome={() => {
|
||||||
history.back()
|
history.back()
|
||||||
}}
|
}}
|
||||||
|
recommendedContracts={[]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -25,8 +25,8 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||||
import { ManalinkCardFromView } from 'web/components/manalink-card'
|
import { ManalinkCardFromView } from 'web/components/manalink-card'
|
||||||
import { Pagination } from 'web/components/pagination'
|
import { Pagination } from 'web/components/pagination'
|
||||||
import { Manalink } from 'common/manalink'
|
import { Manalink } from 'common/manalink'
|
||||||
import { REFERRAL_AMOUNT } from 'common/user'
|
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
|
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||||
|
|
||||||
const LINKS_PER_PAGE = 24
|
const LINKS_PER_PAGE = 24
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,7 @@ import { groupPath } from 'web/lib/firebase/groups'
|
||||||
import {
|
import {
|
||||||
BETTING_STREAK_BONUS_AMOUNT,
|
BETTING_STREAK_BONUS_AMOUNT,
|
||||||
UNIQUE_BETTOR_BONUS_AMOUNT,
|
UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||||
} from 'common/numeric-constants'
|
} from 'common/economy'
|
||||||
import { groupBy, sum, uniq } from 'lodash'
|
import { groupBy, sum, uniq } from 'lodash'
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
import { Pagination } from 'web/components/pagination'
|
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 { SiteLink } from 'web/components/site-link'
|
||||||
import { NotificationSettings } from 'web/components/NotificationSettings'
|
import { NotificationSettings } from 'web/components/NotificationSettings'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
|
||||||
export const NOTIFICATIONS_PER_PAGE = 30
|
export const NOTIFICATIONS_PER_PAGE = 30
|
||||||
const MULTIPLE_USERS_KEY = 'multipleUsers'
|
const MULTIPLE_USERS_KEY = 'multipleUsers'
|
||||||
|
@ -165,7 +166,7 @@ function NotificationsList(props: {
|
||||||
if (!paginatedGroupedNotifications || !allGroupedNotifications) return <div />
|
if (!paginatedGroupedNotifications || !allGroupedNotifications) return <div />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'min-h-[100vh]'}>
|
<div className={'min-h-[100vh] text-sm'}>
|
||||||
{paginatedGroupedNotifications.length === 0 && (
|
{paginatedGroupedNotifications.length === 0 && (
|
||||||
<div className={'mt-2'}>
|
<div className={'mt-2'}>
|
||||||
You don't have any notifications. Try changing your settings to see
|
You don't have any notifications. Try changing your settings to see
|
||||||
|
@ -271,9 +272,17 @@ function IncomeNotificationGroupItem(props: {
|
||||||
}
|
}
|
||||||
return newNotifications
|
return newNotifications
|
||||||
}
|
}
|
||||||
|
const combinedNotifs = combineNotificationsByAddingNumericSourceTexts(
|
||||||
const combinedNotifs =
|
notifications.filter((n) => n.sourceType !== 'betting_streak_bonus')
|
||||||
combineNotificationsByAddingNumericSourceTexts(notifications)
|
)
|
||||||
|
// 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -370,6 +379,8 @@ function IncomeNotificationItem(props: {
|
||||||
const [highlighted] = useState(!notification.isSeen)
|
const [highlighted] = useState(!notification.isSeen)
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
const isMobile = (width && width < 768) || false
|
const isMobile = (width && width < 768) || false
|
||||||
|
const user = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNotificationsAsSeen([notification])
|
setNotificationsAsSeen([notification])
|
||||||
}, [notification])
|
}, [notification])
|
||||||
|
@ -388,20 +399,30 @@ function IncomeNotificationItem(props: {
|
||||||
reasonText = !simple ? `tipped you on` : `in tips on`
|
reasonText = !simple ? `tipped you on` : `in tips on`
|
||||||
} else if (sourceType === 'betting_streak_bonus') {
|
} else if (sourceType === 'betting_streak_bonus') {
|
||||||
reasonText = 'for your'
|
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 =
|
const bettingStreakText =
|
||||||
sourceType === 'betting_streak_bonus' &&
|
sourceType === 'betting_streak_bonus' &&
|
||||||
(sourceText
|
(sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak')
|
||||||
? `🔥 ${
|
|
||||||
parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT
|
|
||||||
} day Betting Streak`
|
|
||||||
: 'Betting Streak')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{reasonText}
|
{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 ? (
|
simple ? (
|
||||||
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
|
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
|
||||||
) : (
|
) : (
|
||||||
|
@ -445,6 +466,7 @@ function IncomeNotificationItem(props: {
|
||||||
if (sourceType === 'challenge') return `${sourceSlug}`
|
if (sourceType === 'challenge') return `${sourceSlug}`
|
||||||
if (sourceType === 'betting_streak_bonus')
|
if (sourceType === 'betting_streak_bonus')
|
||||||
return `/${sourceUserUsername}/?show=betting-streak`
|
return `/${sourceUserUsername}/?show=betting-streak`
|
||||||
|
if (sourceType === 'loan') return `/${sourceUserUsername}/?show=loans`
|
||||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||||
sourceId ?? '',
|
sourceId ?? '',
|
||||||
|
|
|
@ -5,11 +5,11 @@ import { useUser } from 'web/hooks/use-user'
|
||||||
import { Page } from 'web/components/page'
|
import { Page } from 'web/components/page'
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||||
import { REFERRAL_AMOUNT } from 'common/user'
|
|
||||||
import { CopyLinkButton } from 'web/components/copy-link-button'
|
import { CopyLinkButton } from 'web/components/copy-link-button'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { InfoBox } from 'web/components/info-box'
|
import { InfoBox } from 'web/components/info-box'
|
||||||
import { QRCode } from 'web/components/qr-code'
|
import { QRCode } from 'web/components/qr-code'
|
||||||
|
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||||
|
|
||||||
export const getServerSideProps = redirectIfLoggedOut('/')
|
export const getServerSideProps = redirectIfLoggedOut('/')
|
||||||
|
|
||||||
|
|
|
@ -1,24 +1,21 @@
|
||||||
import { sortBy } from 'lodash'
|
|
||||||
import { GetServerSideProps } from 'next'
|
import { GetServerSideProps } from 'next'
|
||||||
import { getServerSideSitemap, ISitemapField } from 'next-sitemap'
|
import { getServerSideSitemap, ISitemapField } from 'next-sitemap'
|
||||||
|
|
||||||
import { DOMAIN } from 'common/envs/constants'
|
import { listAllContracts } from 'web/lib/firebase/contracts'
|
||||||
import { LiteMarket } from './api/v0/_types'
|
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||||
// Fetching data from https://manifold.markets/api
|
const contracts = await listAllContracts(1000, undefined, 'popularityScore')
|
||||||
const response = await fetch(`https://${DOMAIN}/api/v0/markets`)
|
|
||||||
|
|
||||||
const liteMarkets = (await response.json()) as LiteMarket[]
|
const score = (popularity: number) => Math.tanh(Math.log10(popularity + 1))
|
||||||
const sortedMarkets = sortBy(liteMarkets, (m) => -m.volume24Hours)
|
|
||||||
|
|
||||||
const fields = sortedMarkets.map((market) => ({
|
const fields = contracts
|
||||||
// See https://www.sitemaps.org/protocol.html
|
.sort((x) => x.popularityScore ?? 0)
|
||||||
loc: market.url,
|
.map((market) => ({
|
||||||
changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily',
|
loc: `https://manifold.markets/${market.creatorUsername}/${market.slug}`,
|
||||||
priority: market.volume24Hours + market.volume7Days > 100 ? 0.7 : 0.1,
|
changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily',
|
||||||
// TODO: Add `lastmod` aka last modified time
|
priority: score(market.popularityScore ?? 0),
|
||||||
})) as ISitemapField[]
|
lastmod: market.lastUpdatedTime,
|
||||||
|
})) as ISitemapField[]
|
||||||
|
|
||||||
return await getServerSideSitemap(ctx, fields)
|
return await getServerSideSitemap(ctx, fields)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
|
@ -1,4 +1,10 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<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">
|
||||||
<sitemap><loc>https://manifold.markets/sitemap-0.xml</loc></sitemap>
|
<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
|
||||||
</sitemapindex>
|
<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>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user