Merge branch 'main' into twitch-linking

This commit is contained in:
Marshall Polaris 2022-09-14 01:35:48 -07:00
commit 61d73a8b6d
41 changed files with 1611 additions and 504 deletions

View File

@ -1,10 +1,9 @@
import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm'
import { CPMMContract } from './contract'
import { LiquidityProvision } from './liquidity-provision'
import { User } from './user'
export const getNewLiquidityProvision = (
user: User,
userId: string,
amount: number,
contract: CPMMContract,
newLiquidityProvisionId: string
@ -18,7 +17,7 @@ export const getNewLiquidityProvision = (
const newLiquidityProvision: LiquidityProvision = {
id: newLiquidityProvisionId,
userId: user.id,
userId: userId,
contractId: contract.id,
amount,
pool: newPool,

View File

@ -15,6 +15,12 @@ import { Answer } from './answer'
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id
export const UNIQUE_BETTOR_LIQUIDITY_AMOUNT = 20
type NormalizedBet<T extends Bet = Bet> = Omit<
T,
'userAvatarUrl' | 'userName' | 'userUsername'
>
export function getCpmmInitialLiquidity(
providerId: string,
@ -51,7 +57,7 @@ export function getAnteBets(
const { createdTime } = contract
const yesBet: Bet = {
const yesBet: NormalizedBet = {
id: yesAnteId,
userId: creator.id,
contractId: contract.id,
@ -65,7 +71,7 @@ export function getAnteBets(
fees: noFees,
}
const noBet: Bet = {
const noBet: NormalizedBet = {
id: noAnteId,
userId: creator.id,
contractId: contract.id,
@ -93,7 +99,7 @@ export function getFreeAnswerAnte(
const { createdTime } = contract
const anteBet: Bet = {
const anteBet: NormalizedBet = {
id: anteBetId,
userId: anteBettorId,
contractId: contract.id,
@ -123,7 +129,7 @@ export function getMultipleChoiceAntes(
const { createdTime } = contract
const bets: Bet[] = answers.map((answer, i) => ({
const bets: NormalizedBet[] = answers.map((answer, i) => ({
id: betDocIds[i],
userId: creator.id,
contractId: contract.id,
@ -173,7 +179,7 @@ export function getNumericAnte(
range(0, bucketCount).map((_, i) => [i, betAnte])
)
const anteBet: NumericBet = {
const anteBet: NormalizedBet<NumericBet> = {
id: newBetId,
userId: anteBettorId,
contractId: contract.id,

View File

@ -3,6 +3,12 @@ import { Fees } from './fees'
export type Bet = {
id: string
userId: string
// denormalized for bet lists
userAvatarUrl?: string
userUsername: string
userName: string
contractId: string
createdTime: number

View File

@ -31,7 +31,10 @@ import {
floatingLesserEqual,
} from './util/math'
export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'>
export type CandidateBet<T extends Bet = Bet> = Omit<
T,
'id' | 'userId' | 'userAvatarUrl' | 'userName' | 'userUsername'
>
export type BetInfo = {
newBet: CandidateBet
newPool?: { [outcome: string]: number }
@ -322,7 +325,7 @@ export const getNewBinaryDpmBetInfo = (
export const getNewMultiBetInfo = (
outcome: string,
amount: number,
contract: FreeResponseContract | MultipleChoiceContract,
contract: FreeResponseContract | MultipleChoiceContract
) => {
const { pool, totalShares, totalBets } = contract

View File

@ -9,7 +9,10 @@ import { CPMMContract, DPMContract } from './contract'
import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees'
import { sumBy } from 'lodash'
export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
export type CandidateBet<T extends Bet> = Omit<
T,
'id' | 'userId' | 'userAvatarUrl' | 'userName' | 'userUsername'
>
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
const { pool, totalShares, totalBets } = contract

View File

@ -1,11 +1,16 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { Contract } from '../../common/contract'
import { Contract, CPMMContract } from '../../common/contract'
import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object'
import { getNewLiquidityProvision } from '../../common/add-liquidity'
import { APIError, newEndpoint, validate } from './api'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { isProd } from './utils'
const bodySchema = z.object({
contractId: z.string(),
@ -47,7 +52,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
getNewLiquidityProvision(
user,
user.id,
amount,
contract,
newLiquidityProvisionDoc.id
@ -88,3 +93,41 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
})
const firestore = admin.firestore()
export const addHouseLiquidity = (contract: CPMMContract, amount: number) => {
return firestore.runTransaction(async (transaction) => {
const newLiquidityProvisionDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
const providerId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
getNewLiquidityProvision(
providerId,
amount,
contract,
newLiquidityProvisionDoc.id
)
if (newP !== undefined && !isFinite(newP)) {
throw new APIError(
500,
'Liquidity injection rejected due to overflow error.'
)
}
transaction.update(
firestore.doc(`contracts/${contract.id}`),
removeUndefinedProps({
pool: newPool,
p: newP,
totalLiquidity: newTotalLiquidity,
})
)
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
})
}

View File

@ -2,6 +2,7 @@ import * as admin from 'firebase-admin'
import { z } from 'zod'
import { getUser } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { Comment } from '../../common/comment'
import { User } from '../../common/user'
@ -68,10 +69,21 @@ export const changeUser = async (
.get()
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
const betsSnap = await firestore
.collectionGroup('bets')
.where('userId', '==', user.id)
.get()
const betsUpdate: Partial<Bet> = removeUndefinedProps({
userName: update.name,
userUsername: update.username,
userAvatarUrl: update.avatarUrl,
})
const bulkWriter = firestore.bulkWriter()
commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate))
answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate))
contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate))
betsSnap.docs.forEach((d) => bulkWriter.update(d.ref, betsUpdate))
await bulkWriter.flush()
console.log('Done writing!')

View File

@ -8,7 +8,7 @@ import { User } from '../../common/user'
import { Contract } from '../../common/contract'
import { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment'
import { uniq } from 'lodash'
import { groupBy, uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
import { Answer } from '../../common/answer'
import { getContractBetMetrics } from '../../common/calculate'
@ -23,6 +23,7 @@ import {
sendNewAnswerEmail,
sendNewCommentEmail,
sendNewFollowedMarketEmail,
sendNewUniqueBettorsEmail,
} from './emails'
import { filterDefined } from '../../common/util/array'
const firestore = admin.firestore()
@ -774,44 +775,84 @@ export const createUniqueBettorBonusNotification = async (
txnId: string,
contract: Contract,
amount: number,
uniqueBettorIds: string[],
idempotencyKey: string
) => {
console.log('createUniqueBettorBonusNotification')
const privateUser = await getPrivateUser(contractCreatorId)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
privateUser,
'unique_bettors_on_your_contract'
)
if (!sendToBrowser) return
const notificationRef = firestore
.collection(`/users/${contractCreatorId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: contractCreatorId,
reason: 'unique_bettors_on_your_contract',
createdTime: Date.now(),
isSeen: false,
sourceId: txnId,
sourceType: 'bonus',
sourceUpdateType: 'created',
sourceUserName: bettor.name,
sourceUserUsername: bettor.username,
sourceUserAvatarUrl: bettor.avatarUrl,
sourceText: amount.toString(),
sourceSlug: contract.slug,
sourceTitle: contract.question,
// Perhaps not necessary, but just in case
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
sourceContractTitle: contract.question,
sourceContractCreatorUsername: contract.creatorUsername,
if (sendToBrowser) {
const notificationRef = firestore
.collection(`/users/${contractCreatorId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: contractCreatorId,
reason: 'unique_bettors_on_your_contract',
createdTime: Date.now(),
isSeen: false,
sourceId: txnId,
sourceType: 'bonus',
sourceUpdateType: 'created',
sourceUserName: bettor.name,
sourceUserUsername: bettor.username,
sourceUserAvatarUrl: bettor.avatarUrl,
sourceText: amount.toString(),
sourceSlug: contract.slug,
sourceTitle: contract.question,
// Perhaps not necessary, but just in case
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
sourceContractTitle: contract.question,
sourceContractCreatorUsername: contract.creatorUsername,
}
await notificationRef.set(removeUndefinedProps(notification))
}
return await notificationRef.set(removeUndefinedProps(notification))
// TODO send email notification
if (!sendToEmail) return
const uniqueBettorsExcludingCreator = uniqueBettorIds.filter(
(id) => id !== contractCreatorId
)
// only send on 1st and 6th bettor
if (
uniqueBettorsExcludingCreator.length !== 1 &&
uniqueBettorsExcludingCreator.length !== 6
)
return
const totalNewBettorsToReport =
uniqueBettorsExcludingCreator.length === 1 ? 1 : 5
const mostRecentUniqueBettors = await getValues<User>(
firestore
.collection('users')
.where(
'id',
'in',
uniqueBettorsExcludingCreator.slice(
uniqueBettorsExcludingCreator.length - totalNewBettorsToReport,
uniqueBettorsExcludingCreator.length
)
)
)
const bets = await getValues<Bet>(
firestore.collection('contracts').doc(contract.id).collection('bets')
)
// group bets by bettors
const bettorsToTheirBets = groupBy(bets, (bet) => bet.userId)
await sendNewUniqueBettorsEmail(
'unique_bettors_on_your_contract',
contractCreatorId,
privateUser,
contract,
uniqueBettorsExcludingCreator.length,
mostRecentUniqueBettors,
bettorsToTheirBets,
Math.round(amount * totalNewBettorsToReport)
)
}
export const createNewContractNotification = async (

View File

@ -0,0 +1,397 @@
<!DOCTYPE html>
<html style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>New unique predictors on your market</title>
<style type="text/css">
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
}
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
" bgcolor="#f6f6f6">
<table class="body-wrap" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
" bgcolor="#f6f6f6">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
" valign="top"></td>
<td class="container" width="600" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
display: block !important;
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
" valign="top">
<div class="content" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
max-width: 600px;
display: block;
margin: 0 auto;
padding: 20px;
">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
border-radius: 3px;
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
" bgcolor="#fff">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
">
<td class="content-wrap aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 20px;
" align="center" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 0 0 0px 0;
text-align: left;
" valign="top">
<a href="https://manifold.markets" target="_blank">
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
alt="Manifold Markets" />
</a>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
</span>Hi {{name}},</p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> just got its first prediction from a user!
<br/>
<br/>
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for
creating a market that appeals to others, and we'll do so for each new predictor.
<br/>
<br/>
Keep up the good work and check out your newest predictor below!
</span></p>
</div>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
">
<td class="content-block aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 0;
" align="center" valign="top">
<table class="invoice" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
text-align: left;
width: 80%;
margin: 40px auto;
margin-top: 10px;
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin-top: 10px;
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 10px 0;
" valign="top">
<div>
<img src="{{bettor1AvatarUrl}}" width="30" height="30" style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
" alt="" />
<span style="font-weight: bold">{{bettor1Name}}</span>
{{bet1Description}}
</div>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
">
<td style="padding: 20px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a href="{{marketUrl}}" target="_blank" style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
text-decoration: none;
-webkit-text-size-adjust: none;
text-align: center;
color: #ffffff;
background-color: #11b981;
border-radius: 4px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
width: auto;
max-width: 100%;
overflow-wrap: break-word;
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
">
<span style="
display: block;
padding: 10px 20px;
line-height: 120%;
"><span style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
">View market</span></span>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div class="footer" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
clear: both;
color: #999;
margin: 0;
padding: 20px;
">
<table width="100%" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
">
<td class="aligncenter content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
vertical-align: top;
color: #999;
text-align: center;
margin: 0;
padding: 0 0 20px;
" align="center" valign="top">
Questions? Come ask in
<a href="https://discord.gg/eHQBNBqXuh" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</td>
</tr>
</table>
</div>
</div>
</td>
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
" valign="top"></td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,501 @@
<!DOCTYPE html>
<html style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>New unique predictors on your market</title>
<style type="text/css">
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
}
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
" bgcolor="#f6f6f6">
<table class="body-wrap" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
" bgcolor="#f6f6f6">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
" valign="top"></td>
<td class="container" width="600" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
display: block !important;
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
" valign="top">
<div class="content" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
max-width: 600px;
display: block;
margin: 0 auto;
padding: 20px;
">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
border-radius: 3px;
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
" bgcolor="#fff">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
">
<td class="content-wrap aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 20px;
" align="center" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
margin: 0;
padding: 0 0 0px 0;
text-align: left;
" valign="top">
<a href="https://manifold.markets" target="_blank">
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
alt="Manifold Markets" />
</a>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
</span>Hi {{name}},</p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> got predictions from a total of {{totalPredictors}} users!
<br/>
<br/>
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new predictors,
and we'll continue to do so for each new predictor, (although we won't send you any more emails about it for this market).
<br/>
<br/>
Keep up the good work and check out your newest predictors below!
</span></p>
</div>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
">
<td class="content-block aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
vertical-align: top;
text-align: center;
margin: 0;
padding: 0;
" align="center" valign="top">
<table class="invoice" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
text-align: left;
width: 80%;
margin: 40px auto;
margin-top: 10px;
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin-top: 10px;
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 10px 0;
" valign="top">
<div>
<img src="{{bettor1AvatarUrl}}" width="30" height="30" style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
" alt="" />
<span style="font-weight: bold">{{bettor1Name}}</span>
{{bet1Description}}
</div>
</td>
</tr><tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin-top: 10px;
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 10px 0;
" valign="top">
<div>
<img src="{{bettor2AvatarUrl}}" width="30" height="30" style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
" alt="" />
<span style="font-weight: bold">{{bettor2Name}}</span>
{{bet2Description}}
</div>
</td>
</tr><tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin-top: 10px;
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 10px 0;
" valign="top">
<div>
<img src="{{bettor3AvatarUrl}}" width="30" height="30" style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
" alt="" />
<span style="font-weight: bold">{{bettor3Name}}</span>
{{bet3Description}}
</div>
</td>
</tr><tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin-top: 10px;
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 10px 0;
" valign="top">
<div>
<img src="{{bettor4AvatarUrl}}" width="30" height="30" style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
" alt="" />
<span style="font-weight: bold">{{bettor4Name}}</span>
{{bet4Description}}
</div>
</td>
</tr><tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin-top: 10px;
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 10px 0;
" valign="top">
<div>
<img src="{{bettor5AvatarUrl}}" width="30" height="30" style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
" alt="" />
<span style="font-weight: bold">{{bettor5Name}}</span>
{{bet5Description}}
</div>
</td>
</tr>
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
">
<td style="padding: 20px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a href="{{marketUrl}}" target="_blank" style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
text-decoration: none;
-webkit-text-size-adjust: none;
text-align: center;
color: #ffffff;
background-color: #11b981;
border-radius: 4px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
width: auto;
max-width: 100%;
overflow-wrap: break-word;
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
">
<span style="
display: block;
padding: 10px 20px;
line-height: 120%;
"><span style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
">View market</span></span>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div class="footer" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
clear: both;
color: #999;
margin: 0;
padding: 20px;
">
<table width="100%" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
">
<td class="aligncenter content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
vertical-align: top;
color: #999;
text-align: center;
margin: 0;
padding: 0 0 20px;
" align="center" valign="top">
Questions? Come ask in
<a href="https://discord.gg/eHQBNBqXuh" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</td>
</tr>
</table>
</div>
</div>
</td>
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
" valign="top"></td>
</tr>
</table>
</body>
</html>

View File

@ -22,6 +22,7 @@ import {
notification_reason_types,
getDestinationsForUser,
} from '../../common/notification'
import { Dictionary } from 'lodash'
export const sendMarketResolutionEmail = async (
reason: notification_reason_types,
@ -544,3 +545,63 @@ export const sendNewFollowedMarketEmail = async (
}
)
}
export const sendNewUniqueBettorsEmail = async (
reason: notification_reason_types,
userId: string,
privateUser: PrivateUser,
contract: Contract,
totalPredictors: number,
newPredictors: User[],
userBets: Dictionary<[Bet, ...Bet[]]>,
bonusAmount: number
) => {
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
await getDestinationsForUser(privateUser, reason)
if (!privateUser.email || !sendToEmail) return
const user = await getUser(privateUser.id)
if (!user) return
const { name } = user
const firstName = name.split(' ')[0]
const creatorName = contract.creatorName
// make the emails stack for the same contract
const subject = `You made a popular market! ${
contract.question.length > 50
? contract.question.slice(0, 50) + '...'
: contract.question
} just got ${
newPredictors.length
} new predictions. Check out who's predicting on it inside.`
const templateData: Record<string, string> = {
name: firstName,
creatorName,
totalPredictors: totalPredictors.toString(),
bonusString: formatMoney(bonusAmount),
marketTitle: contract.question,
marketUrl: contractUrl(contract),
unsubscribeUrl,
newPredictors: newPredictors.length.toString(),
}
newPredictors.forEach((p, i) => {
templateData[`bettor${i + 1}Name`] = p.name
if (p.avatarUrl) templateData[`bettor${i + 1}AvatarUrl`] = p.avatarUrl
const bet = userBets[p.id][0]
if (bet) {
const { amount, sale } = bet
templateData[`bet${i + 1}Description`] = `${
sale || amount < 0 ? 'sold' : 'bought'
} ${formatMoney(Math.abs(amount))}`
}
})
return await sendTemplateEmail(
privateUser.email,
subject,
newPredictors.length === 1 ? 'new-unique-bettor' : 'new-unique-bettors',
templateData,
{
from: `Manifold Markets <no-reply@manifold.markets>`,
}
)
}

View File

@ -24,12 +24,15 @@ import {
} from '../../common/antes'
import { APIError } from '../../common/api'
import { User } from '../../common/user'
import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes'
import { addHouseLiquidity } from './add-liquidity'
const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
export const onCreateBet = functions.firestore
.document('contracts/{contractId}/bets/{betId}')
export const onCreateBet = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.firestore.document('contracts/{contractId}/bets/{betId}')
.onCreate(async (change, context) => {
const { contractId } = context.params as {
contractId: string
@ -58,6 +61,12 @@ export const onCreateBet = functions.firestore
const bettor = await getUser(bet.userId)
if (!bettor) return
await change.ref.update({
userAvatarUrl: bettor.avatarUrl,
userName: bettor.name,
userUsername: bettor.username,
})
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor)
await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId)
@ -148,18 +157,23 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
}
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
// Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await firestore.collection(`contracts`).doc(contract.id).update({
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
})
}
if (contract.mechanism === 'cpmm-1' && isNewUniqueBettor) {
await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT)
}
// No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
@ -198,6 +212,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
result.txn.id,
contract,
result.txn.amount,
newUniqueBettorIds,
eventId + '-unique-bettor-bonus'
)
}

View File

@ -7,6 +7,7 @@ import { FIXED_ANTE } from '../../common/economy'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
UNIQUE_BETTOR_LIQUIDITY_AMOUNT,
} from '../../common/antes'
export const onCreateLiquidityProvision = functions.firestore
@ -17,9 +18,11 @@ export const onCreateLiquidityProvision = functions.firestore
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
if (
(liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
liquidity.isAnte ||
((liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) &&
liquidity.amount === FIXED_ANTE
(liquidity.amount === FIXED_ANTE ||
liquidity.amount === UNIQUE_BETTOR_LIQUIDITY_AMOUNT))
)
return

View File

@ -139,7 +139,14 @@ export const placebet = newEndpoint({}, async (req, auth) => {
}
const betDoc = contractDoc.collection('bets').doc()
trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet })
trans.create(betDoc, {
id: betDoc.id,
userId: user.id,
userAvatarUrl: user.avatarUrl,
userUsername: user.username,
userName: user.name,
...newBet,
})
log('Created new bet document.')
if (makers) {

View File

@ -3,12 +3,7 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import {
DocumentCorrespondence,
findDiffs,
describeDiff,
applyDiff,
} from './denormalize'
import { findDiffs, describeDiff, applyDiff } from './denormalize'
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
initAdmin()
@ -79,43 +74,36 @@ if (require.main === module) {
getAnswersByUserId(transaction),
])
const usersContracts = Array.from(
usersById.entries(),
([id, doc]): DocumentCorrespondence => {
return [doc, contractsByUserId.get(id) || []]
}
)
const contractDiffs = findDiffs(
usersContracts,
const usersContracts = Array.from(usersById.entries(), ([id, doc]) => {
return [doc, contractsByUserId.get(id) || []] as const
})
const contractDiffs = findDiffs(usersContracts, [
'avatarUrl',
'creatorAvatarUrl'
)
'creatorAvatarUrl',
])
console.log(`Found ${contractDiffs.length} contracts with mismatches.`)
contractDiffs.forEach((d) => {
console.log(describeDiff(d))
applyDiff(transaction, d)
})
const usersComments = Array.from(
usersById.entries(),
([id, doc]): DocumentCorrespondence => {
return [doc, commentsByUserId.get(id) || []]
}
)
const commentDiffs = findDiffs(usersComments, 'avatarUrl', 'userAvatarUrl')
const usersComments = Array.from(usersById.entries(), ([id, doc]) => {
return [doc, commentsByUserId.get(id) || []] as const
})
const commentDiffs = findDiffs(usersComments, [
'avatarUrl',
'userAvatarUrl',
])
console.log(`Found ${commentDiffs.length} comments with mismatches.`)
commentDiffs.forEach((d) => {
console.log(describeDiff(d))
applyDiff(transaction, d)
})
const usersAnswers = Array.from(
usersById.entries(),
([id, doc]): DocumentCorrespondence => {
return [doc, answersByUserId.get(id) || []]
}
)
const answerDiffs = findDiffs(usersAnswers, 'avatarUrl', 'avatarUrl')
const usersAnswers = Array.from(usersById.entries(), ([id, doc]) => {
return [doc, answersByUserId.get(id) || []] as const
})
const answerDiffs = findDiffs(usersAnswers, ['avatarUrl', 'avatarUrl'])
console.log(`Found ${answerDiffs.length} answers with mismatches.`)
answerDiffs.forEach((d) => {
console.log(describeDiff(d))

View File

@ -0,0 +1,38 @@
// Filling in the user-based fields on bets.
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { findDiffs, describeDiff, getDiffUpdate } from './denormalize'
import { log, writeAsync } from '../utils'
initAdmin()
const firestore = admin.firestore()
// not in a transaction for speed -- may need to be run more than once
async function denormalize() {
const users = await firestore.collection('users').get()
log(`Found ${users.size} users.`)
for (const userDoc of users.docs) {
const userBets = await firestore
.collectionGroup('bets')
.where('userId', '==', userDoc.id)
.get()
const mapping = [[userDoc, userBets.docs] as const] as const
const diffs = findDiffs(
mapping,
['avatarUrl', 'userAvatarUrl'],
['name', 'userName'],
['username', 'userUsername']
)
log(`Found ${diffs.length} bets with mismatched user data.`)
const updates = diffs.map((d) => {
log(describeDiff(d))
return getDiffUpdate(d)
})
await writeAsync(firestore, updates)
}
}
if (require.main === module) {
denormalize().catch((e) => console.error(e))
}

View File

@ -3,12 +3,7 @@
import * as admin from 'firebase-admin'
import { zip } from 'lodash'
import { initAdmin } from './script-init'
import {
DocumentCorrespondence,
findDiffs,
describeDiff,
applyDiff,
} from './denormalize'
import { findDiffs, describeDiff, applyDiff } from './denormalize'
import { log } from '../utils'
import { Transaction } from 'firebase-admin/firestore'
@ -41,17 +36,20 @@ async function denormalize() {
)
)
log(`Found ${bets.length} bets associated with comments.`)
const mapping = zip(bets, betComments)
.map(([bet, comment]): DocumentCorrespondence => {
return [bet!, [comment!]] // eslint-disable-line
})
.filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs
const amountDiffs = findDiffs(mapping, 'amount', 'betAmount')
const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome')
log(`Found ${amountDiffs.length} comments with mismatched amounts.`)
log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`)
const diffs = amountDiffs.concat(outcomeDiffs)
// dev DB has some invalid bet IDs
const mapping = zip(bets, betComments)
.filter(([bet, _]) => bet!.exists) // eslint-disable-line
.map(([bet, comment]) => {
return [bet!, [comment!]] as const // eslint-disable-line
})
const diffs = findDiffs(
mapping,
['amount', 'betAmount'],
['outcome', 'betOutcome']
)
log(`Found ${diffs.length} comments with mismatched data.`)
diffs.slice(0, 500).forEach((d) => {
log(describeDiff(d))
applyDiff(trans, d)

View File

@ -2,12 +2,7 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import {
DocumentCorrespondence,
findDiffs,
describeDiff,
applyDiff,
} from './denormalize'
import { findDiffs, describeDiff, applyDiff } from './denormalize'
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
initAdmin()
@ -43,16 +38,15 @@ async function denormalize() {
getContractsById(transaction),
getCommentsByContractId(transaction),
])
const mapping = Object.entries(contractsById).map(
([id, doc]): DocumentCorrespondence => {
return [doc, commentsByContractId.get(id) || []]
}
const mapping = Object.entries(contractsById).map(([id, doc]) => {
return [doc, commentsByContractId.get(id) || []] as const
})
const diffs = findDiffs(
mapping,
['slug', 'contractSlug'],
['question', 'contractQuestion']
)
const slugDiffs = findDiffs(mapping, 'slug', 'contractSlug')
const qDiffs = findDiffs(mapping, 'question', 'contractQuestion')
console.log(`Found ${slugDiffs.length} comments with mismatched slugs.`)
console.log(`Found ${qDiffs.length} comments with mismatched questions.`)
const diffs = slugDiffs.concat(qDiffs)
console.log(`Found ${diffs.length} comments with mismatched data.`)
diffs.slice(0, 500).forEach((d) => {
console.log(describeDiff(d))
applyDiff(transaction, d)

View File

@ -2,32 +2,40 @@
// another set of documents.
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
import { isEqual, zip } from 'lodash'
import { UpdateSpec } from '../utils'
export type DocumentValue = {
doc: DocumentSnapshot
field: string
val: unknown
fields: string[]
vals: unknown[]
}
export type DocumentCorrespondence = [DocumentSnapshot, DocumentSnapshot[]]
export type DocumentMapping = readonly [
DocumentSnapshot,
readonly DocumentSnapshot[]
]
export type DocumentDiff = {
src: DocumentValue
dest: DocumentValue
}
type PathPair = readonly [string, string]
export function findDiffs(
docs: DocumentCorrespondence[],
srcPath: string,
destPath: string
docs: readonly DocumentMapping[],
...paths: PathPair[]
) {
const diffs: DocumentDiff[] = []
const srcPaths = paths.map((p) => p[0])
const destPaths = paths.map((p) => p[1])
for (const [srcDoc, destDocs] of docs) {
const srcVal = srcDoc.get(srcPath)
const srcVals = srcPaths.map((p) => srcDoc.get(p))
for (const destDoc of destDocs) {
const destVal = destDoc.get(destPath)
if (destVal !== srcVal) {
const destVals = destPaths.map((p) => destDoc.get(p))
if (!isEqual(srcVals, destVals)) {
diffs.push({
src: { doc: srcDoc, field: srcPath, val: srcVal },
dest: { doc: destDoc, field: destPath, val: destVal },
src: { doc: srcDoc, fields: srcPaths, vals: srcVals },
dest: { doc: destDoc, fields: destPaths, vals: destVals },
})
}
}
@ -37,12 +45,19 @@ export function findDiffs(
export function describeDiff(diff: DocumentDiff) {
function describeDocVal(x: DocumentValue): string {
return `${x.doc.ref.path}.${x.field}: ${x.val}`
return `${x.doc.ref.path}.[${x.fields.join('|')}]: [${x.vals.join('|')}]`
}
return `${describeDocVal(diff.src)} -> ${describeDocVal(diff.dest)}`
}
export function applyDiff(transaction: Transaction, diff: DocumentDiff) {
const { src, dest } = diff
transaction.update(dest.doc.ref, dest.field, src.val)
export function getDiffUpdate(diff: DocumentDiff) {
return {
doc: diff.dest.doc.ref,
fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)),
} as UpdateSpec
}
export function applyDiff(transaction: Transaction, diff: DocumentDiff) {
const update = getDiffUpdate(diff)
transaction.update(update.doc, update.fields)
}

View File

@ -112,6 +112,9 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
transaction.create(newBetDoc, {
id: newBetDoc.id,
userId: user.id,
userAvatarUrl: user.avatarUrl,
userUsername: user.username,
userName: user.name,
...newBet,
})
transaction.update(

View File

@ -111,9 +111,9 @@ export const getHomeItems = (groups: Group[], sections: string[]) => {
if (!isArray(sections)) sections = []
const items = [
{ label: 'Daily movers', id: 'daily-movers' },
{ label: 'Trending', id: 'score' },
{ label: 'New for you', id: 'newest' },
{ label: 'Daily movers', id: 'daily-movers' },
...groups.map((g) => ({
label: g.name,
id: g.id,

View File

@ -754,7 +754,10 @@ function SellButton(props: {
)
}
function ProfitBadge(props: { profitPercent: number; className?: string }) {
export function ProfitBadge(props: {
profitPercent: number
className?: string
}) {
const { profitPercent, className } = props
if (!profitPercent) return null
const colors =

View File

@ -200,7 +200,7 @@ export function ContractSearch(props: {
}
return (
<Col className="h-full">
<Col>
<ContractSearchControls
className={headerClassName}
defaultSort={defaultSort}

View File

@ -0,0 +1,102 @@
import { Contract } from 'common/contract'
import { useState } from 'react'
import { Button } from './button'
import { ContractSearch } from './contract-search'
import { Col } from './layout/col'
import { Modal } from './layout/modal'
import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator'
export function SelectMarketsModal(props: {
title: string
description?: React.ReactNode
open: boolean
setOpen: (open: boolean) => void
submitLabel: (length: number) => string
onSubmit: (contracts: Contract[]) => void | Promise<void>
contractSearchOptions?: Partial<Parameters<typeof ContractSearch>[0]>
}) {
const {
title,
description,
open,
setOpen,
submitLabel,
onSubmit,
contractSearchOptions,
} = props
const [contracts, setContracts] = useState<Contract[]>([])
const [loading, setLoading] = useState(false)
async function addContract(contract: Contract) {
if (contracts.map((c) => c.id).includes(contract.id)) {
setContracts(contracts.filter((c) => c.id !== contract.id))
} else setContracts([...contracts, contract])
}
async function onFinish() {
setLoading(true)
await onSubmit(contracts)
setLoading(false)
setOpen(false)
setContracts([])
}
return (
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white">
<div className="p-8 pb-0">
<Row>
<div className={'text-xl text-indigo-700'}>{title}</div>
{!loading && (
<Row className="grow justify-end gap-4">
{contracts.length > 0 && (
<Button onClick={onFinish} color="indigo">
{submitLabel(contracts.length)}
</Button>
)}
<Button
onClick={() => {
if (contracts.length > 0) {
setContracts([])
} else {
setOpen(false)
}
}}
color="gray"
>
{contracts.length > 0 ? 'Reset' : 'Cancel'}
</Button>
</Row>
)}
</Row>
{description}
</div>
{loading && (
<div className="w-full justify-center">
<LoadingIndicator />
</div>
)}
<div className="overflow-y-auto sm:px-8">
<ContractSearch
hideOrderSelector
onContractClick={addContract}
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
highlightOptions={{
contractIds: contracts.map((c) => c.id),
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
additionalFilter={{}} /* hide pills */
headerClassName="bg-white"
{...contractSearchOptions}
/>
</div>
</Col>
</Modal>
)
}

View File

@ -294,7 +294,7 @@ export function ExtraMobileContractDetails(props: {
<Tooltip
text={`${formatMoney(
volume
)} bet - ${uniqueBettors} unique traders`}
)} bet - ${uniqueBettors} unique predictors`}
>
{volumeTranslation}
</Tooltip>

View File

@ -6,7 +6,6 @@ import { formatMoney } from 'common/util/format'
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
import { useState, useMemo, useEffect } from 'react'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { useUserById } from 'web/hooks/use-user'
import { listUsers, User } from 'web/lib/firebase/users'
import { FeedBet } from '../feed/feed-bets'
import { FeedComment } from '../feed/feed-comments'
@ -88,7 +87,7 @@ export function ContractTopTrades(props: {
// Now find the betId with the highest profit
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = useUserById(betsById[topBetId]?.userId)
const topBettor = betsById[topBetId]?.userName
// And also the commentId of the comment with the highest profit
const topCommentId = sortBy(
@ -121,7 +120,7 @@ export function ContractTopTrades(props: {
<FeedBet contract={contract} bet={betsById[topBetId]} />
</div>
<div className="mt-2 ml-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
{topBettor} made {formatMoney(profitById[topBetId] || 0)}!
</div>
</>
)}

View File

@ -1,12 +1,6 @@
import { Editor } from '@tiptap/react'
import { Contract } from 'common/contract'
import { useState } from 'react'
import { Button } from '../button'
import { ContractSearch } from '../contract-search'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
import { LoadingIndicator } from '../loading-indicator'
import { SelectMarketsModal } from '../contract-select-modal'
import { embedContractCode, embedContractGridCode } from '../share-embed-button'
import { insertContent } from './utils'
@ -17,83 +11,23 @@ export function MarketModal(props: {
}) {
const { editor, open, setOpen } = props
const [contracts, setContracts] = useState<Contract[]>([])
const [loading, setLoading] = useState(false)
async function addContract(contract: Contract) {
if (contracts.map((c) => c.id).includes(contract.id)) {
setContracts(contracts.filter((c) => c.id !== contract.id))
} else setContracts([...contracts, contract])
}
async function doneAddingContracts() {
setLoading(true)
function onSubmit(contracts: Contract[]) {
if (contracts.length == 1) {
insertContent(editor, embedContractCode(contracts[0]))
} else if (contracts.length > 1) {
insertContent(editor, embedContractGridCode(contracts))
}
setLoading(false)
setOpen(false)
setContracts([])
}
return (
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white">
<Row className="p-8 pb-0">
<div className={'text-xl text-indigo-700'}>Embed a market</div>
{!loading && (
<Row className="grow justify-end gap-4">
{contracts.length == 1 && (
<Button onClick={doneAddingContracts} color={'indigo'}>
Embed 1 question
</Button>
)}
{contracts.length > 1 && (
<Button onClick={doneAddingContracts} color={'indigo'}>
Embed grid of {contracts.length} question
{contracts.length > 1 && 's'}
</Button>
)}
<Button
onClick={() => {
if (contracts.length > 0) {
setContracts([])
} else {
setOpen(false)
}
}}
color="gray"
>
{contracts.length > 0 ? 'Reset' : 'Cancel'}
</Button>
</Row>
)}
</Row>
{loading && (
<div className="w-full justify-center">
<LoadingIndicator />
</div>
)}
<div className="overflow-y-scroll sm:px-8">
<ContractSearch
hideOrderSelector
onContractClick={addContract}
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
highlightOptions={{
contractIds: contracts.map((c) => c.id),
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
additionalFilter={{}} /* hide pills */
headerClassName="bg-white"
/>
</div>
</Col>
</Modal>
<SelectMarketsModal
title="Embed markets"
open={open}
setOpen={setOpen}
submitLabel={(len) =>
len == 1 ? 'Embed 1 question' : `Embed grid of ${len} questions`
}
onSubmit={onSubmit}
/>
)
}

View File

@ -1,8 +1,7 @@
import dayjs from 'dayjs'
import { Contract } from 'common/contract'
import { Bet } from 'common/bet'
import { User } from 'common/user'
import { useUser, useUserById } from 'web/hooks/use-user'
import { useUser } from 'web/hooks/use-user'
import { Row } from 'web/components/layout/row'
import { Avatar, EmptyAvatar } from 'web/components/avatar'
import clsx from 'clsx'
@ -18,29 +17,20 @@ import { UserLink } from 'web/components/user-link'
export function FeedBet(props: { contract: Contract; bet: Bet }) {
const { contract, bet } = props
const { userId, createdTime } = bet
const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01')
// eslint-disable-next-line react-hooks/rules-of-hooks
const bettor = isBeforeJune2022 ? undefined : useUserById(userId)
const user = useUser()
const isSelf = user?.id === userId
const { userAvatarUrl, userUsername, createdTime } = bet
const showUser = dayjs(createdTime).isAfter('2022-06-01')
return (
<Row className="items-center gap-2 pt-3">
{isSelf ? (
<Avatar avatarUrl={user.avatarUrl} username={user.username} />
) : bettor ? (
<Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} />
{showUser ? (
<Avatar avatarUrl={userAvatarUrl} username={userUsername} />
) : (
<EmptyAvatar className="mx-1" />
)}
<BetStatusText
bet={bet}
contract={contract}
isSelf={isSelf}
bettor={bettor}
hideUser={!showUser}
className="flex-1"
/>
</Row>
@ -50,13 +40,13 @@ export function FeedBet(props: { contract: Contract; bet: Bet }) {
export function BetStatusText(props: {
contract: Contract
bet: Bet
isSelf: boolean
bettor?: User
hideUser?: boolean
hideOutcome?: boolean
className?: string
}) {
const { bet, contract, bettor, isSelf, hideOutcome, className } = props
const { bet, contract, hideUser, hideOutcome, className } = props
const { outcomeType } = contract
const self = useUser()
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
const { amount, outcome, createdTime, challengeSlug } = bet
@ -101,10 +91,10 @@ export function BetStatusText(props: {
return (
<div className={clsx('text-sm text-gray-500', className)}>
{bettor ? (
<UserLink name={bettor.name} username={bettor.username} />
{!hideUser ? (
<UserLink name={bet.userName} username={bet.userUsername} />
) : (
<span>{isSelf ? 'You' : 'A trader'}</span>
<span>{self?.id === bet.userId ? 'You' : 'A trader'}</span>
)}{' '}
{bought} {money}
{outOfTotalAmount}

View File

@ -9,13 +9,17 @@ import { RelativeTimestamp } from 'web/components/relative-timestamp'
import React from 'react'
import { LiquidityProvision } from 'common/liquidity-provision'
import { UserLink } from 'web/components/user-link'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes'
export function FeedLiquidity(props: {
className?: string
liquidity: LiquidityProvision
}) {
const { liquidity } = props
const { userId, createdTime } = liquidity
const { userId, createdTime, isAnte } = liquidity
const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01')
// eslint-disable-next-line react-hooks/rules-of-hooks
@ -24,8 +28,15 @@ export function FeedLiquidity(props: {
const user = useUser()
const isSelf = user?.id === userId
if (
isAnte ||
userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID
)
return <></>
return (
<Row className="flex w-full gap-2 pt-3">
<Row className="items-center gap-2 pt-3">
{isSelf ? (
<Avatar avatarUrl={user.avatarUrl} username={user.username} />
) : bettor ? (

View File

@ -4,7 +4,7 @@ import { getFormattedMappedValue } from 'common/pseudo-numeric'
import { formatMoney, formatPercent } from 'common/util/format'
import { sortBy } from 'lodash'
import { useState } from 'react'
import { useUser, useUserById } from 'web/hooks/use-user'
import { useUser } from 'web/hooks/use-user'
import { cancelBet } from 'web/lib/firebase/api'
import { Avatar } from './avatar'
import { Button } from './button'
@ -109,16 +109,14 @@ function LimitBet(props: {
setIsCancelling(true)
}
const user = useUserById(bet.userId)
return (
<tr>
{!isYou && (
<td>
<Avatar
size={'sm'}
avatarUrl={user?.avatarUrl}
username={user?.username}
avatarUrl={bet.userAvatarUrl}
username={bet.userUsername}
/>
</td>
)}

View File

@ -9,10 +9,11 @@ export function MarketIntroPanel() {
<div className="text-xl">Play-money predictions</div>
<Image
height={150}
width={150}
className="self-center"
src="/flappy-logo.gif"
height={125}
width={125}
className="my-4 self-center"
src="/welcome/manipurple.png"
alt="Manifold Markets gradient logo"
/>
<div className="mb-4 text-sm">

View File

@ -1,11 +1,10 @@
import { usePrivateUser } from 'web/hooks/use-user'
import React, { ReactNode, useEffect, useState } from 'react'
import { LoadingIndicator } from 'web/components/loading-indicator'
import React, { memo, ReactNode, useEffect, useState } from 'react'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import {
notification_subscription_types,
notification_destination_types,
PrivateUser,
} from 'common/user'
import { updatePrivateUser } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col'
@ -23,21 +22,22 @@ import {
UsersIcon,
} from '@heroicons/react/outline'
import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
import { filterDefined } from 'common/util/array'
import toast from 'react-hot-toast'
import { SwitchSetting } from 'web/components/switch-setting'
import { uniq } from 'lodash'
import {
storageStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
export function NotificationSettings(props: {
navigateToSection: string | undefined
privateUser: PrivateUser
}) {
const { navigateToSection } = props
const privateUser = usePrivateUser()
const { navigateToSection, privateUser } = props
const [showWatchModal, setShowWatchModal] = useState(false)
if (!privateUser || !privateUser.notificationSubscriptionTypes) {
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
}
const emailsEnabled: Array<keyof notification_subscription_types> = [
'all_comments_on_watched_markets',
'all_replies_to_my_comments_on_watched_markets',
@ -60,11 +60,14 @@ export function NotificationSettings(props: {
'tagged_user', // missing tagged on contract description email
'contract_from_followed_user',
'unique_bettors_on_your_contract',
// TODO: add these
// one-click unsubscribe only unsubscribes them from that type only, (well highlighted), then a link to manage the rest of their notifications
// 'profit_loss_updates', - changes in markets you have shares in
// biggest winner, here are the rest of your markets
// 'referral_bonuses',
// 'unique_bettors_on_your_contract',
// 'on_new_follow',
// 'profit_loss_updates',
// 'tips_on_your_markets',
// 'tips_on_your_comments',
// maybe the following?
@ -78,14 +81,14 @@ export function NotificationSettings(props: {
'thank_you_for_purchases',
]
type sectionData = {
type SectionData = {
label: string
subscriptionTypeToDescription: {
[key in keyof Partial<notification_subscription_types>]: string
}
}
const comments: sectionData = {
const comments: SectionData = {
label: 'New Comments',
subscriptionTypeToDescription: {
all_comments_on_watched_markets: 'All new comments',
@ -99,7 +102,7 @@ export function NotificationSettings(props: {
},
}
const answers: sectionData = {
const answers: SectionData = {
label: 'New Answers',
subscriptionTypeToDescription: {
all_answers_on_watched_markets: 'All new answers',
@ -108,7 +111,7 @@ export function NotificationSettings(props: {
// answers_by_market_creator_on_watched_markets: 'By market creator',
},
}
const updates: sectionData = {
const updates: SectionData = {
label: 'Updates & Resolutions',
subscriptionTypeToDescription: {
market_updates_on_watched_markets: 'All creator updates',
@ -118,7 +121,7 @@ export function NotificationSettings(props: {
// probability_updates_on_watched_markets: 'Probability updates',
},
}
const yourMarkets: sectionData = {
const yourMarkets: SectionData = {
label: 'Markets You Created',
subscriptionTypeToDescription: {
your_contract_closed: 'Your market has closed (and needs resolution)',
@ -128,15 +131,15 @@ export function NotificationSettings(props: {
tips_on_your_markets: 'Likes on your markets',
},
}
const bonuses: sectionData = {
const bonuses: SectionData = {
label: 'Bonuses',
subscriptionTypeToDescription: {
betting_streaks: 'Betting streak bonuses',
betting_streaks: 'Prediction streak bonuses',
referral_bonuses: 'Referral bonuses from referring users',
unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets',
},
}
const otherBalances: sectionData = {
const otherBalances: SectionData = {
label: 'Other',
subscriptionTypeToDescription: {
loan_income: 'Automatic loans from your profitable bets',
@ -144,7 +147,7 @@ export function NotificationSettings(props: {
tips_on_your_comments: 'Tips on your comments',
},
}
const userInteractions: sectionData = {
const userInteractions: SectionData = {
label: 'Users',
subscriptionTypeToDescription: {
tagged_user: 'A user tagged you',
@ -152,7 +155,7 @@ export function NotificationSettings(props: {
contract_from_followed_user: 'New markets created by users you follow',
},
}
const generalOther: sectionData = {
const generalOther: SectionData = {
label: 'Other',
subscriptionTypeToDescription: {
trending_markets: 'Weekly interesting markets',
@ -162,32 +165,29 @@ export function NotificationSettings(props: {
},
}
const NotificationSettingLine = (
description: string,
key: keyof notification_subscription_types,
value: notification_destination_types[]
) => {
const previousInAppValue = value.includes('browser')
const previousEmailValue = value.includes('email')
function NotificationSettingLine(props: {
description: string
subscriptionTypeKey: keyof notification_subscription_types
destinations: notification_destination_types[]
}) {
const { description, subscriptionTypeKey, destinations } = props
const previousInAppValue = destinations.includes('browser')
const previousEmailValue = destinations.includes('email')
const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue)
const [emailEnabled, setEmailEnabled] = useState(previousEmailValue)
const loading = 'Changing Notifications Settings'
const success = 'Changed Notification Settings!'
const highlight = navigateToSection === key
const highlight = navigateToSection === subscriptionTypeKey
useEffect(() => {
if (
inAppEnabled !== previousInAppValue ||
emailEnabled !== previousEmailValue
) {
toast.promise(
const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => {
toast
.promise(
updatePrivateUser(privateUser.id, {
notificationSubscriptionTypes: {
...privateUser.notificationSubscriptionTypes,
[key]: filterDefined([
inAppEnabled ? 'browser' : undefined,
emailEnabled ? 'email' : undefined,
]),
[subscriptionTypeKey]: destinations.includes(setting)
? destinations.filter((d) => d !== setting)
: uniq([...destinations, setting]),
},
}),
{
@ -196,14 +196,14 @@ export function NotificationSettings(props: {
error: 'Error changing notification settings. Try again?',
}
)
}
}, [
inAppEnabled,
emailEnabled,
previousInAppValue,
previousEmailValue,
key,
])
.then(() => {
if (setting === 'browser') {
setInAppEnabled(newValue)
} else {
setEmailEnabled(newValue)
}
})
}
return (
<Row
@ -217,17 +217,17 @@ export function NotificationSettings(props: {
<span>{description}</span>
</Row>
<Row className={'gap-4'}>
{!browserDisabled.includes(key) && (
{!browserDisabled.includes(subscriptionTypeKey) && (
<SwitchSetting
checked={inAppEnabled}
onChange={setInAppEnabled}
onChange={(newVal) => changeSetting('browser', newVal)}
label={'Web'}
/>
)}
{emailsEnabled.includes(key) && (
{emailsEnabled.includes(subscriptionTypeKey) && (
<SwitchSetting
checked={emailEnabled}
onChange={setEmailEnabled}
onChange={(newVal) => changeSetting('email', newVal)}
label={'Email'}
/>
)}
@ -243,17 +243,29 @@ export function NotificationSettings(props: {
return privateUser.notificationSubscriptionTypes[key] ?? []
}
const Section = (icon: ReactNode, data: sectionData) => {
const Section = memo(function Section(props: {
icon: ReactNode
data: SectionData
}) {
const { icon, data } = props
const { label, subscriptionTypeToDescription } = data
const expand =
navigateToSection &&
Object.keys(subscriptionTypeToDescription).includes(navigateToSection)
const [expanded, setExpanded] = useState(expand)
// Not sure how to prevent re-render (and collapse of an open section)
// due to a private user settings change. Just going to persist expanded state here
const [expanded, setExpanded] = usePersistentState(expand ?? false, {
key:
'NotificationsSettingsSection-' +
Object.keys(subscriptionTypeToDescription).join('-'),
store: storageStore(safeLocalStorage()),
})
// Not working as the default value for expanded, so using a useEffect
useEffect(() => {
if (expand) setExpanded(true)
}, [expand])
}, [expand, setExpanded])
return (
<Col className={clsx('ml-2 gap-2')}>
@ -275,19 +287,19 @@ export function NotificationSettings(props: {
)}
</Row>
<Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}>
{Object.entries(subscriptionTypeToDescription).map(([key, value]) =>
NotificationSettingLine(
value,
key as keyof notification_subscription_types,
getUsersSavedPreference(
{Object.entries(subscriptionTypeToDescription).map(([key, value]) => (
<NotificationSettingLine
subscriptionTypeKey={key as keyof notification_subscription_types}
destinations={getUsersSavedPreference(
key as keyof notification_subscription_types
)
)
)}
)}
description={value}
/>
))}
</Col>
</Col>
)
}
})
return (
<div className={'p-2'}>
@ -299,20 +311,38 @@ export function NotificationSettings(props: {
onClick={() => setShowWatchModal(true)}
/>
</Row>
{Section(<ChatIcon className={'h-6 w-6'} />, comments)}
{Section(<LightBulbIcon className={'h-6 w-6'} />, answers)}
{Section(<TrendingUpIcon className={'h-6 w-6'} />, updates)}
{Section(<UserIcon className={'h-6 w-6'} />, yourMarkets)}
<Section icon={<ChatIcon className={'h-6 w-6'} />} data={comments} />
<Section
icon={<TrendingUpIcon className={'h-6 w-6'} />}
data={updates}
/>
<Section
icon={<LightBulbIcon className={'h-6 w-6'} />}
data={answers}
/>
<Section icon={<UserIcon className={'h-6 w-6'} />} data={yourMarkets} />
<Row className={'gap-2 text-xl text-gray-700'}>
<span>Balance Changes</span>
</Row>
{Section(<CurrencyDollarIcon className={'h-6 w-6'} />, bonuses)}
{Section(<CashIcon className={'h-6 w-6'} />, otherBalances)}
<Section
icon={<CurrencyDollarIcon className={'h-6 w-6'} />}
data={bonuses}
/>
<Section
icon={<CashIcon className={'h-6 w-6'} />}
data={otherBalances}
/>
<Row className={'gap-2 text-xl text-gray-700'}>
<span>General</span>
</Row>
{Section(<UsersIcon className={'h-6 w-6'} />, userInteractions)}
{Section(<InboxInIcon className={'h-6 w-6'} />, generalOther)}
<Section
icon={<UsersIcon className={'h-6 w-6'} />}
data={userInteractions}
/>
<Section
icon={<InboxInIcon className={'h-6 w-6'} />}
data={generalOther}
/>
<WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} />
</Col>
</div>

View File

@ -16,13 +16,14 @@ export function BettingStreakModal(props: {
<Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🔥</span>
<span className="text-xl">Daily betting streaks</span>
<span className="text-xl">Daily prediction streaks</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are they?</span>
<span className={'ml-2'}>
You get {formatMoney(BETTING_STREAK_BONUS_AMOUNT)} more for each day
of consecutive betting up to {formatMoney(BETTING_STREAK_BONUS_MAX)}
. The more days you bet in a row, the more you earn!
of consecutive predicting up to{' '}
{formatMoney(BETTING_STREAK_BONUS_MAX)}. The more days you predict
in a row, the more you earn!
</span>
<span className={'text-indigo-700'}>
Where can I check my streak?

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useEvent } from '../hooks/use-event'
export function VisibilityObserver(props: {
@ -8,18 +8,16 @@ export function VisibilityObserver(props: {
const { className } = props
const [elem, setElem] = useState<HTMLElement | null>(null)
const onVisibilityUpdated = useEvent(props.onVisibilityUpdated)
const observer = useRef(
new IntersectionObserver(([entry]) => {
onVisibilityUpdated(entry.isIntersecting)
}, {})
).current
useEffect(() => {
if (elem) {
const observer = new IntersectionObserver(([entry]) => {
onVisibilityUpdated(entry.isIntersecting)
}, {})
observer.observe(elem)
return () => observer.unobserve(elem)
}
}, [elem, observer])
}, [elem, onVisibilityUpdated])
return <div ref={setElem} className={className}></div>
}

View File

@ -1,10 +1,8 @@
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { isEqual } from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import {
Contract,
listenForActiveContracts,
listenForContract,
listenForContracts,
listenForHotContracts,
listenForInactiveContracts,
@ -62,39 +60,6 @@ export const useHotContracts = () => {
return hotContracts
}
export const useUpdatedContracts = (contracts: Contract[] | undefined) => {
const [__, triggerUpdate] = useState(0)
const contractDict = useRef<{ [id: string]: Contract }>({})
useEffect(() => {
if (contracts === undefined) return
contractDict.current = Object.fromEntries(contracts.map((c) => [c.id, c]))
const disposes = contracts.map((contract) => {
const { id } = contract
return listenForContract(id, (contract) => {
const curr = contractDict.current[id]
if (!isEqual(curr, contract)) {
contractDict.current[id] = contract as Contract
triggerUpdate((n) => n + 1)
}
})
})
triggerUpdate((n) => n + 1)
return () => {
disposes.forEach((dispose) => dispose())
}
}, [!!contracts])
return contracts && Object.keys(contractDict.current).length > 0
? contracts.map((c) => contractDict.current[c.id])
: undefined
}
export const usePrefetchUserBetContracts = (userId: string) => {
const queryClient = useQueryClient()
return queryClient.prefetchQuery(

View File

@ -426,7 +426,7 @@ export function NewContract(props: {
<div className="form-control mb-1 items-start">
<label className="label mb-1 gap-2">
<span>Question closes in</span>
<InfoTooltip text="Betting will be halted after this time (local timezone)." />
<InfoTooltip text="Predicting will be halted after this time (local timezone)." />
</label>
<Row className={'w-full items-center gap-2'}>
<ChoicesToggleGroup
@ -483,7 +483,7 @@ export function NewContract(props: {
<label className="label mb-1 gap-2">
<span>Cost</span>
<InfoTooltip
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 predictions.`}
/>
</label>
{!deservesFreeMarket ? (

View File

@ -28,7 +28,7 @@ export default function Home() {
<Page>
<Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2">
<Row className={'w-full items-center justify-between'}>
<Title text="Edit your home page" />
<Title text="Customize your home page" />
<DoneButton />
</Row>
@ -47,7 +47,11 @@ function DoneButton(props: { className?: string }) {
return (
<SiteLink href="/experimental/home">
<Button size="lg" color="blue" className={clsx(className, 'flex')}>
<Button
size="lg"
color="blue"
className={clsx(className, 'flex whitespace-nowrap')}
>
Done
</Button>
</SiteLink>

View File

@ -1,7 +1,7 @@
import React from 'react'
import Router from 'next/router'
import {
PencilIcon,
AdjustmentsIcon,
PlusSmIcon,
ArrowSmRightIcon,
} from '@heroicons/react/solid'
@ -26,11 +26,12 @@ import { Row } from 'web/components/layout/row'
import { ProbChangeTable } from 'web/components/contract/prob-change-table'
import { groupPath } from 'web/lib/firebase/groups'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { calculatePortfolioProfit } from 'common/calculate-metrics'
import { formatMoney } from 'common/util/format'
import { useProbChanges } from 'web/hooks/use-prob-changes'
import { ProfitBadge } from 'web/components/bets-list'
import { calculatePortfolioProfit } from 'common/calculate-metrics'
const Home = () => {
export default function Home() {
const user = useUser()
useTracking('view home')
@ -44,14 +45,14 @@ const Home = () => {
return (
<Page>
<Col className="pm:mx-10 gap-4 px-4 pb-12">
<Row className={'w-full items-center justify-between'}>
<Title className="!mb-0" text="Home" />
<EditButton />
<Row className={'mt-4 w-full items-start justify-between'}>
<Row className="items-end gap-4">
<Title className="!mb-1 !mt-0" text="Home" />
<EditButton />
</Row>
<DailyProfitAndBalance className="" user={user} />
</Row>
<DailyProfitAndBalance userId={user?.id} />
{sections.map((item) => {
const { id } = item
if (id === 'daily-movers') {
@ -97,17 +98,10 @@ function SearchSection(props: {
followed?: boolean
}) {
const { label, user, sort, yourBets, followed } = props
const href = `/home?s=${sort}`
return (
<Col>
<SiteLink className="mb-2 text-xl" href={href}>
{label}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</SiteLink>
<SectionHeader label={label} href={`/home?s=${sort}`} />
<ContractSearch
user={user}
defaultSort={sort}
@ -134,13 +128,7 @@ function GroupSection(props: {
return (
<Col>
<SiteLink className="mb-2 text-xl" href={groupPath(group.slug)}>
{group.name}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</SiteLink>
<SectionHeader label={group.name} href={groupPath(group.slug)} />
<ContractSearch
user={user}
defaultSort={'score'}
@ -159,15 +147,25 @@ function DailyMoversSection(props: { userId: string | null | undefined }) {
return (
<Col className="gap-2">
<SiteLink className="text-xl" href={'/daily-movers'}>
Daily movers{' '}
<SectionHeader label="Daily movers" href="daily-movers" />
<ProbChangeTable changes={changes} />
</Col>
)
}
function SectionHeader(props: { label: string; href: string }) {
const { label, href } = props
return (
<Row className="mb-3 items-center justify-between">
<SiteLink className="text-xl" href={href}>
{label}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</SiteLink>
<ProbChangeTable changes={changes} />
</Col>
</Row>
)
}
@ -176,45 +174,42 @@ function EditButton(props: { className?: string }) {
return (
<SiteLink href="/experimental/home/edit">
<Button size="lg" color="gray-white" className={clsx(className, 'flex')}>
<PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />{' '}
Edit
<Button size="sm" color="gray-white" className={clsx(className, 'flex')}>
<AdjustmentsIcon className={clsx('h-[24px] w-5')} aria-hidden="true" />
</Button>
</SiteLink>
)
}
function DailyProfitAndBalance(props: {
userId: string | null | undefined
user: User | null | undefined
className?: string
}) {
const { userId, className } = props
const metrics = usePortfolioHistory(userId ?? '', 'daily') ?? []
const { user, className } = props
const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? []
const [first, last] = [metrics[0], metrics[metrics.length - 1]]
if (first === undefined || last === undefined) return null
const profit =
calculatePortfolioProfit(last) - calculatePortfolioProfit(first)
const balanceChange = last.balance - first.balance
const profitPercent = profit / first.investmentValue
return (
<div className={clsx(className, 'text-lg')}>
<span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}>
{profit >= 0 && '+'}
{formatMoney(profit)}
</span>{' '}
profit and{' '}
<span
className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')}
>
{balanceChange >= 0 && '+'}
{formatMoney(balanceChange)}
</span>{' '}
balance today
</div>
<Row className={'gap-4'}>
<Col>
<div className="text-gray-500">Daily profit</div>
<Row className={clsx(className, 'items-center text-lg')}>
<span>{formatMoney(profit)}</span>{' '}
<ProfitBadge profitPercent={profitPercent * 100} />
</Row>
</Col>
<Col>
<div className="text-gray-500">Streak</div>
<Row className={clsx(className, 'items-center text-lg')}>
<span>🔥 {user?.currentBettingStreak ?? 0}</span>
</Row>
</Col>
</Row>
)
}
export default Home

View File

@ -31,8 +31,6 @@ import { SEO } from 'web/components/SEO'
import { Linkify } from 'web/components/linkify'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Tabs } from 'web/components/layout/tabs'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { ContractSearch } from 'web/components/contract-search'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
@ -51,6 +49,7 @@ import { Spacer } from 'web/components/layout/spacer'
import { usePost } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser'
import { SelectMarketsModal } from 'web/components/contract-select-modal'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -401,27 +400,12 @@ function GroupLeaderboard(props: {
function AddContractButton(props: { group: Group; user: User }) {
const { group, user } = props
const [open, setOpen] = useState(false)
const [contracts, setContracts] = useState<Contract[]>([])
const [loading, setLoading] = useState(false)
const groupContractIds = useGroupContractIds(group.id)
async function addContractToCurrentGroup(contract: Contract) {
if (contracts.map((c) => c.id).includes(contract.id)) {
setContracts(contracts.filter((c) => c.id !== contract.id))
} else setContracts([...contracts, contract])
}
async function doneAddingContracts() {
Promise.all(
contracts.map(async (contract) => {
setLoading(true)
await addContractToGroup(group, contract, user.id)
})
).then(() => {
setLoading(false)
setOpen(false)
setContracts([])
})
async function onSubmit(contracts: Contract[]) {
await Promise.all(
contracts.map((contract) => addContractToGroup(group, contract, user.id))
)
}
return (
@ -437,71 +421,27 @@ function AddContractButton(props: { group: Group; user: User }) {
</Button>
</div>
<Modal
<SelectMarketsModal
open={open}
setOpen={setOpen}
className={'max-w-4xl sm:p-0'}
size={'xl'}
>
<Col
className={'min-h-screen w-full max-w-4xl gap-4 rounded-md bg-white'}
>
<Col className="p-8 pb-0">
<div className={'text-xl text-indigo-700'}>Add markets</div>
<div className={'text-md my-4 text-gray-600'}>
Add pre-existing markets to this group, or{' '}
<Link href={`/create?groupId=${group.id}`}>
<span className="cursor-pointer font-semibold underline">
create a new one
</span>
</Link>
.
</div>
{contracts.length > 0 && (
<Col className={'w-full '}>
{!loading ? (
<Row className={'justify-end gap-4'}>
<Button onClick={doneAddingContracts} color={'indigo'}>
Add {contracts.length} question
{contracts.length > 1 && 's'}
</Button>
<Button
onClick={() => {
setContracts([])
}}
color={'gray'}
>
Cancel
</Button>
</Row>
) : (
<Row className={'justify-center'}>
<LoadingIndicator />
</Row>
)}
</Col>
)}
</Col>
<div className={'overflow-y-scroll sm:px-8'}>
<ContractSearch
user={user}
hideOrderSelector={true}
onContractClick={addContractToCurrentGroup}
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
additionalFilter={{
excludeContractIds: groupContractIds,
}}
highlightOptions={{
contractIds: contracts.map((c) => c.id),
highlightClassName: '!bg-indigo-100 border-indigo-100 border-2',
}}
/>
title="Add markets"
description={
<div className={'text-md my-4 text-gray-600'}>
Add pre-existing markets to this group, or{' '}
<Link href={`/create?groupId=${group.id}`}>
<span className="cursor-pointer font-semibold underline">
create a new one
</span>
</Link>
.
</div>
</Col>
</Modal>
}
submitLabel={(len) => `Add ${len} question${len !== 1 ? 's' : ''}`}
onSubmit={onSubmit}
contractSearchOptions={{
additionalFilter: { excludeContractIds: groupContractIds },
}}
/>
</>
)
}

View File

@ -112,6 +112,7 @@ export default function Notifications() {
content: (
<NotificationSettings
navigateToSection={navigateToSection}
privateUser={privateUser}
/>
),
},
@ -428,7 +429,7 @@ function IncomeNotificationItem(props: {
reasonText = !simple
? `Bonus for ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
} new traders on`
} new predictors on`
: 'bonus on'
} else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you on` : `in tips on`
@ -436,7 +437,7 @@ function IncomeNotificationItem(props: {
if (sourceText && +sourceText === 50) reasonText = '(max) for your'
else reasonText = 'for your'
} else if (sourceType === 'loan' && sourceText) {
reasonText = `of your invested bets returned as a`
reasonText = `of your invested predictions returned as a`
// TODO: support just 'like' notification without a tip
} else if (sourceType === 'tip_and_like' && sourceText) {
reasonText = !simple ? `liked` : `in likes on`
@ -448,7 +449,9 @@ function IncomeNotificationItem(props: {
: user?.currentBettingStreak ?? 0
const bettingStreakText =
sourceType === 'betting_streak_bonus' &&
(sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak')
(sourceText
? `🔥 ${streakInDays} day Prediction Streak`
: 'Prediction Streak')
return (
<>
@ -546,7 +549,7 @@ function IncomeNotificationItem(props: {
{(isTip || isUniqueBettorBonus) && (
<MultiUserTransactionLink
userInfos={userLinks}
modalLabel={isTip ? 'Who tipped you' : 'Unique traders'}
modalLabel={isTip ? 'Who tipped you' : 'Unique predictors'}
/>
)}
<Row className={'line-clamp-2 flex max-w-xl'}>

View File

@ -155,7 +155,7 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
<Page>
<SEO
title="Tournaments"
description="Win money by betting in forecasting touraments on current events, sports, science, and more"
description="Win money by predicting in forecasting tournaments on current events, sports, science, and more"
/>
<Col className="m-4 gap-10 sm:mx-10 sm:gap-24 xl:w-[125%]">
{sections.map(