Merge branch 'manifoldmarkets:main' into main

This commit is contained in:
marsteralex 2022-09-15 15:45:01 -07:00 committed by GitHub
commit 932003bff3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
104 changed files with 2492 additions and 1192 deletions

View File

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

View File

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

View File

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

View File

@ -13,5 +13,5 @@ export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT = export const BETTING_STREAK_BONUS_AMOUNT =
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10 econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 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 BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5

View File

@ -21,7 +21,10 @@ export function isWhitelisted(email?: string) {
} }
// TODO: Before open sourcing, we should turn these into env vars // TODO: Before open sourcing, we should turn these into env vars
export function isAdmin(email: string) { export function isAdmin(email?: string) {
if (!email) {
return false
}
return ENV_CONFIG.adminEmails.includes(email) return ENV_CONFIG.adminEmails.includes(email)
} }

View File

@ -15,6 +15,9 @@ export type EnvConfig = {
// Branding // Branding
moneyMoniker: string // e.g. 'M$' moneyMoniker: string // e.g. 'M$'
bettor?: string // e.g. 'bettor' or 'predictor'
presentBet?: string // e.g. 'bet' or 'predict'
pastBet?: string // e.g. 'bet' or 'prediction'
faviconPath?: string // Should be a file in /public faviconPath?: string // Should be a file in /public
navbarLogoPath?: string navbarLogoPath?: string
newQuestionPlaceholders: string[] newQuestionPlaceholders: string[]
@ -74,10 +77,14 @@ export const PROD_CONFIG: EnvConfig = {
'iansphilips@gmail.com', // Ian 'iansphilips@gmail.com', // Ian
'd4vidchee@gmail.com', // D4vid 'd4vidchee@gmail.com', // D4vid
'federicoruizcassarino@gmail.com', // Fede 'federicoruizcassarino@gmail.com', // Fede
'ingawei@gmail.com', //Inga
], ],
visibility: 'PUBLIC', visibility: 'PUBLIC',
moneyMoniker: 'M$', moneyMoniker: 'M$',
bettor: 'predictor',
pastBet: 'prediction',
presentBet: 'predict',
navbarLogoPath: '', navbarLogoPath: '',
faviconPath: '/favicon.ico', faviconPath: '/favicon.ico',
newQuestionPlaceholders: [ newQuestionPlaceholders: [

View File

@ -2,3 +2,8 @@ export type Follow = {
userId: string userId: string
timestamp: number timestamp: number
} }
export type ContractFollow = {
id: string // user id
createdTime: number
}

View File

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

View File

@ -1,5 +1,4 @@
import { notification_subscription_types, PrivateUser } from './user' import { notification_preference } from './user-notification-preferences'
import { DOMAIN } from './envs/constants'
export type Notification = { export type Notification = {
id: string id: string
@ -18,7 +17,7 @@ export type Notification = {
sourceUserUsername?: string sourceUserUsername?: string
sourceUserAvatarUrl?: string sourceUserAvatarUrl?: string
sourceText?: string sourceText?: string
data?: string data?: { [key: string]: any }
sourceContractTitle?: string sourceContractTitle?: string
sourceContractCreatorUsername?: string sourceContractCreatorUsername?: string
@ -29,6 +28,7 @@ export type Notification = {
isSeenOnHref?: string isSeenOnHref?: string
} }
export type notification_source_types = export type notification_source_types =
| 'contract' | 'contract'
| 'comment' | 'comment'
@ -54,7 +54,7 @@ export type notification_source_update_types =
| 'deleted' | 'deleted'
| 'closed' | 'closed'
/* Optional - if possible use a keyof notification_subscription_types */ /* Optional - if possible use a notification_preference */
export type notification_reason_types = export type notification_reason_types =
| 'tagged_user' | 'tagged_user'
| 'on_new_follow' | 'on_new_follow'
@ -92,68 +92,165 @@ export type notification_reason_types =
| 'your_contract_closed' | 'your_contract_closed'
| 'subsidized_your_market' | 'subsidized_your_market'
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types type notification_descriptions = {
// You might want to add a key:value here if there will be multiple notification reasons that map to the same [key in notification_preference]: {
// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to simple: string
// 'all_comments_on_watched_markets' subscription type detailed: string
// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types }
export const notificationReasonToSubscriptionType: Partial< }
Record<notification_reason_types, keyof notification_subscription_types> export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
> = { all_answers_on_my_markets: {
you_referred_user: 'referral_bonuses', simple: 'Answers on your markets',
user_joined_to_bet_on_your_market: 'referral_bonuses', detailed: 'Answers on your own markets',
tip_received: 'tips_on_your_comments', },
bet_fill: 'limit_order_fills', all_comments_on_my_markets: {
user_joined_from_your_group_invite: 'referral_bonuses', simple: 'Comments on your markets',
challenge_accepted: 'limit_order_fills', detailed: 'Comments on your own markets',
betting_streak_incremented: 'betting_streaks', },
liked_and_tipped_your_contract: 'tips_on_your_markets', answers_by_followed_users_on_watched_markets: {
comment_on_your_contract: 'all_comments_on_my_markets', simple: 'Only answers by users you follow',
answer_on_your_contract: 'all_answers_on_my_markets', detailed: "Only answers by users you follow on markets you're watching",
comment_on_contract_you_follow: 'all_comments_on_watched_markets', },
answer_on_contract_you_follow: 'all_answers_on_watched_markets', answers_by_market_creator_on_watched_markets: {
update_on_contract_you_follow: 'market_updates_on_watched_markets', simple: 'Only answers by market creator',
resolution_on_contract_you_follow: 'resolutions_on_watched_markets', detailed: "Only answers by market creator on markets you're watching",
comment_on_contract_with_users_shares_in: },
'all_comments_on_contracts_with_shares_in_on_watched_markets', betting_streaks: {
answer_on_contract_with_users_shares_in: simple: 'For predictions made over consecutive days',
'all_answers_on_contracts_with_shares_in_on_watched_markets', detailed: 'Bonuses for predictions made over consecutive days',
update_on_contract_with_users_shares_in: },
'market_updates_on_watched_markets_with_shares_in', comments_by_followed_users_on_watched_markets: {
resolution_on_contract_with_users_shares_in: simple: 'Only comments by users you follow',
'resolutions_on_watched_markets_with_shares_in', detailed:
comment_on_contract_with_users_answer: 'all_comments_on_watched_markets', 'Only comments by users that you follow on markets that you watch',
update_on_contract_with_users_answer: 'market_updates_on_watched_markets', },
resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets', contract_from_followed_user: {
answer_on_contract_with_users_answer: 'all_answers_on_watched_markets', simple: 'New markets from users you follow',
comment_on_contract_with_users_comment: 'all_comments_on_watched_markets', detailed: 'New markets from users you follow',
answer_on_contract_with_users_comment: 'all_answers_on_watched_markets', },
update_on_contract_with_users_comment: 'market_updates_on_watched_markets', limit_order_fills: {
resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets', simple: 'Limit order fills',
reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets', detailed: 'When your limit order is filled by another user',
reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets', },
loan_income: {
simple: 'Automatic loans from your predictions in unresolved markets',
detailed:
'Automatic loans from your predictions that are locked in unresolved markets',
},
market_updates_on_watched_markets: {
simple: 'All creator updates',
detailed: 'All market updates made by the creator',
},
market_updates_on_watched_markets_with_shares_in: {
simple: "Only creator updates on markets that you're invested in",
detailed:
"Only updates made by the creator on markets that you're invested in",
},
on_new_follow: {
simple: 'A user followed you',
detailed: 'A user followed you',
},
onboarding_flow: {
simple: 'Emails to help you get started using Manifold',
detailed: 'Emails to help you learn how to use Manifold',
},
probability_updates_on_watched_markets: {
simple: 'Large changes in probability on markets that you watch',
detailed: 'Large changes in probability on markets that you watch',
},
profit_loss_updates: {
simple: 'Weekly profit and loss updates',
detailed: 'Weekly profit and loss updates',
},
referral_bonuses: {
simple: 'For referring new users',
detailed: 'Bonuses you receive from referring a new user',
},
resolutions_on_watched_markets: {
simple: 'All market resolutions',
detailed: "All resolutions on markets that you're watching",
},
resolutions_on_watched_markets_with_shares_in: {
simple: "Only market resolutions that you're invested in",
detailed:
"Only resolutions of markets you're watching and that you're invested in",
},
subsidized_your_market: {
simple: 'Your market was subsidized',
detailed: 'When someone subsidizes your market',
},
tagged_user: {
simple: 'A user tagged you',
detailed: 'When another use tags you',
},
thank_you_for_purchases: {
simple: 'Thank you notes for your purchases',
detailed: 'Thank you notes for your purchases',
},
tipped_comments_on_watched_markets: {
simple: 'Only highly tipped comments on markets that you watch',
detailed: 'Only highly tipped comments on markets that you watch',
},
tips_on_your_comments: {
simple: 'Tips on your comments',
detailed: 'Tips on your comments',
},
tips_on_your_markets: {
simple: 'Tips/Likes on your markets',
detailed: 'Tips/Likes on your markets',
},
trending_markets: {
simple: 'Weekly interesting markets',
detailed: 'Weekly interesting markets',
},
unique_bettors_on_your_contract: {
simple: 'For unique predictors on your markets',
detailed: 'Bonuses for unique predictors on your markets',
},
your_contract_closed: {
simple: 'Your market has closed and you need to resolve it',
detailed: 'Your market has closed and you need to resolve it',
},
all_comments_on_watched_markets: {
simple: 'All new comments',
detailed: 'All new comments on markets you follow',
},
all_comments_on_contracts_with_shares_in_on_watched_markets: {
simple: `Only on markets you're invested in`,
detailed: `Comments on markets that you're watching and you're invested in`,
},
all_replies_to_my_comments_on_watched_markets: {
simple: 'Only replies to your comments',
detailed: "Only replies to your comments on markets you're watching",
},
all_replies_to_my_answers_on_watched_markets: {
simple: 'Only replies to your answers',
detailed: "Only replies to your answers on markets you're watching",
},
all_answers_on_watched_markets: {
simple: 'All new answers',
detailed: "All new answers on markets you're watching",
},
all_answers_on_contracts_with_shares_in_on_watched_markets: {
simple: `Only on markets you're invested in`,
detailed: `Answers on markets that you're watching and that you're invested in`,
},
} }
export const getDestinationsForUser = async ( export type BettingStreakData = {
privateUser: PrivateUser, streak: number
reason: notification_reason_types | keyof notification_subscription_types bonusAmount: number
) => { }
const notificationSettings = privateUser.notificationSubscriptionTypes
let destinations export type BetFillData = {
let subscriptionType: keyof notification_subscription_types | undefined betOutcome: string
if (Object.keys(notificationSettings).includes(reason)) { creatorOutcome: string
subscriptionType = reason as keyof notification_subscription_types probability: number
destinations = notificationSettings[subscriptionType] fillAmount: number
} else { }
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key] export type ContractResolutionData = {
destinations = subscriptionType outcome: string
? notificationSettings[subscriptionType] userPayout: number
: [] userInvestment: number
}
return {
sendToEmail: destinations.includes('email'),
sendToBrowser: destinations.includes('browser'),
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
}
} }

View File

@ -9,7 +9,10 @@ import { CPMMContract, DPMContract } from './contract'
import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees' import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees'
import { sumBy } from 'lodash' 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) => { export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract

View File

@ -1,6 +1,13 @@
// A txn (pronounced "texan") respresents a payment between two ids on Manifold // A txn (pronounced "texan") respresents a payment between two ids on Manifold
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars) // Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus type AnyTxnType =
| Donation
| Tip
| Manalink
| Referral
| UniqueBettorBonus
| BettingStreakBonus
| CancelUniqueBettorBonus
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = { export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -23,6 +30,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
| 'REFERRAL' | 'REFERRAL'
| 'UNIQUE_BETTOR_BONUS' | 'UNIQUE_BETTOR_BONUS'
| 'BETTING_STREAK_BONUS' | 'BETTING_STREAK_BONUS'
| 'CANCEL_UNIQUE_BETTOR_BONUS'
// Any extra data // Any extra data
data?: { [key: string]: any } data?: { [key: string]: any }
@ -60,13 +68,40 @@ type Referral = {
category: 'REFERRAL' category: 'REFERRAL'
} }
type Bonus = { type UniqueBettorBonus = {
fromType: 'BANK' fromType: 'BANK'
toType: 'USER' toType: 'USER'
category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' category: 'UNIQUE_BETTOR_BONUS'
data: {
contractId: string
uniqueNewBettorId?: string
// Old unique bettor bonus txns stored all unique bettor ids
uniqueBettorIds?: string[]
}
}
type BettingStreakBonus = {
fromType: 'BANK'
toType: 'USER'
category: 'BETTING_STREAK_BONUS'
data: {
currentBettingStreak?: number
}
}
type CancelUniqueBettorBonus = {
fromType: 'USER'
toType: 'BANK'
category: 'CANCEL_UNIQUE_BETTOR_BONUS'
data: {
contractId: string
}
} }
export type DonationTxn = Txn & Donation export type DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink export type ManalinkTxn = Txn & Manalink
export type ReferralTxn = Txn & Referral export type ReferralTxn = Txn & Referral
export type BettingStreakBonusTxn = Txn & BettingStreakBonus
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus

View File

@ -0,0 +1,244 @@
import { filterDefined } from './util/array'
import { notification_reason_types } from './notification'
import { getFunctionUrl } from './api'
import { DOMAIN } from './envs/constants'
import { PrivateUser } from './user'
export type notification_destination_types = 'email' | 'browser'
export type notification_preference = keyof notification_preferences
export type notification_preferences = {
// Watched Markets
all_comments_on_watched_markets: notification_destination_types[]
all_answers_on_watched_markets: notification_destination_types[]
// Comments
tipped_comments_on_watched_markets: notification_destination_types[]
comments_by_followed_users_on_watched_markets: notification_destination_types[]
all_replies_to_my_comments_on_watched_markets: notification_destination_types[]
all_replies_to_my_answers_on_watched_markets: notification_destination_types[]
all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
// Answers
answers_by_followed_users_on_watched_markets: notification_destination_types[]
answers_by_market_creator_on_watched_markets: notification_destination_types[]
all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
// On users' markets
your_contract_closed: notification_destination_types[]
all_comments_on_my_markets: notification_destination_types[]
all_answers_on_my_markets: notification_destination_types[]
subsidized_your_market: notification_destination_types[]
// Market updates
resolutions_on_watched_markets: notification_destination_types[]
resolutions_on_watched_markets_with_shares_in: notification_destination_types[]
market_updates_on_watched_markets: notification_destination_types[]
market_updates_on_watched_markets_with_shares_in: notification_destination_types[]
probability_updates_on_watched_markets: notification_destination_types[]
// Balance Changes
loan_income: notification_destination_types[]
betting_streaks: notification_destination_types[]
referral_bonuses: notification_destination_types[]
unique_bettors_on_your_contract: notification_destination_types[]
tips_on_your_comments: notification_destination_types[]
tips_on_your_markets: notification_destination_types[]
limit_order_fills: notification_destination_types[]
// General
tagged_user: notification_destination_types[]
on_new_follow: notification_destination_types[]
contract_from_followed_user: notification_destination_types[]
trending_markets: notification_destination_types[]
profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[]
}
export const getDefaultNotificationPreferences = (
userId: string,
privateUser?: PrivateUser,
noEmails?: boolean
) => {
const {
unsubscribedFromCommentEmails,
unsubscribedFromAnswerEmails,
unsubscribedFromResolutionEmails,
unsubscribedFromWeeklyTrendingEmails,
unsubscribedFromGenericEmails,
} = privateUser || {}
const constructPref = (browserIf: boolean, emailIf: boolean) => {
const browser = browserIf ? 'browser' : undefined
const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[]
}
return {
// Watched Markets
all_comments_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_answers_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
// Comments
tips_on_your_comments: constructPref(true, !unsubscribedFromCommentEmails),
comments_by_followed_users_on_watched_markets: constructPref(true, false),
all_replies_to_my_comments_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_replies_to_my_answers_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
// Answers
answers_by_followed_users_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
answers_by_market_creator_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
// On users' markets
your_contract_closed: constructPref(
true,
!unsubscribedFromResolutionEmails
), // High priority
all_comments_on_my_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_answers_on_my_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
subsidized_your_market: constructPref(true, true),
// Market updates
resolutions_on_watched_markets: constructPref(
true,
!unsubscribedFromResolutionEmails
),
market_updates_on_watched_markets: constructPref(true, false),
market_updates_on_watched_markets_with_shares_in: constructPref(
true,
false
),
resolutions_on_watched_markets_with_shares_in: constructPref(
true,
!unsubscribedFromResolutionEmails
),
//Balance Changes
loan_income: constructPref(true, false),
betting_streaks: constructPref(true, false),
referral_bonuses: constructPref(true, true),
unique_bettors_on_your_contract: constructPref(true, false),
tipped_comments_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
tips_on_your_markets: constructPref(true, true),
limit_order_fills: constructPref(true, false),
// General
tagged_user: constructPref(true, true),
on_new_follow: constructPref(true, true),
contract_from_followed_user: constructPref(true, true),
trending_markets: constructPref(
false,
!unsubscribedFromWeeklyTrendingEmails
),
profit_loss_updates: constructPref(false, true),
probability_updates_on_watched_markets: constructPref(true, false),
thank_you_for_purchases: constructPref(
false,
!unsubscribedFromGenericEmails
),
onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails),
} as notification_preferences
}
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
// You might want to add a key:value here if there will be multiple notification reasons that map to the same
// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to
// 'all_comments_on_watched_markets' subscription type
// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types
const notificationReasonToSubscriptionType: Partial<
Record<notification_reason_types, notification_preference>
> = {
you_referred_user: 'referral_bonuses',
user_joined_to_bet_on_your_market: 'referral_bonuses',
tip_received: 'tips_on_your_comments',
bet_fill: 'limit_order_fills',
user_joined_from_your_group_invite: 'referral_bonuses',
challenge_accepted: 'limit_order_fills',
betting_streak_incremented: 'betting_streaks',
liked_and_tipped_your_contract: 'tips_on_your_markets',
comment_on_your_contract: 'all_comments_on_my_markets',
answer_on_your_contract: 'all_answers_on_my_markets',
comment_on_contract_you_follow: 'all_comments_on_watched_markets',
answer_on_contract_you_follow: 'all_answers_on_watched_markets',
update_on_contract_you_follow: 'market_updates_on_watched_markets',
resolution_on_contract_you_follow: 'resolutions_on_watched_markets',
comment_on_contract_with_users_shares_in:
'all_comments_on_contracts_with_shares_in_on_watched_markets',
answer_on_contract_with_users_shares_in:
'all_answers_on_contracts_with_shares_in_on_watched_markets',
update_on_contract_with_users_shares_in:
'market_updates_on_watched_markets_with_shares_in',
resolution_on_contract_with_users_shares_in:
'resolutions_on_watched_markets_with_shares_in',
comment_on_contract_with_users_answer: 'all_comments_on_watched_markets',
update_on_contract_with_users_answer: 'market_updates_on_watched_markets',
resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets',
answer_on_contract_with_users_answer: 'all_answers_on_watched_markets',
comment_on_contract_with_users_comment: 'all_comments_on_watched_markets',
answer_on_contract_with_users_comment: 'all_answers_on_watched_markets',
update_on_contract_with_users_comment: 'market_updates_on_watched_markets',
resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets',
reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets',
reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets',
}
export const getNotificationDestinationsForUser = (
privateUser: PrivateUser,
// TODO: accept reasons array from most to least important and work backwards
reason: notification_reason_types | notification_preference
) => {
const notificationSettings = privateUser.notificationPreferences
let destinations
let subscriptionType: notification_preference | undefined
if (Object.keys(notificationSettings).includes(reason)) {
subscriptionType = reason as notification_preference
destinations = notificationSettings[subscriptionType]
} else {
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key]
destinations = subscriptionType
? notificationSettings[subscriptionType]
: []
}
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return {
sendToEmail: destinations.includes('email'),
sendToBrowser: destinations.includes('browser'),
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
}
}

View File

@ -1,4 +1,5 @@
import { filterDefined } from './util/array' import { notification_preferences } from './user-notification-preferences'
import { ENV_CONFIG } from 'common/envs/constants'
export type User = { export type User = {
id: string id: string
@ -65,62 +66,14 @@ export type PrivateUser = {
initialDeviceToken?: string initialDeviceToken?: string
initialIpAddress?: string initialIpAddress?: string
apiKey?: string apiKey?: string
/** @deprecated - use notificationSubscriptionTypes */ notificationPreferences: notification_preferences
notificationPreferences?: notification_subscribe_types twitchInfo?: {
notificationSubscriptionTypes: notification_subscription_types twitchName: string
controlToken: string
botEnabled?: boolean
}
} }
export type notification_destination_types = 'email' | 'browser'
export type notification_subscription_types = {
// Watched Markets
all_comments_on_watched_markets: notification_destination_types[]
all_answers_on_watched_markets: notification_destination_types[]
// Comments
tipped_comments_on_watched_markets: notification_destination_types[]
comments_by_followed_users_on_watched_markets: notification_destination_types[]
all_replies_to_my_comments_on_watched_markets: notification_destination_types[]
all_replies_to_my_answers_on_watched_markets: notification_destination_types[]
all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
// Answers
answers_by_followed_users_on_watched_markets: notification_destination_types[]
answers_by_market_creator_on_watched_markets: notification_destination_types[]
all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
// On users' markets
your_contract_closed: notification_destination_types[]
all_comments_on_my_markets: notification_destination_types[]
all_answers_on_my_markets: notification_destination_types[]
subsidized_your_market: notification_destination_types[]
// Market updates
resolutions_on_watched_markets: notification_destination_types[]
resolutions_on_watched_markets_with_shares_in: notification_destination_types[]
market_updates_on_watched_markets: notification_destination_types[]
market_updates_on_watched_markets_with_shares_in: notification_destination_types[]
probability_updates_on_watched_markets: notification_destination_types[]
// Balance Changes
loan_income: notification_destination_types[]
betting_streaks: notification_destination_types[]
referral_bonuses: notification_destination_types[]
unique_bettors_on_your_contract: notification_destination_types[]
tips_on_your_comments: notification_destination_types[]
tips_on_your_markets: notification_destination_types[]
limit_order_fills: notification_destination_types[]
// General
tagged_user: notification_destination_types[]
on_new_follow: notification_destination_types[]
contract_from_followed_user: notification_destination_types[]
trending_markets: notification_destination_types[]
profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[]
}
export type notification_subscribe_types = 'all' | 'less' | 'none'
export type PortfolioMetrics = { export type PortfolioMetrics = {
investmentValue: number investmentValue: number
balance: number balance: number
@ -132,139 +85,9 @@ export type PortfolioMetrics = {
export const MANIFOLD_USERNAME = 'ManifoldMarkets' export const MANIFOLD_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
export const getDefaultNotificationSettings = ( export const BETTOR = ENV_CONFIG.bettor ?? 'bettor' // aka predictor
userId: string, export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'bettors'
privateUser?: PrivateUser, export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'bet' // aka predict
noEmails?: boolean export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'bets'
) => { export const PAST_BET = ENV_CONFIG.pastBet ?? 'bet' // aka prediction
const prevPref = privateUser?.notificationPreferences ?? 'all' export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'bets' // aka predictions
const wantsLess = prevPref === 'less'
const wantsAll = prevPref === 'all'
const {
unsubscribedFromCommentEmails,
unsubscribedFromAnswerEmails,
unsubscribedFromResolutionEmails,
unsubscribedFromWeeklyTrendingEmails,
unsubscribedFromGenericEmails,
} = privateUser || {}
const constructPref = (browserIf: boolean, emailIf: boolean) => {
const browser = browserIf ? 'browser' : undefined
const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[]
}
return {
// Watched Markets
all_comments_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromCommentEmails
),
all_answers_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromAnswerEmails
),
// Comments
tips_on_your_comments: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
comments_by_followed_users_on_watched_markets: constructPref(
wantsAll,
false
),
all_replies_to_my_comments_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
all_replies_to_my_answers_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromCommentEmails
),
// Answers
answers_by_followed_users_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromAnswerEmails
),
answers_by_market_creator_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromAnswerEmails
),
all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromAnswerEmails
),
// On users' markets
your_contract_closed: constructPref(
wantsAll || wantsLess,
!unsubscribedFromResolutionEmails
), // High priority
all_comments_on_my_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
all_answers_on_my_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromAnswerEmails
),
subsidized_your_market: constructPref(wantsAll || wantsLess, true),
// Market updates
resolutions_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromResolutionEmails
),
market_updates_on_watched_markets: constructPref(
wantsAll || wantsLess,
false
),
market_updates_on_watched_markets_with_shares_in: constructPref(
wantsAll || wantsLess,
false
),
resolutions_on_watched_markets_with_shares_in: constructPref(
wantsAll || wantsLess,
!unsubscribedFromResolutionEmails
),
//Balance Changes
loan_income: constructPref(wantsAll || wantsLess, false),
betting_streaks: constructPref(wantsAll || wantsLess, false),
referral_bonuses: constructPref(wantsAll || wantsLess, true),
unique_bettors_on_your_contract: constructPref(
wantsAll || wantsLess,
false
),
tipped_comments_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
tips_on_your_markets: constructPref(wantsAll || wantsLess, true),
limit_order_fills: constructPref(wantsAll || wantsLess, false),
// General
tagged_user: constructPref(wantsAll || wantsLess, true),
on_new_follow: constructPref(wantsAll || wantsLess, true),
contract_from_followed_user: constructPref(wantsAll || wantsLess, true),
trending_markets: constructPref(
false,
!unsubscribedFromWeeklyTrendingEmails
),
profit_loss_updates: constructPref(false, true),
probability_updates_on_watched_markets: constructPref(
wantsAll || wantsLess,
false
),
thank_you_for_purchases: constructPref(
false,
!unsubscribedFromGenericEmails
),
onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails),
} as notification_subscription_types
}

View File

@ -14,7 +14,8 @@ service cloud.firestore {
'manticmarkets@gmail.com', 'manticmarkets@gmail.com',
'iansphilips@gmail.com', 'iansphilips@gmail.com',
'd4vidchee@gmail.com', 'd4vidchee@gmail.com',
'federicoruizcassarino@gmail.com' 'federicoruizcassarino@gmail.com',
'ingawei@gmail.com'
] ]
} }
@ -77,7 +78,7 @@ service cloud.firestore {
allow read: if userId == request.auth.uid || isAdmin(); allow read: if userId == request.auth.uid || isAdmin();
allow update: if (userId == request.auth.uid || isAdmin()) allow update: if (userId == request.auth.uid || isAdmin())
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]); .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'unsubscribedFromWeeklyTrendingEmails', 'notificationPreferences', 'twitchInfo']);
} }
match /private-users/{userId}/views/{viewId} { match /private-users/{userId}/views/{viewId} {
@ -161,7 +162,7 @@ service cloud.firestore {
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['isSeen', 'viewTime']); .hasOnly(['isSeen', 'viewTime']);
} }
match /{somePath=**}/groupMembers/{memberId} { match /{somePath=**}/groupMembers/{memberId} {
allow read; allow read;
} }

View File

@ -1,11 +1,16 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { Contract } from '../../common/contract' import { Contract, CPMMContract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { getNewLiquidityProvision } from '../../common/add-liquidity' import { getNewLiquidityProvision } from '../../common/add-liquidity'
import { APIError, newEndpoint, validate } from './api' 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({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -47,7 +52,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
getNewLiquidityProvision( getNewLiquidityProvision(
user, user.id,
amount, amount,
contract, contract,
newLiquidityProvisionDoc.id newLiquidityProvisionDoc.id
@ -88,3 +93,41 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
}) })
const firestore = admin.firestore() 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 { z } from 'zod'
import { getUser } from './utils' import { getUser } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { User } from '../../common/user' import { User } from '../../common/user'
@ -68,10 +69,21 @@ export const changeUser = async (
.get() .get()
const answerUpdate: Partial<Answer> = removeUndefinedProps(update) 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() const bulkWriter = firestore.bulkWriter()
commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate)) commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate))
answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate)) answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate))
contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate)) contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate))
betsSnap.docs.forEach((d) => bulkWriter.update(d.ref, betsUpdate))
await bulkWriter.flush() await bulkWriter.flush()
console.log('Done writing!') console.log('Done writing!')

View File

@ -1,6 +1,8 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { import {
getDestinationsForUser, BetFillData,
BettingStreakData,
ContractResolutionData,
Notification, Notification,
notification_reason_types, notification_reason_types,
} from '../../common/notification' } from '../../common/notification'
@ -26,6 +28,8 @@ import {
sendNewUniqueBettorsEmail, sendNewUniqueBettorsEmail,
} from './emails' } from './emails'
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import { ContractFollow } from '../../common/follow'
const firestore = admin.firestore() const firestore = admin.firestore()
type recipients_to_reason_texts = { type recipients_to_reason_texts = {
@ -65,7 +69,7 @@ export const createNotification = async (
const { reason } = userToReasonTexts[userId] const { reason } = userToReasonTexts[userId]
const privateUser = await getPrivateUser(userId) const privateUser = await getPrivateUser(userId)
if (!privateUser) continue if (!privateUser) continue
const { sendToBrowser, sendToEmail } = await getDestinationsForUser( const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
reason reason
) )
@ -157,7 +161,7 @@ export type replied_users_info = {
export const createCommentOrAnswerOrUpdatedContractNotification = async ( export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceId: string, sourceId: string,
sourceType: 'comment' | 'answer' | 'contract', sourceType: 'comment' | 'answer' | 'contract',
sourceUpdateType: 'created' | 'updated' | 'resolved', sourceUpdateType: 'created' | 'updated',
sourceUser: User, sourceUser: User,
idempotencyKey: string, idempotencyKey: string,
sourceText: string, sourceText: string,
@ -165,17 +169,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
miscData?: { miscData?: {
repliedUsersInfo: replied_users_info repliedUsersInfo: replied_users_info
taggedUserIds: string[] taggedUserIds: string[]
},
resolutionData?: {
bets: Bet[]
userInvestments: { [userId: string]: number }
userPayouts: { [userId: string]: number }
creator: User
creatorPayout: number
contract: Contract
outcome: string
resolutionProbability?: number
resolutions?: { [outcome: string]: number }
} }
) => { ) => {
const { repliedUsersInfo, taggedUserIds } = miscData ?? {} const { repliedUsersInfo, taggedUserIds } = miscData ?? {}
@ -228,14 +221,10 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
userId: string, userId: string,
reason: notification_reason_types reason: notification_reason_types
) => { ) => {
if ( if (!stillFollowingContract(userId) || sourceUser.id == userId) return
!stillFollowingContract(sourceContract.creatorId) ||
sourceUser.id == userId
)
return
const privateUser = await getPrivateUser(userId) const privateUser = await getPrivateUser(userId)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser, sendToEmail } = await getDestinationsForUser( const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
reason reason
) )
@ -274,24 +263,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceUser.avatarUrl sourceUser.avatarUrl
) )
emailRecipientIdsList.push(userId) emailRecipientIdsList.push(userId)
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'resolved' &&
resolutionData
) {
await sendMarketResolutionEmail(
reason,
privateUser,
resolutionData.userInvestments[userId] ?? 0,
resolutionData.userPayouts[userId] ?? 0,
sourceUser,
resolutionData.creatorPayout,
sourceContract,
resolutionData.outcome,
resolutionData.resolutionProbability,
resolutionData.resolutions
)
emailRecipientIdsList.push(userId)
} }
} }
@ -445,6 +416,8 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
) )
} }
//TODO: store all possible reasons why the user might be getting the notification and choose the most lenient that they
// have enabled so they will unsubscribe from the least important notifications
await notifyRepliedUser() await notifyRepliedUser()
await notifyTaggedUsers() await notifyTaggedUsers()
await notifyContractCreator() await notifyContractCreator()
@ -467,7 +440,7 @@ export const createTipNotification = async (
) => { ) => {
const privateUser = await getPrivateUser(toUser.id) const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser( const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser, privateUser,
'tip_received' 'tip_received'
) )
@ -512,7 +485,7 @@ export const createBetFillNotification = async (
) => { ) => {
const privateUser = await getPrivateUser(toUser.id) const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser( const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser, privateUser,
'bet_fill' 'bet_fill'
) )
@ -541,6 +514,12 @@ export const createBetFillNotification = async (
sourceContractTitle: contract.question, sourceContractTitle: contract.question,
sourceContractSlug: contract.slug, sourceContractSlug: contract.slug,
sourceContractId: contract.id, sourceContractId: contract.id,
data: {
betOutcome: bet.outcome,
creatorOutcome: userBet.outcome,
fillAmount,
probability: userBet.limitProb,
} as BetFillData,
} }
return await notificationRef.set(removeUndefinedProps(notification)) return await notificationRef.set(removeUndefinedProps(notification))
@ -557,7 +536,7 @@ export const createReferralNotification = async (
) => { ) => {
const privateUser = await getPrivateUser(toUser.id) const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser( const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser, privateUser,
'you_referred_user' 'you_referred_user'
) )
@ -611,7 +590,7 @@ export const createLoanIncomeNotification = async (
) => { ) => {
const privateUser = await getPrivateUser(toUser.id) const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser( const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser, privateUser,
'loan_income' 'loan_income'
) )
@ -649,7 +628,7 @@ export const createChallengeAcceptedNotification = async (
) => { ) => {
const privateUser = await getPrivateUser(challengeCreator.id) const privateUser = await getPrivateUser(challengeCreator.id)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser( const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser, privateUser,
'challenge_accepted' 'challenge_accepted'
) )
@ -686,11 +665,12 @@ export const createBettingStreakBonusNotification = async (
bet: Bet, bet: Bet,
contract: Contract, contract: Contract,
amount: number, amount: number,
streak: number,
idempotencyKey: string idempotencyKey: string
) => { ) => {
const privateUser = await getPrivateUser(user.id) const privateUser = await getPrivateUser(user.id)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser( const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser, privateUser,
'betting_streak_incremented' 'betting_streak_incremented'
) )
@ -719,6 +699,10 @@ export const createBettingStreakBonusNotification = async (
sourceContractId: contract.id, sourceContractId: contract.id,
sourceContractTitle: contract.question, sourceContractTitle: contract.question,
sourceContractCreatorUsername: contract.creatorUsername, sourceContractCreatorUsername: contract.creatorUsername,
data: {
streak: streak,
bonusAmount: amount,
} as BettingStreakData,
} }
return await notificationRef.set(removeUndefinedProps(notification)) return await notificationRef.set(removeUndefinedProps(notification))
} }
@ -733,7 +717,7 @@ export const createLikeNotification = async (
) => { ) => {
const privateUser = await getPrivateUser(toUser.id) const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser( const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser, privateUser,
'liked_and_tipped_your_contract' 'liked_and_tipped_your_contract'
) )
@ -780,7 +764,7 @@ export const createUniqueBettorBonusNotification = async (
) => { ) => {
const privateUser = await getPrivateUser(contractCreatorId) const privateUser = await getPrivateUser(contractCreatorId)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser, sendToEmail } = await getDestinationsForUser( const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'unique_bettors_on_your_contract' 'unique_bettors_on_your_contract'
) )
@ -870,7 +854,7 @@ export const createNewContractNotification = async (
) => { ) => {
const privateUser = await getPrivateUser(userId) const privateUser = await getPrivateUser(userId)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser, sendToEmail } = await getDestinationsForUser( const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
reason reason
) )
@ -930,3 +914,130 @@ export const createNewContractNotification = async (
await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user') await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user')
} }
} }
export const createContractResolvedNotifications = async (
contract: Contract,
creator: User,
outcome: string,
probabilityInt: number | undefined,
resolutionValue: number | undefined,
resolutionData: {
bets: Bet[]
userInvestments: { [userId: string]: number }
userPayouts: { [userId: string]: number }
creator: User
creatorPayout: number
contract: Contract
outcome: string
resolutionProbability?: number
resolutions?: { [outcome: string]: number }
}
) => {
let resolutionText = outcome ?? contract.question
if (
contract.outcomeType === 'FREE_RESPONSE' ||
contract.outcomeType === 'MULTIPLE_CHOICE'
) {
const answerText = contract.answers.find(
(answer) => answer.id === outcome
)?.text
if (answerText) resolutionText = answerText
} else if (contract.outcomeType === 'BINARY') {
if (resolutionText === 'MKT' && probabilityInt)
resolutionText = `${probabilityInt}%`
else if (resolutionText === 'MKT') resolutionText = 'PROB'
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
if (resolutionText === 'MKT' && resolutionValue)
resolutionText = `${resolutionValue}`
}
const idempotencyKey = contract.id + '-resolved'
const createBrowserNotification = async (
userId: string,
reason: notification_reason_types
) => {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reason,
createdTime: Date.now(),
isSeen: false,
sourceId: contract.id,
sourceType: 'contract',
sourceUpdateType: 'resolved',
sourceContractId: contract.id,
sourceUserName: creator.name,
sourceUserUsername: creator.username,
sourceUserAvatarUrl: creator.avatarUrl,
sourceText: resolutionText,
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: contract.slug,
sourceTitle: contract.question,
data: {
outcome,
userInvestment: resolutionData.userInvestments[userId] ?? 0,
userPayout: resolutionData.userPayouts[userId] ?? 0,
} as ContractResolutionData,
}
return await notificationRef.set(removeUndefinedProps(notification))
}
const sendNotificationsIfSettingsPermit = async (
userId: string,
reason: notification_reason_types
) => {
if (!stillFollowingContract(userId) || creator.id == userId) return
const privateUser = await getPrivateUser(userId)
if (!privateUser) return
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
reason
)
// Browser notifications
if (sendToBrowser) {
await createBrowserNotification(userId, reason)
}
// Emails notifications
if (sendToEmail)
await sendMarketResolutionEmail(
reason,
privateUser,
resolutionData.userInvestments[userId] ?? 0,
resolutionData.userPayouts[userId] ?? 0,
creator,
resolutionData.creatorPayout,
contract,
resolutionData.outcome,
resolutionData.resolutionProbability,
resolutionData.resolutions
)
}
const contractFollowersIds = (
await getValues<ContractFollow>(
firestore.collection(`contracts/${contract.id}/follows`)
)
).map((follow) => follow.id)
const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId)
}
await Promise.all(
contractFollowersIds.map((id) =>
sendNotificationsIfSettingsPermit(
id,
resolutionData.userInvestments[id]
? 'resolution_on_contract_with_users_shares_in'
: 'resolution_on_contract_you_follow'
)
)
)
}

View File

@ -1,11 +1,7 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { import { PrivateUser, User } from '../../common/user'
getDefaultNotificationSettings,
PrivateUser,
User,
} from '../../common/user'
import { getUser, getUserByUsername, getValues } from './utils' import { getUser, getUserByUsername, getValues } from './utils'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { import {
@ -22,6 +18,7 @@ import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy' import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
import { getDefaultNotificationPreferences } from '../../common/user-notification-preferences'
const bodySchema = z.object({ const bodySchema = z.object({
deviceToken: z.string().optional(), deviceToken: z.string().optional(),
@ -83,7 +80,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
email, email,
initialIpAddress: req.ip, initialIpAddress: req.ip,
initialDeviceToken: deviceToken, initialDeviceToken: deviceToken,
notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid), notificationPreferences: getDefaultNotificationPreferences(auth.uid),
} }
await firestore.collection('private-users').doc(auth.uid).create(privateUser) await firestore.collection('private-users').doc(auth.uid).create(privateUser)

View File

@ -1,321 +0,0 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>Manifold Markets 7th Day Anniversary Gift!</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing:normal;background-color:#F4F4F4;">
<div style="background-color:#F4F4F4;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tbody>
<tr>
<td align="center"
style="font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img
alt="" height="auto" src="https://i.imgur.com/8EP8Y8q.gif"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
width="550"></a></td>
</tr>
</tbody>
</table>
</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;">
Hi {{name}},</span></p>
</div>
</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;">Thanks for
using Manifold Markets. Running low
on mana (M$)? Click the link below to receive a one time gift of M$500!</span></p>
</div>
</td>
</tr>
<tr>
<td>
<p></p>
</td>
</tr>
<tr>
<td align="center">
<table cellspacing="0" cellpadding="0">
<tr>
<td>
<table cellspacing="0" cellpadding="0">
<tr>
<td style="border-radius: 2px;" bgcolor="#4337c9">
<a href="{{manalink}}" target="_blank"
style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;">
Claim M$500
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;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: 23px; margin: 10px 0; margin-top: 10px;"
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
you know, besides making correct predictions, there are
plenty of other ways to earn mana?</span></p>
<ul>
<li style="line-height:23px;"><span
style="font-family:Arial, sans-serif;font-size:18px;">Receiving
tips on comments</span></li>
<li style="line-height:23px;"><span
style="font-family:Arial, sans-serif;font-size:18px;">Unique
trader bonus for each user who bets on your
markets</span></li>
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank" href="https://manifold.markets/referrals"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Referring
friends</u></span></a></span></li>
<li style="line-height:23px;"><a class="link-build-content"
style="color:inherit;; text-decoration: none;" target="_blank"
href="https://manifold.markets/group/bugs?s=most-traded"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting
bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;">
and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank"
href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving
feedback</u></span></a></li>
</ul>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;">&nbsp;</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">David
from Manifold</span></p>
<p class="text-build-content" data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-bottom: 10px;">&nbsp;</p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;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: 23px; margin: 10px 0; margin-top: 10px;"
data-testid="3Q8BP69fq"></a></li>
</ul>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;">&nbsp;</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span></p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">David from Manifold</span></p>
<p class="text-build-content" data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-bottom: 10px;">&nbsp;</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td align="center"
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:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
<p style="margin: 10px 0;">This e-mail has been sent to {{name}},
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</p>
</div>
</td>
</tr>
<tr>
<td align="center"
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:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@ -494,7 +494,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</p> </p>
</div> </div>
</td> </td>

View File

@ -443,7 +443,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</p> </p>
</div> </div>
</td> </td>

View File

@ -529,7 +529,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -369,7 +369,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -487,7 +487,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -369,7 +369,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -470,7 +470,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -502,7 +502,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -318,7 +318,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</p> </p>
</div> </div>
</td> </td>

View File

@ -376,7 +376,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -480,7 +480,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -283,7 +283,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</p> </p>
</div> </div>
</td> </td>

View File

@ -218,7 +218,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</p> </p>
</div> </div>
</td> </td>

View File

@ -290,7 +290,7 @@
<a href="{{unsubscribeUrl}}" style=" <a href="{{unsubscribeUrl}}" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to manage your notifications</a>. " target="_blank">click here to unsubscribe from this type of notification</a>.
</p> </p>
</div> </div>
</td> </td>

View File

@ -2,11 +2,7 @@ import { DOMAIN } from '../../common/envs/constants'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { import { PrivateUser, User } from '../../common/user'
notification_subscription_types,
PrivateUser,
User,
} from '../../common/user'
import { import {
formatLargeNumber, formatLargeNumber,
formatMoney, formatMoney,
@ -18,11 +14,12 @@ import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email' import { sendTemplateEmail, sendTextEmail } from './send-email'
import { getUser } from './utils' import { getUser } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { import { notification_reason_types } from '../../common/notification'
notification_reason_types,
getDestinationsForUser,
} from '../../common/notification'
import { Dictionary } from 'lodash' import { Dictionary } from 'lodash'
import {
getNotificationDestinationsForUser,
notification_preference,
} from '../../common/user-notification-preferences'
export const sendMarketResolutionEmail = async ( export const sendMarketResolutionEmail = async (
reason: notification_reason_types, reason: notification_reason_types,
@ -36,8 +33,10 @@ export const sendMarketResolutionEmail = async (
resolutionProbability?: number, resolutionProbability?: number,
resolutions?: { [outcome: string]: number } resolutions?: { [outcome: string]: number }
) => { ) => {
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
await getDestinationsForUser(privateUser, reason) privateUser,
reason
)
if (!privateUser || !privateUser.email || !sendToEmail) return if (!privateUser || !privateUser.email || !sendToEmail) return
const user = await getUser(privateUser.id) const user = await getUser(privateUser.id)
@ -154,7 +153,7 @@ export const sendWelcomeEmail = async (
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' as keyof notification_subscription_types 'onboarding_flow' as notification_preference
}` }`
return await sendTemplateEmail( return await sendTemplateEmail(
@ -214,7 +213,7 @@ export const sendOneWeekBonusEmail = async (
if ( if (
!privateUser || !privateUser ||
!privateUser.email || !privateUser.email ||
!privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') !privateUser.notificationPreferences.onboarding_flow.includes('email')
) )
return return
@ -222,7 +221,7 @@ export const sendOneWeekBonusEmail = async (
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' as keyof notification_subscription_types 'onboarding_flow' as notification_preference
}` }`
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -247,7 +246,7 @@ export const sendCreatorGuideEmail = async (
if ( if (
!privateUser || !privateUser ||
!privateUser.email || !privateUser.email ||
!privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') !privateUser.notificationPreferences.onboarding_flow.includes('email')
) )
return return
@ -255,7 +254,7 @@ export const sendCreatorGuideEmail = async (
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' as keyof notification_subscription_types 'onboarding_flow' as notification_preference
}` }`
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -279,7 +278,7 @@ export const sendThankYouEmail = async (
if ( if (
!privateUser || !privateUser ||
!privateUser.email || !privateUser.email ||
!privateUser.notificationSubscriptionTypes.thank_you_for_purchases.includes( !privateUser.notificationPreferences.thank_you_for_purchases.includes(
'email' 'email'
) )
) )
@ -289,7 +288,7 @@ export const sendThankYouEmail = async (
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'thank_you_for_purchases' as keyof notification_subscription_types 'thank_you_for_purchases' as notification_preference
}` }`
return await sendTemplateEmail( return await sendTemplateEmail(
@ -312,8 +311,10 @@ export const sendMarketCloseEmail = async (
privateUser: PrivateUser, privateUser: PrivateUser,
contract: Contract contract: Contract
) => { ) => {
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
await getDestinationsForUser(privateUser, reason) privateUser,
reason
)
if (!privateUser.email || !sendToEmail) return if (!privateUser.email || !sendToEmail) return
@ -350,8 +351,10 @@ export const sendNewCommentEmail = async (
answerText?: string, answerText?: string,
answerId?: string answerId?: string
) => { ) => {
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
await getDestinationsForUser(privateUser, reason) privateUser,
reason
)
if (!privateUser || !privateUser.email || !sendToEmail) return if (!privateUser || !privateUser.email || !sendToEmail) return
const { question } = contract const { question } = contract
@ -425,8 +428,10 @@ export const sendNewAnswerEmail = async (
// Don't send the creator's own answers. // Don't send the creator's own answers.
if (privateUser.id === creatorId) return if (privateUser.id === creatorId) return
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
await getDestinationsForUser(privateUser, reason) privateUser,
reason
)
if (!privateUser.email || !sendToEmail) return if (!privateUser.email || !sendToEmail) return
const { question, creatorUsername, slug } = contract const { question, creatorUsername, slug } = contract
@ -460,14 +465,12 @@ export const sendInterestingMarketsEmail = async (
if ( if (
!privateUser || !privateUser ||
!privateUser.email || !privateUser.email ||
!privateUser.notificationSubscriptionTypes.trending_markets.includes( !privateUser.notificationPreferences.trending_markets.includes('email')
'email'
)
) )
return return
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${ const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'trending_markets' as keyof notification_subscription_types 'trending_markets' as notification_preference
}` }`
const { name } = user const { name } = user
@ -518,8 +521,10 @@ export const sendNewFollowedMarketEmail = async (
privateUser: PrivateUser, privateUser: PrivateUser,
contract: Contract contract: Contract
) => { ) => {
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
await getDestinationsForUser(privateUser, reason) privateUser,
reason
)
if (!privateUser.email || !sendToEmail) return if (!privateUser.email || !sendToEmail) return
const user = await getUser(privateUser.id) const user = await getUser(privateUser.id)
if (!user) return if (!user) return
@ -555,8 +560,10 @@ export const sendNewUniqueBettorsEmail = async (
userBets: Dictionary<[Bet, ...Bet[]]>, userBets: Dictionary<[Bet, ...Bet[]]>,
bonusAmount: number bonusAmount: number
) => { ) => {
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
await getDestinationsForUser(privateUser, reason) privateUser,
reason
)
if (!privateUser.email || !sendToEmail) return if (!privateUser.email || !sendToEmail) return
const user = await getUser(privateUser.id) const user = await getUser(privateUser.id)
if (!user) return if (!user) return

View File

@ -71,6 +71,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge' import { acceptchallenge } from './accept-challenge'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any) return onRequest(opts, handler as any)
@ -96,6 +97,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser) const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge) const acceptChallenge = toCloudFunction(acceptchallenge)
const createPostFunction = toCloudFunction(createpost) const createPostFunction = toCloudFunction(createpost)
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
export { export {
healthFunction as health, healthFunction as health,
@ -119,4 +121,5 @@ export {
getCurrentUserFunction as getcurrentuser, getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge, acceptChallenge as acceptchallenge,
createPostFunction as createpost, createPostFunction as createpost,
saveTwitchCredentials as savetwitchcredentials
} }

View File

@ -24,6 +24,10 @@ import {
} from '../../common/antes' } from '../../common/antes'
import { APIError } from '../../common/api' import { APIError } from '../../common/api'
import { User } from '../../common/user' import { User } from '../../common/user'
import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes'
import { addHouseLiquidity } from './add-liquidity'
import { DAY_MS } from '../../common/util/time'
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
const firestore = admin.firestore() const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
@ -59,6 +63,12 @@ export const onCreateBet = functions
const bettor = await getUser(bet.userId) const bettor = await getUser(bet.userId)
if (!bettor) return if (!bettor) return
await change.ref.update({
userAvatarUrl: bettor.avatarUrl,
userName: bettor.name,
userUsername: bettor.username,
})
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor) await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor)
await notifyFills(bet, contract, eventId, bettor) await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId) await updateBettingStreak(bettor, bet, contract, eventId)
@ -72,12 +82,16 @@ const updateBettingStreak = async (
contract: Contract, contract: Contract,
eventId: string eventId: string
) => { ) => {
const betStreakResetTime = getTodaysBettingStreakResetTime() const now = Date.now()
const currentDateResetTime = currentDateBettingStreakResetTime()
// if now is before reset time, use yesterday's reset time
const lastDateResetTime = currentDateResetTime - DAY_MS
const betStreakResetTime =
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime
const lastBetTime = user?.lastBetTime ?? 0 const lastBetTime = user?.lastBetTime ?? 0
// If they've already bet after the reset time, or if we haven't hit the reset time yet // If they've already bet after the reset time
if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime) if (lastBetTime > betStreakResetTime) return
return
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1 const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak // Otherwise, add 1 to their betting streak
@ -96,6 +110,7 @@ const updateBettingStreak = async (
const bonusTxnDetails = { const bonusTxnDetails = {
currentBettingStreak: newBettingStreak, currentBettingStreak: newBettingStreak,
} }
// TODO: set the id of the txn to the eventId to prevent duplicates
const result = await firestore.runTransaction(async (trans) => { const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = { const bonusTxn: TxnData = {
fromId: fromUserId, fromId: fromUserId,
@ -106,11 +121,14 @@ const updateBettingStreak = async (
token: 'M$', token: 'M$',
category: 'BETTING_STREAK_BONUS', category: 'BETTING_STREAK_BONUS',
description: JSON.stringify(bonusTxnDetails), description: JSON.stringify(bonusTxnDetails),
} data: bonusTxnDetails,
} as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'>
return await runTxn(trans, bonusTxn) return await runTxn(trans, bonusTxn)
}) })
if (!result.txn) { if (!result.txn) {
log("betting streak bonus txn couldn't be made") log("betting streak bonus txn couldn't be made")
log('status:', result.status)
log('message:', result.message)
return return
} }
@ -120,6 +138,7 @@ const updateBettingStreak = async (
bet, bet,
contract, contract,
bonusAmount, bonusAmount,
newBettingStreak,
eventId eventId
) )
} }
@ -149,12 +168,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
} }
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id) const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id]) const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
// Update contract unique bettors // Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) { if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`) log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`) isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await firestore.collection(`contracts`).doc(contract.id).update({ await firestore.collection(`contracts`).doc(contract.id).update({
uniqueBettorIds: newUniqueBettorIds, uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length, uniqueBettorCount: newUniqueBettorIds.length,
@ -164,10 +184,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
// No need to give a bonus for the creator's bet // No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettor.id == contract.creatorId) return if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
if (contract.mechanism === 'cpmm-1') {
await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT)
}
// Create combined txn for all new unique bettors // Create combined txn for all new unique bettors
const bonusTxnDetails = { const bonusTxnDetails = {
contractId: contract.id, contractId: contract.id,
uniqueBettorIds: newUniqueBettorIds, uniqueNewBettorId: bettor.id,
} }
const fromUserId = isProd() const fromUserId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID ? HOUSE_LIQUIDITY_PROVIDER_ID
@ -175,6 +199,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
const fromSnap = await firestore.doc(`users/${fromUserId}`).get() const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.') if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User const fromUser = fromSnap.data() as User
// TODO: set the id of the txn to the eventId to prevent duplicates
const result = await firestore.runTransaction(async (trans) => { const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = { const bonusTxn: TxnData = {
fromId: fromUser.id, fromId: fromUser.id,
@ -185,12 +210,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
token: 'M$', token: 'M$',
category: 'UNIQUE_BETTOR_BONUS', category: 'UNIQUE_BETTOR_BONUS',
description: JSON.stringify(bonusTxnDetails), description: JSON.stringify(bonusTxnDetails),
} data: bonusTxnDetails,
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
return await runTxn(trans, bonusTxn) return await runTxn(trans, bonusTxn)
}) })
if (result.status != 'success' || !result.txn) { if (result.status != 'success' || !result.txn) {
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status) log(`No bonus for user: ${contract.creatorId} - status:`, result.status)
log('message:', result.message)
} else { } else {
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
await createUniqueBettorBonusNotification( await createUniqueBettorBonusNotification(
@ -246,6 +273,6 @@ const notifyFills = async (
) )
} }
const getTodaysBettingStreakResetTime = () => { const currentDateBettingStreakResetTime = () => {
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
} }

View File

@ -7,6 +7,7 @@ import { FIXED_ANTE } from '../../common/economy'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID,
UNIQUE_BETTOR_LIQUIDITY_AMOUNT,
} from '../../common/antes' } from '../../common/antes'
export const onCreateLiquidityProvision = functions.firestore 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 // Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
if ( if (
(liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID || liquidity.isAnte ||
((liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
liquidity.userId === DEV_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 return

View File

@ -139,7 +139,14 @@ export const placebet = newEndpoint({}, async (req, auth) => {
} }
const betDoc = contractDoc.collection('bets').doc() 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.') log('Created new bet document.')
if (makers) { if (makers) {

View File

@ -9,19 +9,25 @@ import {
RESOLUTIONS, RESOLUTIONS,
} from '../../common/contract' } from '../../common/contract'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getUser, isProd, payUser } from './utils' import { getUser, getValues, isProd, log, payUser } from './utils'
import { import {
getLoanPayouts, getLoanPayouts,
getPayouts, getPayouts,
groupPayoutsByUser, groupPayoutsByUser,
Payout, Payout,
} from '../../common/payouts' } from '../../common/payouts'
import { isManifoldId } from '../../common/envs/constants' import { isAdmin, isManifoldId } from '../../common/envs/constants'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { getContractBetMetrics } from '../../common/calculate' import { getContractBetMetrics } from '../../common/calculate'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { createContractResolvedNotifications } from './create-notification'
import { CancelUniqueBettorBonusTxn, Txn } from '../../common/txn'
import { runTxn, TxnData } from './transact'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -76,13 +82,18 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
throw new APIError(404, 'No contract exists with the provided ID') throw new APIError(404, 'No contract exists with the provided ID')
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
const { creatorId, closeTime } = contract const { creatorId, closeTime } = contract
const firebaseUser = await admin.auth().getUser(auth.uid)
const { value, resolutions, probabilityInt, outcome } = getResolutionParams( const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
contract, contract,
req.body req.body
) )
if (creatorId !== auth.uid && !isManifoldId(auth.uid)) if (
creatorId !== auth.uid &&
!isManifoldId(auth.uid) &&
!isAdmin(firebaseUser.email)
)
throw new APIError(403, 'User is not creator of contract') throw new APIError(403, 'User is not creator of contract')
if (contract.resolution) throw new APIError(400, 'Contract already resolved') if (contract.resolution) throw new APIError(400, 'Contract already resolved')
@ -158,6 +169,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
await processPayouts(liquidityPayouts, true) await processPayouts(liquidityPayouts, true)
await processPayouts([...payouts, ...loanPayouts]) await processPayouts([...payouts, ...loanPayouts])
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
@ -165,33 +177,13 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
groupBy(bets, (bet) => bet.userId), groupBy(bets, (bet) => bet.userId),
(bets) => getContractBetMetrics(contract, bets).invested (bets) => getContractBetMetrics(contract, bets).invested
) )
let resolutionText = outcome ?? contract.question
if (
contract.outcomeType === 'FREE_RESPONSE' ||
contract.outcomeType === 'MULTIPLE_CHOICE'
) {
const answerText = contract.answers.find(
(answer) => answer.id === outcome
)?.text
if (answerText) resolutionText = answerText
} else if (contract.outcomeType === 'BINARY') {
if (resolutionText === 'MKT' && probabilityInt)
resolutionText = `${probabilityInt}%`
else if (resolutionText === 'MKT') resolutionText = 'PROB'
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
if (resolutionText === 'MKT' && value) resolutionText = `${value}`
}
// TODO: this actually may be too slow to complete with a ton of users to notify? await createContractResolvedNotifications(
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'resolved',
creator,
contract.id + '-resolution',
resolutionText,
contract, contract,
undefined, creator,
outcome,
probabilityInt,
value,
{ {
bets, bets,
userInvestments, userInvestments,
@ -294,4 +286,55 @@ function validateAnswer(
} }
} }
async function undoUniqueBettorRewardsIfCancelResolution(
contract: Contract,
outcome: string
) {
if (outcome === 'CANCEL') {
const creatorsBonusTxns = await getValues<Txn>(
firestore
.collection('txns')
.where('category', '==', 'UNIQUE_BETTOR_BONUS')
.where('toId', '==', contract.creatorId)
)
const bonusTxnsOnThisContract = creatorsBonusTxns.filter(
(txn) => txn.data && txn.data.contractId === contract.id
)
log('total bonusTxnsOnThisContract', bonusTxnsOnThisContract.length)
const totalBonusAmount = sumBy(bonusTxnsOnThisContract, (txn) => txn.amount)
log('totalBonusAmount to be withdrawn', totalBonusAmount)
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: contract.creatorId,
fromType: 'USER',
toId: isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
toType: 'BANK',
amount: totalBonusAmount,
token: 'M$',
category: 'CANCEL_UNIQUE_BETTOR_BONUS',
data: {
contractId: contract.id,
},
} as Omit<CancelUniqueBettorBonusTxn, 'id' | 'createdTime'>
return await runTxn(trans, bonusTxn)
})
if (result.status != 'success' || !result.txn) {
log(
`Couldn't cancel bonus for user: ${contract.creatorId} - status:`,
result.status
)
log('message:', result.message)
} else {
log(
`Cancel Bonus txn for user: ${contract.creatorId} completed:`,
result.txn?.id
)
}
}
}
const firestore = admin.firestore() const firestore = admin.firestore()

View File

@ -0,0 +1,22 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { newEndpoint, validate } from './api'
const bodySchema = z.object({
twitchInfo: z.object({
twitchName: z.string(),
controlToken: z.string(),
}),
})
export const savetwitchcredentials = newEndpoint({}, async (req, auth) => {
const { twitchInfo } = validate(bodySchema, req.body)
const userId = auth.uid
await firestore.doc(`private-users/${userId}`).update({ twitchInfo })
return { success: true }
})
const firestore = admin.firestore()

View File

@ -4,14 +4,14 @@ import { initAdmin } from './script-init'
initAdmin() initAdmin()
import { getValues } from '../utils' import { getValues } from '../utils'
import { Contract } from 'common/lib/contract' import { Contract } from 'common/contract'
import { Comment } from 'common/lib/comment' import { Comment } from 'common/comment'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { Bet } from 'common/lib/bet' import { Bet } from 'common/bet'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/lib/antes' } from 'common/antes'
const firestore = admin.firestore() const firestore = admin.firestore()

View File

@ -1,8 +1,8 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
import { getDefaultNotificationSettings } from 'common/user'
import { getAllPrivateUsers, isProd } from 'functions/src/utils' import { getAllPrivateUsers, isProd } from 'functions/src/utils'
import { getDefaultNotificationPreferences } from 'common/user-notification-preferences'
initAdmin() initAdmin()
const firestore = admin.firestore() const firestore = admin.firestore()
@ -17,7 +17,7 @@ async function main() {
.collection('private-users') .collection('private-users')
.doc(privateUser.id) .doc(privateUser.id)
.update({ .update({
notificationSubscriptionTypes: getDefaultNotificationSettings( notificationPreferences: getDefaultNotificationPreferences(
privateUser.id, privateUser.id,
privateUser, privateUser,
disableEmails disableEmails

View File

@ -3,8 +3,9 @@ import * as admin from 'firebase-admin'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()
import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user' import { PrivateUser, User } from 'common/user'
import { STARTING_BALANCE } from 'common/economy' import { STARTING_BALANCE } from 'common/economy'
import { getDefaultNotificationPreferences } from 'common/user-notification-preferences'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -21,7 +22,7 @@ async function main() {
id: user.id, id: user.id,
email, email,
username, username,
notificationSubscriptionTypes: getDefaultNotificationSettings(user.id), notificationPreferences: getDefaultNotificationPreferences(user.id),
} }
if (user.totalDeposits === undefined) { if (user.totalDeposits === undefined) {

View File

@ -3,12 +3,7 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
import { import { findDiffs, describeDiff, applyDiff } from './denormalize'
DocumentCorrespondence,
findDiffs,
describeDiff,
applyDiff,
} from './denormalize'
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
initAdmin() initAdmin()
@ -79,43 +74,36 @@ if (require.main === module) {
getAnswersByUserId(transaction), getAnswersByUserId(transaction),
]) ])
const usersContracts = Array.from( const usersContracts = Array.from(usersById.entries(), ([id, doc]) => {
usersById.entries(), return [doc, contractsByUserId.get(id) || []] as const
([id, doc]): DocumentCorrespondence => { })
return [doc, contractsByUserId.get(id) || []] const contractDiffs = findDiffs(usersContracts, [
}
)
const contractDiffs = findDiffs(
usersContracts,
'avatarUrl', 'avatarUrl',
'creatorAvatarUrl' 'creatorAvatarUrl',
) ])
console.log(`Found ${contractDiffs.length} contracts with mismatches.`) console.log(`Found ${contractDiffs.length} contracts with mismatches.`)
contractDiffs.forEach((d) => { contractDiffs.forEach((d) => {
console.log(describeDiff(d)) console.log(describeDiff(d))
applyDiff(transaction, d) applyDiff(transaction, d)
}) })
const usersComments = Array.from( const usersComments = Array.from(usersById.entries(), ([id, doc]) => {
usersById.entries(), return [doc, commentsByUserId.get(id) || []] as const
([id, doc]): DocumentCorrespondence => { })
return [doc, commentsByUserId.get(id) || []] const commentDiffs = findDiffs(usersComments, [
} 'avatarUrl',
) 'userAvatarUrl',
const commentDiffs = findDiffs(usersComments, 'avatarUrl', 'userAvatarUrl') ])
console.log(`Found ${commentDiffs.length} comments with mismatches.`) console.log(`Found ${commentDiffs.length} comments with mismatches.`)
commentDiffs.forEach((d) => { commentDiffs.forEach((d) => {
console.log(describeDiff(d)) console.log(describeDiff(d))
applyDiff(transaction, d) applyDiff(transaction, d)
}) })
const usersAnswers = Array.from( const usersAnswers = Array.from(usersById.entries(), ([id, doc]) => {
usersById.entries(), return [doc, answersByUserId.get(id) || []] as const
([id, doc]): DocumentCorrespondence => { })
return [doc, answersByUserId.get(id) || []] const answerDiffs = findDiffs(usersAnswers, ['avatarUrl', 'avatarUrl'])
}
)
const answerDiffs = findDiffs(usersAnswers, 'avatarUrl', 'avatarUrl')
console.log(`Found ${answerDiffs.length} answers with mismatches.`) console.log(`Found ${answerDiffs.length} answers with mismatches.`)
answerDiffs.forEach((d) => { answerDiffs.forEach((d) => {
console.log(describeDiff(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 * as admin from 'firebase-admin'
import { zip } from 'lodash' import { zip } from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
import { import { findDiffs, describeDiff, applyDiff } from './denormalize'
DocumentCorrespondence,
findDiffs,
describeDiff,
applyDiff,
} from './denormalize'
import { log } from '../utils' import { log } from '../utils'
import { Transaction } from 'firebase-admin/firestore' import { Transaction } from 'firebase-admin/firestore'
@ -41,17 +36,20 @@ async function denormalize() {
) )
) )
log(`Found ${bets.length} bets associated with comments.`) 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') // dev DB has some invalid bet IDs
const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome') const mapping = zip(bets, betComments)
log(`Found ${amountDiffs.length} comments with mismatched amounts.`) .filter(([bet, _]) => bet!.exists) // eslint-disable-line
log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`) .map(([bet, comment]) => {
const diffs = amountDiffs.concat(outcomeDiffs) 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) => { diffs.slice(0, 500).forEach((d) => {
log(describeDiff(d)) log(describeDiff(d))
applyDiff(trans, d) applyDiff(trans, d)

View File

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

View File

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

@ -0,0 +1,34 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { Txn } from 'common/txn'
import { getValues } from 'functions/src/utils'
initAdmin()
const firestore = admin.firestore()
async function main() {
// get all txns
const bonusTxns = await getValues<Txn>(
firestore
.collection('txns')
.where('category', 'in', ['UNIQUE_BETTOR_BONUS', 'BETTING_STREAK_BONUS'])
)
// JSON parse description field and add to data field
const updatedTxns = bonusTxns.map((txn) => {
txn.data = txn.description && JSON.parse(txn.description)
return txn
})
console.log('updatedTxns', updatedTxns[0])
// update txns
await Promise.all(
updatedTxns.map((txn) => {
return firestore.collection('txns').doc(txn.id).update({
data: txn.data,
})
})
)
}
if (require.main === module) main().then(() => process.exit())

View File

@ -0,0 +1,25 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getAllPrivateUsers } from 'functions/src/utils'
import { FieldValue } from 'firebase-admin/firestore'
initAdmin()
const firestore = admin.firestore()
async function main() {
const privateUsers = await getAllPrivateUsers()
await Promise.all(
privateUsers.map((privateUser) => {
if (!privateUser.id) return Promise.resolve()
return firestore.collection('private-users').doc(privateUser.id).update({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
notificationPreferences: privateUser.notificationSubscriptionTypes,
notificationSubscriptionTypes: FieldValue.delete(),
})
})
)
}
if (require.main === module) main().then(() => process.exit())

View File

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

View File

@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe' import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express() const app = express()
@ -65,6 +66,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/unsubscribe', unsubscribe)
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost) addEndpointRoute('/createpost', createpost)

View File

@ -1,79 +1,227 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { EndpointDefinition } from './api' import { EndpointDefinition } from './api'
import { getUser } from './utils' import { getPrivateUser } from './utils'
import { PrivateUser } from '../../common/user' import { PrivateUser } from '../../common/user'
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
import { notification_preference } from '../../common/user-notification-preferences'
export const unsubscribe: EndpointDefinition = { export const unsubscribe: EndpointDefinition = {
opts: { method: 'GET', minInstances: 1 }, opts: { method: 'GET', minInstances: 1 },
handler: async (req, res) => { handler: async (req, res) => {
const id = req.query.id as string const id = req.query.id as string
let type = req.query.type as string const type = req.query.type as string
if (!id || !type) { if (!id || !type) {
res.status(400).send('Empty id or type parameter.') res.status(400).send('Empty id or subscription type parameter.')
return
}
console.log(`Unsubscribing ${id} from ${type}`)
const notificationSubscriptionType = type as notification_preference
if (notificationSubscriptionType === undefined) {
res.status(400).send('Invalid subscription type parameter.')
return return
} }
if (type === 'market-resolved') type = 'market-resolve' const user = await getPrivateUser(id)
if (
![
'market-resolve',
'market-comment',
'market-answer',
'generic',
'weekly-trending',
].includes(type)
) {
res.status(400).send('Invalid type parameter.')
return
}
const user = await getUser(id)
if (!user) { if (!user) {
res.send('This user is not currently subscribed or does not exist.') res.send('This user is not currently subscribed or does not exist.')
return return
} }
const { name } = user const previousDestinations =
user.notificationPreferences[notificationSubscriptionType]
console.log(previousDestinations)
const { email } = user
const update: Partial<PrivateUser> = { const update: Partial<PrivateUser> = {
...(type === 'market-resolve' && { notificationPreferences: {
unsubscribedFromResolutionEmails: true, ...user.notificationPreferences,
}), [notificationSubscriptionType]: previousDestinations.filter(
...(type === 'market-comment' && { (destination) => destination !== 'email'
unsubscribedFromCommentEmails: true, ),
}), },
...(type === 'market-answer' && {
unsubscribedFromAnswerEmails: true,
}),
...(type === 'generic' && {
unsubscribedFromGenericEmails: true,
}),
...(type === 'weekly-trending' && {
unsubscribedFromWeeklyTrendingEmails: true,
}),
} }
await firestore.collection('private-users').doc(id).update(update) await firestore.collection('private-users').doc(id).update(update)
if (type === 'market-resolve') res.send(
res.send( `
`${name}, you have been unsubscribed from market resolution emails on Manifold Markets.` <!DOCTYPE html>
) <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
else if (type === 'market-comment') xmlns:o="urn:schemas-microsoft-com:office:office">
res.send(
`${name}, you have been unsubscribed from market comment emails on Manifold Markets.` <head>
) <title>Manifold Markets 7th Day Anniversary Gift!</title>
else if (type === 'market-answer') <!--[if !mso]><!-->
res.send( <meta http-equiv="X-UA-Compatible" content="IE=edge">
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.` <!--<![endif]-->
) <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
else if (type === 'weekly-trending') <meta name="viewport" content="width=device-width,initial-scale=1">
res.send( <style type="text/css">
`${name}, you have been unsubscribed from weekly trending emails on Manifold Markets.` #outlook a {
) padding: 0;
else res.send(`${name}, you have been unsubscribed.`) }
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing:normal;background-color:#F4F4F4;">
<div style="background-color:#F4F4F4;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tbody>
<tr>
<td style="width:550px;">
<a href="https://manifold.markets" target="_blank">
<img alt="banner logo" height="auto" src="https://manifold.markets/logo-banner.png"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
title="" width="550">
</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;">
Hello!</span></p>
</div>
</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;">
${email} has been unsubscribed from email notifications related to:
</span>
<br/>
<br/>
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
</p>
<br/>
<br/>
<br/>
<span>Click
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
to manage the rest of your notification settings.
</span>
</div>
</td>
</tr>
<tr>
<td>
<p></p>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
`
)
}, },
} }

View File

@ -4,7 +4,7 @@ import { chunk } from 'lodash'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { Post } from 'common/post' import { Post } from '../../common/post'
export const log = (...args: unknown[]) => { export const log = (...args: unknown[]) => {
console.log(`[${new Date().toISOString()}]`, ...args) console.log(`[${new Date().toISOString()}]`, ...args)

View File

@ -11,6 +11,8 @@ import { ResolveConfirmationButton } from '../confirmation-button'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
export function AnswerResolvePanel(props: { export function AnswerResolvePanel(props: {
isAdmin: boolean
isCreator: boolean
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
setResolveOption: ( setResolveOption: (
@ -18,7 +20,14 @@ export function AnswerResolvePanel(props: {
) => void ) => void
chosenAnswers: { [answerId: string]: number } chosenAnswers: { [answerId: string]: number }
}) { }) {
const { contract, resolveOption, setResolveOption, chosenAnswers } = props const {
contract,
resolveOption,
setResolveOption,
chosenAnswers,
isAdmin,
isCreator,
} = props
const answers = Object.keys(chosenAnswers) const answers = Object.keys(chosenAnswers)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -76,7 +85,14 @@ export function AnswerResolvePanel(props: {
return ( return (
<Col className="gap-4 rounded"> <Col className="gap-4 rounded">
<div>Resolve your market</div> <Row className="justify-between">
<div>Resolve your market</div>
{isAdmin && !isCreator && (
<span className="rounded bg-red-200 p-1 text-xs text-red-600">
ADMIN
</span>
)}
</Row>
<Col className="gap-4 sm:flex-row sm:items-center"> <Col className="gap-4 sm:flex-row sm:items-center">
<ChooseCancelSelector <ChooseCancelSelector
className="sm:!flex-row sm:items-center" className="sm:!flex-row sm:items-center"

View File

@ -24,10 +24,13 @@ import { Linkify } from 'web/components/linkify'
import { BuyButton } from 'web/components/yes-no-selector' import { BuyButton } from 'web/components/yes-no-selector'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { useAdmin } from 'web/hooks/use-admin'
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
export function AnswersPanel(props: { export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
}) { }) {
const isAdmin = useAdmin()
const { contract } = props const { contract } = props
const { creatorId, resolution, resolutions, totalBets, outcomeType } = const { creatorId, resolution, resolutions, totalBets, outcomeType } =
contract contract
@ -154,17 +157,20 @@ export function AnswersPanel(props: {
<CreateAnswerPanel contract={contract} /> <CreateAnswerPanel contract={contract} />
)} )}
{user?.id === creatorId && !resolution && ( {(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) &&
<> !resolution && (
<Spacer h={2} /> <>
<AnswerResolvePanel <Spacer h={2} />
contract={contract} <AnswerResolvePanel
resolveOption={resolveOption} isAdmin={isAdmin}
setResolveOption={setResolveOption} isCreator={user?.id === creatorId}
chosenAnswers={chosenAnswers} contract={contract}
/> resolveOption={resolveOption}
</> setResolveOption={setResolveOption}
)} chosenAnswers={chosenAnswers}
/>
</>
)}
</Col> </Col>
) )
} }

View File

@ -10,6 +10,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { PRESENT_BET } from 'common/user'
/** 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: {
@ -36,12 +37,12 @@ export default function BetButton(props: {
<Button <Button
size="lg" size="lg"
className={clsx( className={clsx(
'my-auto inline-flex min-w-[75px] whitespace-nowrap', 'my-auto inline-flex min-w-[75px] whitespace-nowrap capitalize',
btnClassName btnClassName
)} )}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
Predict {PRESENT_BET}
</Button> </Button>
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />

View File

@ -79,7 +79,7 @@ export function BetInline(props: {
return ( return (
<Col className={clsx('items-center', className)}> <Col className={clsx('items-center', className)}>
<Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3"> <Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3">
<div className="text-xl">Bet</div> <div className="text-xl">Predict</div>
<YesNoSelector <YesNoSelector
className="space-x-0" className="space-x-0"
btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl" btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl"

View File

@ -3,7 +3,7 @@ import algoliasearch from 'algoliasearch/lite'
import { SearchOptions } from '@algolia/client-search' import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { User } from 'common/user' import { PAST_BETS, User } from 'common/user'
import { import {
ContractHighlightOptions, ContractHighlightOptions,
ContractsGrid, ContractsGrid,
@ -41,7 +41,7 @@ const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
export const SORTS = [ export const SORTS = [
{ label: 'Newest', value: 'newest' }, { label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' }, { label: 'Trending', value: 'score' },
{ label: 'Most traded', value: 'most-traded' }, { label: `Most ${PAST_BETS}`, value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' }, { label: '24h volume', value: '24-hour-vol' },
{ label: '24h change', value: 'prob-change-day' }, { label: '24h change', value: 'prob-change-day' },
{ label: 'Last updated', value: 'last-updated' }, { label: 'Last updated', value: 'last-updated' },
@ -80,9 +80,10 @@ export function ContractSearch(props: {
highlightOptions?: ContractHighlightOptions highlightOptions?: ContractHighlightOptions
onContractClick?: (contract: Contract) => void onContractClick?: (contract: Contract) => void
hideOrderSelector?: boolean hideOrderSelector?: boolean
cardHideOptions?: { cardUIOptions?: {
hideGroupLink?: boolean hideGroupLink?: boolean
hideQuickBet?: boolean hideQuickBet?: boolean
noLinkAvatar?: boolean
} }
headerClassName?: string headerClassName?: string
persistPrefix?: string persistPrefix?: string
@ -102,7 +103,7 @@ export function ContractSearch(props: {
additionalFilter, additionalFilter,
onContractClick, onContractClick,
hideOrderSelector, hideOrderSelector,
cardHideOptions, cardUIOptions,
highlightOptions, highlightOptions,
headerClassName, headerClassName,
persistPrefix, persistPrefix,
@ -164,6 +165,7 @@ export function ContractSearch(props: {
numericFilters, numericFilters,
page: requestedPage, page: requestedPage,
hitsPerPage: 20, hitsPerPage: 20,
advancedSyntax: true,
}) })
// if there's a more recent request, forget about this one // if there's a more recent request, forget about this one
if (id === requestId.current) { if (id === requestId.current) {
@ -222,7 +224,7 @@ export function ContractSearch(props: {
showTime={state.showTime ?? undefined} showTime={state.showTime ?? undefined}
onContractClick={onContractClick} onContractClick={onContractClick}
highlightOptions={highlightOptions} highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions} cardUIOptions={cardUIOptions}
/> />
)} )}
</Col> </Col>
@ -449,7 +451,7 @@ function ContractSearchControls(props: {
selected={state.pillFilter === 'your-bets'} selected={state.pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')} onSelect={selectPill('your-bets')}
> >
Your trades Your {PAST_BETS}
</PillButton> </PillButton>
)} )}

View File

@ -85,7 +85,11 @@ export function SelectMarketsModal(props: {
<ContractSearch <ContractSearch
hideOrderSelector hideOrderSelector
onContractClick={addContract} onContractClick={addContract}
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} cardUIOptions={{
hideGroupLink: true,
hideQuickBet: true,
noLinkAvatar: true,
}}
highlightOptions={{ highlightOptions={{
contractIds: contracts.map((c) => c.id), contractIds: contracts.map((c) => c.id),
highlightClassName: highlightClassName:

View File

@ -42,6 +42,7 @@ export function ContractCard(props: {
hideQuickBet?: boolean hideQuickBet?: boolean
hideGroupLink?: boolean hideGroupLink?: boolean
trackingPostfix?: string trackingPostfix?: string
noLinkAvatar?: boolean
}) { }) {
const { const {
showTime, showTime,
@ -51,6 +52,7 @@ export function ContractCard(props: {
hideQuickBet, hideQuickBet,
hideGroupLink, hideGroupLink,
trackingPostfix, trackingPostfix,
noLinkAvatar,
} = props } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract const { question, outcomeType } = contract
@ -78,6 +80,7 @@ export function ContractCard(props: {
<AvatarDetails <AvatarDetails
contract={contract} contract={contract}
className={'hidden md:inline-flex'} className={'hidden md:inline-flex'}
noLink={noLinkAvatar}
/> />
<p <p
className={clsx( className={clsx(
@ -142,7 +145,12 @@ export function ContractCard(props: {
showQuickBet ? 'w-[85%]' : 'w-full' showQuickBet ? 'w-[85%]' : 'w-full'
)} )}
> >
<AvatarDetails contract={contract} short={true} className="md:hidden" /> <AvatarDetails
contract={contract}
short={true}
className="md:hidden"
noLink={noLinkAvatar}
/>
<MiscDetails <MiscDetails
contract={contract} contract={contract}
showTime={showTime} showTime={showTime}

View File

@ -86,8 +86,9 @@ export function AvatarDetails(props: {
contract: Contract contract: Contract
className?: string className?: string
short?: boolean short?: boolean
noLink?: boolean
}) { }) {
const { contract, short, className } = props const { contract, short, className, noLink } = props
const { creatorName, creatorUsername, creatorAvatarUrl } = contract const { creatorName, creatorUsername, creatorAvatarUrl } = contract
return ( return (
@ -98,8 +99,14 @@ export function AvatarDetails(props: {
username={creatorUsername} username={creatorUsername}
avatarUrl={creatorAvatarUrl} avatarUrl={creatorAvatarUrl}
size={6} size={6}
noLink={noLink}
/>
<UserLink
name={creatorName}
username={creatorUsername}
short={short}
noLink={noLink}
/> />
<UserLink name={creatorName} username={creatorUsername} short={short} />
</Row> </Row>
) )
} }

View File

@ -18,6 +18,8 @@ import { deleteField } from 'firebase/firestore'
import ShortToggle from '../widgets/short-toggle' import ShortToggle from '../widgets/short-toggle'
import { DuplicateContractButton } from '../copy-contract-button' import { DuplicateContractButton } from '../copy-contract-button'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { BETTORS } from 'common/user'
import { capitalize } from 'lodash'
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'
@ -135,7 +137,7 @@ export function ContractInfoDialog(props: {
</tr> */} </tr> */}
<tr> <tr>
<td>Traders</td> <td>{capitalize(BETTORS)}</td>
<td>{bettorsCount}</td> <td>{bettorsCount}</td>
</tr> </tr>

View File

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

View File

@ -1,7 +1,7 @@
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract, CPMMBinaryContract } from 'common/contract' import { Contract, CPMMBinaryContract } from 'common/contract'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { User } from 'common/user' import { PAST_BETS, User } from 'common/user'
import { import {
ContractCommentsActivity, ContractCommentsActivity,
ContractBetsActivity, ContractBetsActivity,
@ -18,6 +18,11 @@ import { useLiquidity } from 'web/hooks/use-liquidity'
import { BetSignUpPrompt } from '../sign-up-prompt' import { BetSignUpPrompt } from '../sign-up-prompt'
import { PlayMoneyDisclaimer } from '../play-money-disclaimer' import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
import BetButton from '../bet-button' import BetButton from '../bet-button'
import { capitalize } from 'lodash'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes'
export function ContractTabs(props: { export function ContractTabs(props: {
contract: Contract contract: Contract
@ -36,13 +41,19 @@ export function ContractTabs(props: {
const visibleBets = bets.filter( const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0 (bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
) )
const visibleLps = lps?.filter((l) => !l.isAnte && l.amount > 0) const visibleLps = (lps ?? []).filter(
(l) =>
!l.isAnte &&
l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID &&
l.userId !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID &&
l.amount > 0
)
// Load comments here, so the badge count will be correct // Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id) const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments const comments = updatedComments ?? props.comments
const betActivity = visibleLps && ( const betActivity = lps != null && (
<ContractBetsActivity <ContractBetsActivity
contract={contract} contract={contract}
bets={visibleBets} bets={visibleBets}
@ -114,13 +125,13 @@ export function ContractTabs(props: {
badge: `${comments.length}`, badge: `${comments.length}`,
}, },
{ {
title: 'Trades', title: capitalize(PAST_BETS),
content: betActivity, content: betActivity,
badge: `${visibleBets.length}`, badge: `${visibleBets.length + visibleLps.length}`,
}, },
...(!user || !userBets?.length ...(!user || !userBets?.length
? [] ? []
: [{ title: 'Your trades', content: yourTrades }]), : [{ title: `Your ${PAST_BETS}`, content: yourTrades }]),
]} ]}
/> />
{!user ? ( {!user ? (

View File

@ -21,9 +21,10 @@ export function ContractsGrid(props: {
loadMore?: () => void loadMore?: () => void
showTime?: ShowTime showTime?: ShowTime
onContractClick?: (contract: Contract) => void onContractClick?: (contract: Contract) => void
cardHideOptions?: { cardUIOptions?: {
hideQuickBet?: boolean hideQuickBet?: boolean
hideGroupLink?: boolean hideGroupLink?: boolean
noLinkAvatar?: boolean
} }
highlightOptions?: ContractHighlightOptions highlightOptions?: ContractHighlightOptions
trackingPostfix?: string trackingPostfix?: string
@ -34,11 +35,11 @@ export function ContractsGrid(props: {
showTime, showTime,
loadMore, loadMore,
onContractClick, onContractClick,
cardHideOptions, cardUIOptions,
highlightOptions, highlightOptions,
trackingPostfix, trackingPostfix,
} = props } = props
const { hideQuickBet, hideGroupLink } = cardHideOptions || {} const { hideQuickBet, hideGroupLink, noLinkAvatar } = cardUIOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {} const { contractIds, highlightClassName } = highlightOptions || {}
const onVisibilityUpdated = useCallback( const onVisibilityUpdated = useCallback(
(visible) => { (visible) => {
@ -80,6 +81,7 @@ export function ContractsGrid(props: {
onClick={ onClick={
onContractClick ? () => onContractClick(contract) : undefined onContractClick ? () => onContractClick(contract) : undefined
} }
noLinkAvatar={noLinkAvatar}
hideQuickBet={hideQuickBet} hideQuickBet={hideQuickBet}
hideGroupLink={hideGroupLink} hideGroupLink={hideGroupLink}
trackingPostfix={trackingPostfix} trackingPostfix={trackingPostfix}

View File

@ -18,21 +18,22 @@ export const WatchMarketModal = (props: {
<Col className={'gap-2'}> <Col className={'gap-2'}>
<span className={'text-indigo-700'}> What is watching?</span> <span className={'text-indigo-700'}> What is watching?</span>
<span className={'ml-2'}> <span className={'ml-2'}>
You'll receive notifications on markets by betting, commenting, or Watching a market means you'll receive notifications from activity
clicking the on it. You automatically start watching a market if you comment on
it, bet on it, or click the
<EyeIcon <EyeIcon
className={clsx('ml-1 inline h-6 w-6 align-top')} className={clsx('ml-1 inline h-6 w-6 align-top')}
aria-hidden="true" aria-hidden="true"
/> />
button on them. button.
</span> </span>
<span className={'text-indigo-700'}> <span className={'text-indigo-700'}>
What types of notifications will I receive? What types of notifications will I receive?
</span> </span>
<span className={'ml-2'}> <span className={'ml-2'}>
You'll receive notifications for new comments, answers, and updates New comments, answers, and updates to the question. See the
to the question. See the notifications settings pages to customize notifications settings pages to customize which types of
which types of notifications you receive on watched markets. notifications you receive on watched markets.
</span> </span>
</Col> </Col>
</Col> </Col>

View File

@ -21,6 +21,8 @@ import { FileUploadButton } from './file-upload-button'
import { linkClass } from './site-link' import { linkClass } from './site-link'
import { mentionSuggestion } from './editor/mention-suggestion' import { mentionSuggestion } from './editor/mention-suggestion'
import { DisplayMention } from './editor/mention' import { DisplayMention } from './editor/mention'
import { contractMentionSuggestion } from './editor/contract-mention-suggestion'
import { DisplayContractMention } from './editor/contract-mention'
import Iframe from 'common/util/tiptap-iframe' import Iframe from 'common/util/tiptap-iframe'
import TiptapTweet from './editor/tiptap-tweet' import TiptapTweet from './editor/tiptap-tweet'
import { EmbedModal } from './editor/embed-modal' import { EmbedModal } from './editor/embed-modal'
@ -97,7 +99,12 @@ export function useTextEditor(props: {
CharacterCount.configure({ limit: max }), CharacterCount.configure({ limit: max }),
simple ? DisplayImage : Image, simple ? DisplayImage : Image,
DisplayLink, DisplayLink,
DisplayMention.configure({ suggestion: mentionSuggestion }), DisplayMention.configure({
suggestion: mentionSuggestion,
}),
DisplayContractMention.configure({
suggestion: contractMentionSuggestion,
}),
Iframe, Iframe,
TiptapTweet, TiptapTweet,
], ],
@ -316,13 +323,21 @@ export function RichContent(props: {
smallImage ? DisplayImage : Image, smallImage ? DisplayImage : Image,
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
DisplayMention, DisplayMention,
DisplayContractMention.configure({
// Needed to set a different PluginKey for Prosemirror
suggestion: contractMentionSuggestion,
}),
Iframe, Iframe,
TiptapTweet, TiptapTweet,
], ],
content, content,
editable: false, editable: false,
}) })
useEffect(() => void editor?.commands?.setContent(content), [editor, content]) useEffect(
// Check isDestroyed here so hot reload works, see https://github.com/ueberdosis/tiptap/issues/1451#issuecomment-941988769
() => void !editor?.isDestroyed && editor?.commands?.setContent(content),
[editor, content]
)
return <EditorContent className={className} editor={editor} /> return <EditorContent className={className} editor={editor} />
} }

View File

@ -0,0 +1,68 @@
import { SuggestionProps } from '@tiptap/suggestion'
import clsx from 'clsx'
import { Contract } from 'common/contract'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { contractPath } from 'web/lib/firebase/contracts'
import { Avatar } from '../avatar'
// copied from https://tiptap.dev/api/nodes/mention#usage
const M = forwardRef((props: SuggestionProps<Contract>, ref) => {
const { items: contracts, command } = props
const [selectedIndex, setSelectedIndex] = useState(0)
useEffect(() => setSelectedIndex(0), [contracts])
const submitUser = (index: number) => {
const contract = contracts[index]
if (contract)
command({ id: contract.id, label: contractPath(contract) } as any)
}
const onUp = () =>
setSelectedIndex((i) => (i + contracts.length - 1) % contracts.length)
const onDown = () => setSelectedIndex((i) => (i + 1) % contracts.length)
const onEnter = () => submitUser(selectedIndex)
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: any) => {
if (event.key === 'ArrowUp') {
onUp()
return true
}
if (event.key === 'ArrowDown') {
onDown()
return true
}
if (event.key === 'Enter') {
onEnter()
return true
}
return false
},
}))
return (
<div className="w-42 absolute z-10 overflow-x-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{!contracts.length ? (
<span className="m-1 whitespace-nowrap">No results...</span>
) : (
contracts.map((contract, i) => (
<button
className={clsx(
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4 hover:bg-indigo-200',
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
)}
onClick={() => submitUser(i)}
key={contract.id}
>
<Avatar avatarUrl={contract.creatorAvatarUrl} size="xs" />
{contract.question}
</button>
))
)}
</div>
)
})
// Just to keep the formatting pretty
export { M as MentionList }

View File

@ -0,0 +1,76 @@
import type { MentionOptions } from '@tiptap/extension-mention'
import { ReactRenderer } from '@tiptap/react'
import { searchInAny } from 'common/util/parse'
import { orderBy } from 'lodash'
import tippy from 'tippy.js'
import { getCachedContracts } from 'web/hooks/use-contracts'
import { MentionList } from './contract-mention-list'
import { PluginKey } from 'prosemirror-state'
type Suggestion = MentionOptions['suggestion']
const beginsWith = (text: string, query: string) =>
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
// copied from https://tiptap.dev/api/nodes/mention#usage
// TODO: merge with mention-suggestion.ts?
export const contractMentionSuggestion: Suggestion = {
char: '%',
allowSpaces: true,
pluginKey: new PluginKey('contract-mention'),
items: async ({ query }) =>
orderBy(
(await getCachedContracts()).filter((c) =>
searchInAny(query, c.question)
),
[(c) => [c.question].some((s) => beginsWith(s, query))],
['desc', 'desc']
).slice(0, 5),
render: () => {
let component: ReactRenderer
let popup: ReturnType<typeof tippy>
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
})
if (!props.clientRect) {
return
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect as any,
appendTo: () => document.body,
content: component?.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
onUpdate(props) {
component?.updateProps(props)
if (!props.clientRect) {
return
}
popup?.[0].setProps({
getReferenceClientRect: props.clientRect as any,
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup?.[0].hide()
return true
}
return (component?.ref as any)?.onKeyDown(props)
},
onExit() {
popup?.[0].destroy()
component?.destroy()
},
}
},
}

View File

@ -0,0 +1,41 @@
import Mention from '@tiptap/extension-mention'
import {
mergeAttributes,
NodeViewWrapper,
ReactNodeViewRenderer,
} from '@tiptap/react'
import clsx from 'clsx'
import { useContract } from 'web/hooks/use-contract'
import { ContractCard } from '../contract/contract-card'
const name = 'contract-mention-component'
const ContractMentionComponent = (props: any) => {
const contract = useContract(props.node.attrs.id)
return (
<NodeViewWrapper className={clsx(name, 'not-prose')}>
{contract && (
<ContractCard
contract={contract}
className="my-2 w-full border border-gray-100"
/>
)}
</NodeViewWrapper>
)
}
/**
* Mention extension that renders React. See:
* https://tiptap.dev/guide/custom-extensions#extend-existing-extensions
* https://tiptap.dev/guide/node-views/react#render-a-react-component
*/
export const DisplayContractMention = Mention.extend({
parseHTML: () => [{ tag: name }],
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
addNodeView: () =>
ReactNodeViewRenderer(ContractMentionComponent, {
// On desktop, render cards below half-width so you can stack two
className: 'inline-block sm:w-[calc(50%-1rem)] sm:mr-1',
}),
})

View File

@ -12,7 +12,7 @@ export default function WrappedTwitterTweetEmbed(props: {
const tweetId = props.node.attrs.tweetId.slice(1) const tweetId = props.node.attrs.tweetId.slice(1)
return ( return (
<NodeViewWrapper className="tiptap-tweet"> <NodeViewWrapper className="tiptap-tweet [&_.twitter-tweet]:mx-auto">
<TwitterTweetEmbed tweetId={tweetId} /> <TwitterTweetEmbed tweetId={tweetId} />
</NodeViewWrapper> </NodeViewWrapper>
) )

View File

@ -1,8 +1,10 @@
import { useState } from 'react'
import { Contract, FreeResponseContract } from 'common/contract' import { Contract, FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { getOutcomeProbability } from 'common/calculate' import { getOutcomeProbability } from 'common/calculate'
import { Pagination } from 'web/components/pagination'
import { FeedBet } from './feed-bets' import { FeedBet } from './feed-bets'
import { FeedLiquidity } from './feed-liquidity' import { FeedLiquidity } from './feed-liquidity'
import { FeedAnswerCommentGroup } from './feed-answer-comment-group' import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
@ -19,6 +21,10 @@ export function ContractBetsActivity(props: {
lps: LiquidityProvision[] lps: LiquidityProvision[]
}) { }) {
const { contract, bets, lps } = props const { contract, bets, lps } = props
const [page, setPage] = useState(0)
const ITEMS_PER_PAGE = 50
const start = page * ITEMS_PER_PAGE
const end = start + ITEMS_PER_PAGE
const items = [ const items = [
...bets.map((bet) => ({ ...bets.map((bet) => ({
@ -33,24 +39,35 @@ export function ContractBetsActivity(props: {
})), })),
] ]
const sortedItems = sortBy(items, (item) => const pageItems = sortBy(items, (item) =>
item.type === 'bet' item.type === 'bet'
? -item.bet.createdTime ? -item.bet.createdTime
: item.type === 'liquidity' : item.type === 'liquidity'
? -item.lp.createdTime ? -item.lp.createdTime
: undefined : undefined
) ).slice(start, end)
return ( return (
<Col className="gap-4"> <>
{sortedItems.map((item) => <Col className="mb-4 gap-4">
item.type === 'bet' ? ( {pageItems.map((item) =>
<FeedBet key={item.id} contract={contract} bet={item.bet} /> item.type === 'bet' ? (
) : ( <FeedBet key={item.id} contract={contract} bet={item.bet} />
<FeedLiquidity key={item.id} liquidity={item.lp} /> ) : (
) <FeedLiquidity key={item.id} liquidity={item.lp} />
)} )
</Col> )}
</Col>
<Pagination
page={page}
itemsPerPage={50}
totalItems={items.length}
setPage={setPage}
scrollToTop
nextTitle={'Older'}
prevTitle={'Newer'}
/>
</>
) )
} }

View File

@ -1,8 +1,7 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { User } from 'common/user' import { useUser } from 'web/hooks/use-user'
import { useUser, useUserById } from 'web/hooks/use-user'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Avatar, EmptyAvatar } from 'web/components/avatar'
import clsx from 'clsx' import clsx from 'clsx'
@ -15,32 +14,24 @@ import { SiteLink } from 'web/components/site-link'
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
import { Challenge } from 'common/challenge' import { Challenge } from 'common/challenge'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { BETTOR } from 'common/user'
export function FeedBet(props: { contract: Contract; bet: Bet }) { export function FeedBet(props: { contract: Contract; bet: Bet }) {
const { contract, bet } = props const { contract, bet } = props
const { userId, createdTime } = bet const { userAvatarUrl, userUsername, createdTime } = bet
const showUser = dayjs(createdTime).isAfter('2022-06-01')
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
return ( return (
<Row className="items-center gap-2 pt-3"> <Row className="items-center gap-2 pt-3">
{isSelf ? ( {showUser ? (
<Avatar avatarUrl={user.avatarUrl} username={user.username} /> <Avatar avatarUrl={userAvatarUrl} username={userUsername} />
) : bettor ? (
<Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} />
) : ( ) : (
<EmptyAvatar className="mx-1" /> <EmptyAvatar className="mx-1" />
)} )}
<BetStatusText <BetStatusText
bet={bet} bet={bet}
contract={contract} contract={contract}
isSelf={isSelf} hideUser={!showUser}
bettor={bettor}
className="flex-1" className="flex-1"
/> />
</Row> </Row>
@ -50,13 +41,13 @@ export function FeedBet(props: { contract: Contract; bet: Bet }) {
export function BetStatusText(props: { export function BetStatusText(props: {
contract: Contract contract: Contract
bet: Bet bet: Bet
isSelf: boolean hideUser?: boolean
bettor?: User
hideOutcome?: boolean hideOutcome?: boolean
className?: string className?: string
}) { }) {
const { bet, contract, bettor, isSelf, hideOutcome, className } = props const { bet, contract, hideUser, hideOutcome, className } = props
const { outcomeType } = contract const { outcomeType } = contract
const self = useUser()
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isFreeResponse = outcomeType === 'FREE_RESPONSE' const isFreeResponse = outcomeType === 'FREE_RESPONSE'
const { amount, outcome, createdTime, challengeSlug } = bet const { amount, outcome, createdTime, challengeSlug } = bet
@ -101,10 +92,10 @@ export function BetStatusText(props: {
return ( return (
<div className={clsx('text-sm text-gray-500', className)}> <div className={clsx('text-sm text-gray-500', className)}>
{bettor ? ( {!hideUser ? (
<UserLink name={bettor.name} username={bettor.username} /> <UserLink name={bet.userName} username={bet.userUsername} />
) : ( ) : (
<span>{isSelf ? 'You' : 'A trader'}</span> <span>{self?.id === bet.userId ? 'You' : `A ${BETTOR}`}</span>
)}{' '} )}{' '}
{bought} {money} {bought} {money}
{outOfTotalAmount} {outOfTotalAmount}

View File

@ -1,6 +1,6 @@
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { User } from 'common/user' import { PRESENT_BET, User } from 'common/user'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash' import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash'
@ -255,7 +255,7 @@ function CommentStatus(props: {
const { contract, outcome, prob } = props const { contract, outcome, prob } = props
return ( return (
<> <>
{' betting '} {` ${PRESENT_BET}ing `}
<OutcomeLabel outcome={outcome} contract={contract} truncate="short" /> <OutcomeLabel outcome={outcome} contract={contract} truncate="short" />
{prob && ' at ' + Math.round(prob * 100) + '%'} {prob && ' at ' + Math.round(prob * 100) + '%'}
</> </>

View File

@ -1,6 +1,6 @@
import clsx from 'clsx' import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { User } from 'common/user' import { BETTOR, User } from 'common/user'
import { useUser, useUserById } from 'web/hooks/use-user' import { useUser, useUserById } from 'web/hooks/use-user'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Avatar, EmptyAvatar } from 'web/components/avatar'
@ -25,7 +25,7 @@ export function FeedLiquidity(props: {
const isSelf = user?.id === userId const isSelf = user?.id === userId
return ( return (
<Row className="flex w-full gap-2 pt-3"> <Row className="items-center gap-2 pt-3">
{isSelf ? ( {isSelf ? (
<Avatar avatarUrl={user.avatarUrl} username={user.username} /> <Avatar avatarUrl={user.avatarUrl} username={user.username} />
) : bettor ? ( ) : bettor ? (
@ -63,7 +63,7 @@ export function LiquidityStatusText(props: {
{bettor ? ( {bettor ? (
<UserLink name={bettor.name} username={bettor.username} /> <UserLink name={bettor.name} username={bettor.username} />
) : ( ) : (
<span>{isSelf ? 'You' : 'A trader'}</span> <span>{isSelf ? 'You' : `A ${BETTOR}`}</span>
)}{' '} )}{' '}
{bought} a subsidy of {money} {bought} a subsidy of {money}
<RelativeTimestamp time={createdTime} /> <RelativeTimestamp time={createdTime} />

View File

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

View File

@ -13,6 +13,7 @@ import { NoLabel, YesLabel } from './outcome-label'
import { Col } from './layout/col' import { Col } from './layout/col'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { InfoTooltip } from './info-tooltip' import { InfoTooltip } from './info-tooltip'
import { BETTORS, PRESENT_BET } from 'common/user'
export function LiquidityPanel(props: { contract: CPMMContract }) { export function LiquidityPanel(props: { contract: CPMMContract }) {
const { contract } = props const { contract } = props
@ -104,7 +105,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
<> <>
<div className="mb-4 text-gray-500"> <div className="mb-4 text-gray-500">
Contribute your M$ to make this market more accurate.{' '} Contribute your M$ to make this market more accurate.{' '}
<InfoTooltip text="More liquidity stabilizes the market, encouraging traders to bet. You can withdraw your subsidy at any time." /> <InfoTooltip
text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}. You can withdraw your subsidy at any time.`}
/>
</div> </div>
<Row> <Row>

View File

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

View File

@ -17,6 +17,7 @@ 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 { PAST_BETS } from 'common/user'
function getNavigation() { function getNavigation() {
return [ return [
@ -64,7 +65,7 @@ export function BottomNavBar() {
item={{ item={{
name: formatMoney(user.balance), name: formatMoney(user.balance),
trackingEventName: 'profile', trackingEventName: 'profile',
href: `/${user.username}?tab=trades`, href: `/${user.username}?tab=${PAST_BETS}`,
icon: () => ( icon: () => (
<Avatar <Avatar
className="mx-auto my-1" className="mx-auto my-1"

View File

@ -4,11 +4,12 @@ import { User } from 'web/lib/firebase/users'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { PAST_BETS } from 'common/user'
export function ProfileSummary(props: { user: User }) { export function ProfileSummary(props: { user: User }) {
const { user } = props const { user } = props
return ( return (
<Link href={`/${user.username}?tab=trades`}> <Link href={`/${user.username}?tab=${PAST_BETS}`}>
<a <a
onClick={trackCallback('sidebar: profile')} onClick={trackCallback('sidebar: profile')}
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"

View File

@ -1,11 +1,7 @@
import React, { memo, ReactNode, useEffect, useState } from 'react' import React, { memo, ReactNode, useEffect, useState } from 'react'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import clsx from 'clsx' import clsx from 'clsx'
import { import { PrivateUser } from 'common/user'
notification_subscription_types,
notification_destination_types,
PrivateUser,
} from 'common/user'
import { updatePrivateUser } from 'web/lib/firebase/users' import { updatePrivateUser } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { import {
@ -30,6 +26,11 @@ import {
usePersistentState, usePersistentState,
} from 'web/hooks/use-persistent-state' } from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local' import { safeLocalStorage } from 'web/lib/util/local'
import { NOTIFICATION_DESCRIPTIONS } from 'common/notification'
import {
notification_destination_types,
notification_preference,
} from 'common/user-notification-preferences'
export function NotificationSettings(props: { export function NotificationSettings(props: {
navigateToSection: string | undefined navigateToSection: string | undefined
@ -38,7 +39,7 @@ export function NotificationSettings(props: {
const { navigateToSection, privateUser } = props const { navigateToSection, privateUser } = props
const [showWatchModal, setShowWatchModal] = useState(false) const [showWatchModal, setShowWatchModal] = useState(false)
const emailsEnabled: Array<keyof notification_subscription_types> = [ const emailsEnabled: Array<notification_preference> = [
'all_comments_on_watched_markets', 'all_comments_on_watched_markets',
'all_replies_to_my_comments_on_watched_markets', 'all_replies_to_my_comments_on_watched_markets',
'all_comments_on_contracts_with_shares_in_on_watched_markets', 'all_comments_on_contracts_with_shares_in_on_watched_markets',
@ -74,7 +75,7 @@ export function NotificationSettings(props: {
// 'probability_updates_on_watched_markets', // 'probability_updates_on_watched_markets',
// 'limit_order_fills', // 'limit_order_fills',
] ]
const browserDisabled: Array<keyof notification_subscription_types> = [ const browserDisabled: Array<notification_preference> = [
'trending_markets', 'trending_markets',
'profit_loss_updates', 'profit_loss_updates',
'onboarding_flow', 'onboarding_flow',
@ -83,91 +84,82 @@ export function NotificationSettings(props: {
type SectionData = { type SectionData = {
label: string label: string
subscriptionTypeToDescription: { subscriptionTypes: Partial<notification_preference>[]
[key in keyof Partial<notification_subscription_types>]: string
}
} }
const comments: SectionData = { const comments: SectionData = {
label: 'New Comments', label: 'New Comments',
subscriptionTypeToDescription: { subscriptionTypes: [
all_comments_on_watched_markets: 'All new comments', 'all_comments_on_watched_markets',
all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, 'all_comments_on_contracts_with_shares_in_on_watched_markets',
// TODO: combine these two // TODO: combine these two
all_replies_to_my_comments_on_watched_markets: 'all_replies_to_my_comments_on_watched_markets',
'Only replies to your comments', 'all_replies_to_my_answers_on_watched_markets',
all_replies_to_my_answers_on_watched_markets: ],
'Only replies to your answers',
// comments_by_followed_users_on_watched_markets: 'By followed users',
},
} }
const answers: SectionData = { const answers: SectionData = {
label: 'New Answers', label: 'New Answers',
subscriptionTypeToDescription: { subscriptionTypes: [
all_answers_on_watched_markets: 'All new answers', 'all_answers_on_watched_markets',
all_answers_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, 'all_answers_on_contracts_with_shares_in_on_watched_markets',
// answers_by_followed_users_on_watched_markets: 'By followed users', ],
// answers_by_market_creator_on_watched_markets: 'By market creator',
},
} }
const updates: SectionData = { const updates: SectionData = {
label: 'Updates & Resolutions', label: 'Updates & Resolutions',
subscriptionTypeToDescription: { subscriptionTypes: [
market_updates_on_watched_markets: 'All creator updates', 'market_updates_on_watched_markets',
market_updates_on_watched_markets_with_shares_in: `Only creator updates on markets you're invested in`, 'market_updates_on_watched_markets_with_shares_in',
resolutions_on_watched_markets: 'All market resolutions', 'resolutions_on_watched_markets',
resolutions_on_watched_markets_with_shares_in: `Only market resolutions you're invested in`, 'resolutions_on_watched_markets_with_shares_in',
// probability_updates_on_watched_markets: 'Probability updates', ],
},
} }
const yourMarkets: SectionData = { const yourMarkets: SectionData = {
label: 'Markets You Created', label: 'Markets You Created',
subscriptionTypeToDescription: { subscriptionTypes: [
your_contract_closed: 'Your market has closed (and needs resolution)', 'your_contract_closed',
all_comments_on_my_markets: 'Comments on your markets', 'all_comments_on_my_markets',
all_answers_on_my_markets: 'Answers on your markets', 'all_answers_on_my_markets',
subsidized_your_market: 'Your market was subsidized', 'subsidized_your_market',
tips_on_your_markets: 'Likes on your markets', 'tips_on_your_markets',
}, ],
} }
const bonuses: SectionData = { const bonuses: SectionData = {
label: 'Bonuses', label: 'Bonuses',
subscriptionTypeToDescription: { subscriptionTypes: [
betting_streaks: 'Prediction streak bonuses', 'betting_streaks',
referral_bonuses: 'Referral bonuses from referring users', 'referral_bonuses',
unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets', 'unique_bettors_on_your_contract',
}, ],
} }
const otherBalances: SectionData = { const otherBalances: SectionData = {
label: 'Other', label: 'Other',
subscriptionTypeToDescription: { subscriptionTypes: [
loan_income: 'Automatic loans from your profitable bets', 'loan_income',
limit_order_fills: 'Limit order fills', 'limit_order_fills',
tips_on_your_comments: 'Tips on your comments', 'tips_on_your_comments',
}, ],
} }
const userInteractions: SectionData = { const userInteractions: SectionData = {
label: 'Users', label: 'Users',
subscriptionTypeToDescription: { subscriptionTypes: [
tagged_user: 'A user tagged you', 'tagged_user',
on_new_follow: 'Someone followed you', 'on_new_follow',
contract_from_followed_user: 'New markets created by users you follow', 'contract_from_followed_user',
}, ],
} }
const generalOther: SectionData = { const generalOther: SectionData = {
label: 'Other', label: 'Other',
subscriptionTypeToDescription: { subscriptionTypes: [
trending_markets: 'Weekly interesting markets', 'trending_markets',
thank_you_for_purchases: 'Thank you notes for your purchases', 'thank_you_for_purchases',
onboarding_flow: 'Explanatory emails to help you get started', 'onboarding_flow',
// profit_loss_updates: 'Weekly profit/loss updates', ],
},
} }
function NotificationSettingLine(props: { function NotificationSettingLine(props: {
description: string description: string
subscriptionTypeKey: keyof notification_subscription_types subscriptionTypeKey: notification_preference
destinations: notification_destination_types[] destinations: notification_destination_types[]
}) { }) {
const { description, subscriptionTypeKey, destinations } = props const { description, subscriptionTypeKey, destinations } = props
@ -183,8 +175,8 @@ export function NotificationSettings(props: {
toast toast
.promise( .promise(
updatePrivateUser(privateUser.id, { updatePrivateUser(privateUser.id, {
notificationSubscriptionTypes: { notificationPreferences: {
...privateUser.notificationSubscriptionTypes, ...privateUser.notificationPreferences,
[subscriptionTypeKey]: destinations.includes(setting) [subscriptionTypeKey]: destinations.includes(setting)
? destinations.filter((d) => d !== setting) ? destinations.filter((d) => d !== setting)
: uniq([...destinations, setting]), : uniq([...destinations, setting]),
@ -237,10 +229,8 @@ export function NotificationSettings(props: {
) )
} }
const getUsersSavedPreference = ( const getUsersSavedPreference = (key: notification_preference) => {
key: keyof notification_subscription_types return privateUser.notificationPreferences[key] ?? []
) => {
return privateUser.notificationSubscriptionTypes[key] ?? []
} }
const Section = memo(function Section(props: { const Section = memo(function Section(props: {
@ -248,17 +238,15 @@ export function NotificationSettings(props: {
data: SectionData data: SectionData
}) { }) {
const { icon, data } = props const { icon, data } = props
const { label, subscriptionTypeToDescription } = data const { label, subscriptionTypes } = data
const expand = const expand =
navigateToSection && navigateToSection &&
Object.keys(subscriptionTypeToDescription).includes(navigateToSection) subscriptionTypes.includes(navigateToSection as notification_preference)
// Not sure how to prevent re-render (and collapse of an open section) // 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 // due to a private user settings change. Just going to persist expanded state here
const [expanded, setExpanded] = usePersistentState(expand ?? false, { const [expanded, setExpanded] = usePersistentState(expand ?? false, {
key: key: 'NotificationsSettingsSection-' + subscriptionTypes.join('-'),
'NotificationsSettingsSection-' +
Object.keys(subscriptionTypeToDescription).join('-'),
store: storageStore(safeLocalStorage()), store: storageStore(safeLocalStorage()),
}) })
@ -287,13 +275,13 @@ export function NotificationSettings(props: {
)} )}
</Row> </Row>
<Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}> <Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}>
{Object.entries(subscriptionTypeToDescription).map(([key, value]) => ( {subscriptionTypes.map((subType) => (
<NotificationSettingLine <NotificationSettingLine
subscriptionTypeKey={key as keyof notification_subscription_types} subscriptionTypeKey={subType as notification_preference}
destinations={getUsersSavedPreference( destinations={getUsersSavedPreference(
key as keyof notification_subscription_types subType as notification_preference
)} )}
description={value} description={NOTIFICATION_DESCRIPTIONS[subType].simple}
/> />
))} ))}
</Col> </Col>

View File

@ -10,13 +10,16 @@ import { NumericContract, PseudoNumericContract } from 'common/contract'
import { APIError, resolveMarket } from 'web/lib/firebase/api' import { APIError, resolveMarket } from 'web/lib/firebase/api'
import { BucketInput } from './bucket-input' import { BucketInput } from './bucket-input'
import { getPseudoProbability } from 'common/pseudo-numeric' import { getPseudoProbability } from 'common/pseudo-numeric'
import { BETTOR, BETTORS, PAST_BETS } from 'common/user'
export function NumericResolutionPanel(props: { export function NumericResolutionPanel(props: {
isAdmin: boolean
isCreator: boolean
creator: User creator: User
contract: NumericContract | PseudoNumericContract contract: NumericContract | PseudoNumericContract
className?: string className?: string
}) { }) {
const { contract, className } = props const { contract, className, isAdmin, isCreator } = props
const { min, max, outcomeType } = contract const { min, max, outcomeType } = contract
const [outcomeMode, setOutcomeMode] = useState< const [outcomeMode, setOutcomeMode] = useState<
@ -78,10 +81,20 @@ export function NumericResolutionPanel(props: {
: 'btn-disabled' : 'btn-disabled'
return ( return (
<Col className={clsx('rounded-md bg-white px-8 py-6', className)}> <Col
<div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div> className={clsx(
'relative w-full rounded-md bg-white px-8 py-6',
className
)}
>
{isAdmin && !isCreator && (
<span className="absolute right-4 top-4 rounded bg-red-200 p-1 text-xs text-red-600">
ADMIN
</span>
)}
<div className="whitespace-nowrap text-2xl">Resolve market</div>
<div className="mb-3 text-sm text-gray-500">Outcome</div> <div className="my-3 text-sm text-gray-500">Outcome</div>
<Spacer h={4} /> <Spacer h={4} />
@ -99,9 +112,12 @@ export function NumericResolutionPanel(props: {
<div> <div>
{outcome === 'CANCEL' ? ( {outcome === 'CANCEL' ? (
<>All trades will be returned with no fees.</> <>
All {PAST_BETS} will be returned. Unique {BETTOR} bonuses will be
withdrawn from your account
</>
) : ( ) : (
<>Resolving this market will immediately pay out traders.</> <>Resolving this market will immediately pay out {BETTORS}.</>
)} )}
</div> </div>

View File

@ -3,19 +3,44 @@ 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,
BETTING_STREAK_RESET_HOUR,
} from 'common/economy' } from 'common/economy'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { User } from 'common/user'
import dayjs from 'dayjs'
import clsx from 'clsx'
export function BettingStreakModal(props: { export function BettingStreakModal(props: {
isOpen: boolean isOpen: boolean
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
currentUser?: User | null
}) { }) {
const { isOpen, setOpen } = props const { isOpen, setOpen, currentUser } = props
const missingStreak = currentUser && !hasCompletedStreakToday(currentUser)
return ( return (
<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={clsx(
'text-8xl',
missingStreak ? 'grayscale' : 'grayscale-0'
)}
>
🔥
</span>
{missingStreak && (
<Col className={' gap-2 text-center'}>
<span className={'font-bold'}>
You haven't predicted yet today!
</span>
<span className={'ml-2'}>
If the fire icon is gray, this means you haven't predicted yet
today to get your streak bonus. Get out there and make a
prediction!
</span>
</Col>
)}
<span className="text-xl">Daily prediction streaks</span> <span className="text-xl">Daily prediction 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>
@ -37,3 +62,17 @@ export function BettingStreakModal(props: {
</Modal> </Modal>
) )
} }
export function hasCompletedStreakToday(user: User) {
const now = dayjs().utc()
const utcTodayAtResetHour = now
.hour(BETTING_STREAK_RESET_HOUR)
.minute(0)
.second(0)
const utcYesterdayAtResetHour = utcTodayAtResetHour.subtract(1, 'day')
let resetTime = utcTodayAtResetHour.valueOf()
if (now.isBefore(utcTodayAtResetHour)) {
resetTime = utcYesterdayAtResetHour.valueOf()
}
return (user?.lastBetTime ?? 0) > resetTime
}

View File

@ -1,5 +1,6 @@
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { PAST_BETS } from 'common/user'
export function LoansModal(props: { export function LoansModal(props: {
isOpen: boolean isOpen: boolean
@ -11,7 +12,7 @@ export function LoansModal(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 className="text-xl">Daily loans on your trades</span> <span className="text-xl">Daily loans on your {PAST_BETS}</span>
<Col className={'gap-2'}> <Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are daily loans?</span> <span className={'text-indigo-700'}> What are daily loans?</span>
<span className={'ml-2'}> <span className={'ml-2'}>

View File

@ -0,0 +1,133 @@
import clsx from 'clsx'
import { MouseEventHandler, ReactNode, useState } from 'react'
import toast from 'react-hot-toast'
import { LinkIcon } from '@heroicons/react/solid'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { updatePrivateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
import { copyToClipboard } from 'web/lib/util/copy'
import { Button, ColorType } from './../button'
import { Row } from './../layout/row'
import { LoadingIndicator } from './../loading-indicator'
function BouncyButton(props: {
children: ReactNode
onClick?: MouseEventHandler<any>
color?: ColorType
}) {
const { children, onClick, color } = props
return (
<Button
color={color}
size="lg"
onClick={onClick}
className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case"
>
{children}
</Button>
)
}
export function TwitchPanel() {
const user = useUser()
const privateUser = usePrivateUser()
const twitchInfo = privateUser?.twitchInfo
const twitchName = privateUser?.twitchInfo?.twitchName
const twitchToken = privateUser?.twitchInfo?.controlToken
const twitchBotConnected = privateUser?.twitchInfo?.botEnabled
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
const copyOverlayLink = async () => {
copyToClipboard(`http://localhost:1000/overlay?t=${twitchToken}`)
toast.success('Overlay link copied!', {
icon: linkIcon,
})
}
const copyDockLink = async () => {
copyToClipboard(`http://localhost:1000/dock?t=${twitchToken}`)
toast.success('Dock link copied!', {
icon: linkIcon,
})
}
const updateBotConnected = (connected: boolean) => async () => {
if (user && twitchInfo) {
twitchInfo.botEnabled = connected
await updatePrivateUser(user.id, { twitchInfo })
}
}
const [twitchLoading, setTwitchLoading] = useState(false)
const createLink = async () => {
if (!user || !privateUser) return
setTwitchLoading(true)
const promise = linkTwitchAccountRedirect(user, privateUser)
track('link twitch from profile')
await promise
setTwitchLoading(false)
}
return (
<>
<div>
<label className="label">Twitch</label>
{!twitchName ? (
<Row>
<Button
color="indigo"
onClick={createLink}
disabled={twitchLoading}
>
Link your Twitch account
</Button>
{twitchLoading && <LoadingIndicator className="ml-4" />}
</Row>
) : (
<Row>
<span className="mr-4 text-gray-500">Linked Twitch account</span>{' '}
{twitchName}
</Row>
)}
</div>
{twitchToken && (
<div>
<div className="flex w-full">
<div
className={clsx(
'flex grow gap-4',
twitchToken ? '' : 'tooltip tooltip-top'
)}
data-tip="You must link your Twitch account first"
>
<BouncyButton color="blue" onClick={copyOverlayLink}>
Copy overlay link
</BouncyButton>
<BouncyButton color="indigo" onClick={copyDockLink}>
Copy dock link
</BouncyButton>
{twitchBotConnected ? (
<BouncyButton color="red" onClick={updateBotConnected(false)}>
Remove bot from your channel
</BouncyButton>
) : (
<BouncyButton color="green" onClick={updateBotConnected(true)}>
Add bot to your channel
</BouncyButton>
)}
</div>
</div>
</div>
)}
</>
)
}

View File

@ -10,13 +10,16 @@ import { APIError, resolveMarket } from 'web/lib/firebase/api'
import { ProbabilitySelector } from './probability-selector' import { ProbabilitySelector } from './probability-selector'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { BinaryContract, resolution } from 'common/contract' import { BinaryContract, resolution } from 'common/contract'
import { BETTOR, BETTORS, PAST_BETS } from 'common/user'
export function ResolutionPanel(props: { export function ResolutionPanel(props: {
isAdmin: boolean
isCreator: boolean
creator: User creator: User
contract: BinaryContract contract: BinaryContract
className?: string className?: string
}) { }) {
const { contract, className } = props const { contract, className, isAdmin, isCreator } = props
// const earnedFees = // const earnedFees =
// contract.mechanism === 'dpm-2' // contract.mechanism === 'dpm-2'
@ -66,7 +69,12 @@ export function ResolutionPanel(props: {
: 'btn-disabled' : 'btn-disabled'
return ( return (
<Col className={clsx('rounded-md bg-white px-8 py-6', className)}> <Col className={clsx('relative rounded-md bg-white px-8 py-6', className)}>
{isAdmin && !isCreator && (
<span className="absolute right-4 top-4 rounded bg-red-200 p-1 text-xs text-red-600">
ADMIN
</span>
)}
<div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div> <div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div>
<div className="mb-3 text-sm text-gray-500">Outcome</div> <div className="mb-3 text-sm text-gray-500">Outcome</div>
@ -83,23 +91,28 @@ export function ResolutionPanel(props: {
<div> <div>
{outcome === 'YES' ? ( {outcome === 'YES' ? (
<> <>
Winnings will be paid out to traders who bought YES. Winnings will be paid out to {BETTORS} who bought YES.
{/* <br /> {/* <br />
<br /> <br />
You will earn {earnedFees}. */} You will earn {earnedFees}. */}
</> </>
) : outcome === 'NO' ? ( ) : outcome === 'NO' ? (
<> <>
Winnings will be paid out to traders who bought NO. Winnings will be paid out to {BETTORS} who bought NO.
{/* <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 {PAST_BETS} will be returned. Unique {BETTOR} bonuses will be
withdrawn from your account
</>
) : outcome === 'MKT' ? ( ) : outcome === 'MKT' ? (
<Col className="gap-6"> <Col className="gap-6">
<div>Traders will be paid out at the probability you specify:</div> <div>
{PAST_BETS} will be paid out at the probability you specify:
</div>
<ProbabilitySelector <ProbabilitySelector
probabilityInt={Math.round(prob)} probabilityInt={Math.round(prob)}
setProbabilityInt={setProb} setProbabilityInt={setProb}
@ -107,7 +120,7 @@ export function ResolutionPanel(props: {
{/* 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 {BETTORS}.</>
)} )}
</div> </div>

View File

@ -20,13 +20,18 @@ export function UserLink(props: {
username: string username: string
className?: string className?: string
short?: boolean short?: boolean
noLink?: boolean
}) { }) {
const { name, username, className, short } = props const { name, username, className, short, noLink } = props
const shortName = short ? shortenName(name) : name const shortName = short ? shortenName(name) : name
return ( return (
<SiteLink <SiteLink
href={`/${username}`} href={`/${username}`}
className={clsx('z-10 truncate', className)} className={clsx(
'z-10 truncate',
className,
noLink ? 'pointer-events-none' : ''
)}
> >
{shortName} {shortName}
</SiteLink> </SiteLink>

View File

@ -28,10 +28,15 @@ import { ReferralsButton } from 'web/components/referrals-button'
import { formatMoney } from 'common/util/format' 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,
hasCompletedStreakToday,
} from 'web/components/profile/betting-streak-modal'
import { REFERRAL_AMOUNT } from 'common/economy' import { REFERRAL_AMOUNT } from 'common/economy'
import { LoansModal } from './profile/loans-modal' import { LoansModal } from './profile/loans-modal'
import { UserLikesButton } from 'web/components/profile/user-likes-button' import { UserLikesButton } from 'web/components/profile/user-likes-button'
import { PAST_BETS } from 'common/user'
import { capitalize } from 'lodash'
export function UserPage(props: { user: User }) { export function UserPage(props: { user: User }) {
const { user } = props const { user } = props
@ -83,6 +88,7 @@ export function UserPage(props: { user: User }) {
<BettingStreakModal <BettingStreakModal
isOpen={showBettingStreakModal} isOpen={showBettingStreakModal}
setOpen={setShowBettingStreakModal} setOpen={setShowBettingStreakModal}
currentUser={currentUser}
/> />
{showLoansModal && ( {showLoansModal && (
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} /> <LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
@ -139,7 +145,12 @@ export function UserPage(props: { user: User }) {
<span>profit</span> <span>profit</span>
</Col> </Col>
<Col <Col
className={'cursor-pointer items-center text-gray-500'} className={clsx(
'cursor-pointer items-center text-gray-500',
isCurrentUser && !hasCompletedStreakToday(user)
? 'grayscale'
: 'grayscale-0'
)}
onClick={() => setShowBettingStreakModal(true)} onClick={() => setShowBettingStreakModal(true)}
> >
<span>🔥 {user.currentBettingStreak ?? 0}</span> <span>🔥 {user.currentBettingStreak ?? 0}</span>
@ -231,7 +242,8 @@ export function UserPage(props: { user: User }) {
<SiteLink href="/referrals"> <SiteLink href="/referrals">
Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend! Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend!
</SiteLink>{' '} </SiteLink>{' '}
You have <ReferralsButton user={user} currentUser={currentUser} /> You've gotten{' '}
<ReferralsButton user={user} currentUser={currentUser} />
</span> </span>
<ShareIconButton <ShareIconButton
copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`} copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`}
@ -260,7 +272,7 @@ export function UserPage(props: { user: User }) {
), ),
}, },
{ {
title: 'Trades', title: capitalize(PAST_BETS),
content: ( content: (
<> <>
<BetsList user={user} /> <BetsList user={user} />

View File

@ -9,8 +9,9 @@ import {
listenForNewContracts, listenForNewContracts,
getUserBetContracts, getUserBetContracts,
getUserBetContractsQuery, getUserBetContractsQuery,
listAllContracts,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { useQueryClient } from 'react-query' import { QueryClient, useQueryClient } from 'react-query'
import { MINUTE_MS } from 'common/util/time' import { MINUTE_MS } from 'common/util/time'
export const useContracts = () => { export const useContracts = () => {
@ -23,6 +24,12 @@ export const useContracts = () => {
return contracts return contracts
} }
const q = new QueryClient()
export const getCachedContracts = async () =>
q.fetchQuery(['contracts'], () => listAllContracts(1000), {
staleTime: Infinity,
})
export const useActiveContracts = () => { export const useActiveContracts = () => {
const [activeContracts, setActiveContracts] = useState< const [activeContracts, setActiveContracts] = useState<
Contract[] | undefined Contract[] | undefined

9
web/lib/api/api-key.ts Normal file
View File

@ -0,0 +1,9 @@
import { updatePrivateUser } from '../firebase/users'
export const generateNewApiKey = async (userId: string) => {
const newApiKey = crypto.randomUUID()
return await updatePrivateUser(userId, { apiKey: newApiKey })
.then(() => newApiKey)
.catch(() => undefined)
}

View File

@ -0,0 +1,41 @@
import { PrivateUser, User } from 'common/user'
import { generateNewApiKey } from '../api/api-key'
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately
export async function initLinkTwitchAccount(
manifoldUserID: string,
manifoldUserAPIKey: string
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
manifoldID: manifoldUserID,
apiKey: manifoldUserAPIKey,
redirectURL: window.location.href,
}),
})
const responseData = await response.json()
if (!response.ok) {
throw new Error(responseData.message)
}
const responseFetch = fetch(
`${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}`
)
return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())]
}
export async function linkTwitchAccountRedirect(
user: User,
privateUser: PrivateUser
) {
const apiKey = privateUser.apiKey ?? (await generateNewApiKey(user.id))
if (!apiKey) throw new Error("Couldn't retrieve or create Manifold api key")
const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey)
window.location.href = twitchAuthURL
}

View File

@ -48,6 +48,7 @@
"nanoid": "^3.3.4", "nanoid": "^3.3.4",
"next": "12.2.5", "next": "12.2.5",
"node-fetch": "3.2.4", "node-fetch": "3.2.4",
"prosemirror-state": "1.4.1",
"react": "17.0.2", "react": "17.0.2",
"react-beautiful-dnd": "13.1.1", "react-beautiful-dnd": "13.1.1",
"react-confetti": "6.0.1", "react-confetti": "6.0.1",

View File

@ -45,6 +45,8 @@ import {
import { ContractsGrid } from 'web/components/contract/contracts-grid' import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { usePrefetch } from 'web/hooks/use-prefetch' import { usePrefetch } from 'web/hooks/use-prefetch'
import { useAdmin } from 'web/hooks/use-admin'
import dayjs from 'dayjs'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
@ -110,19 +112,28 @@ export default function ContractPage(props: {
) )
} }
// requires an admin to resolve a week after market closes
export function needsAdminToResolve(contract: Contract) {
return !contract.isResolved && dayjs().diff(contract.closeTime, 'day') > 7
}
export function ContractPageSidebar(props: { export function ContractPageSidebar(props: {
user: User | null | undefined user: User | null | undefined
contract: Contract contract: Contract
}) { }) {
const { contract, user } = props const { contract, user } = props
const { creatorId, isResolved, outcomeType } = contract const { creatorId, isResolved, outcomeType } = contract
const isCreator = user?.id === creatorId const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isNumeric = outcomeType === 'NUMERIC' const isNumeric = outcomeType === 'NUMERIC'
const allowTrade = tradingAllowed(contract) const allowTrade = tradingAllowed(contract)
const allowResolve = !isResolved && isCreator && !!user const isAdmin = useAdmin()
const allowResolve =
!isResolved &&
(isCreator || (needsAdminToResolve(contract) && isAdmin)) &&
!!user
const hasSidePanel = const hasSidePanel =
(isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve) (isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve)
@ -139,9 +150,19 @@ export function ContractPageSidebar(props: {
))} ))}
{allowResolve && {allowResolve &&
(isNumeric || isPseudoNumeric ? ( (isNumeric || isPseudoNumeric ? (
<NumericResolutionPanel creator={user} contract={contract} /> <NumericResolutionPanel
isAdmin={isAdmin}
creator={user}
isCreator={isCreator}
contract={contract}
/>
) : ( ) : (
<ResolutionPanel creator={user} contract={contract} /> <ResolutionPanel
isAdmin={isAdmin}
creator={user}
isCreator={isCreator}
contract={contract}
/>
))} ))}
</Col> </Col>
) : null ) : null
@ -154,10 +175,8 @@ export function ContractPageContent(
} }
) { ) {
const { backToHome, comments, user } = props const { backToHome, comments, user } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
usePrefetch(user?.id) usePrefetch(user?.id)
useTracking( useTracking(
'view market', 'view market',
{ {

View File

@ -0,0 +1,23 @@
import { NextApiRequest, NextApiResponse } from 'next'
import {
CORS_ORIGIN_MANIFOLD,
CORS_ORIGIN_LOCALHOST,
} from 'common/envs/constants'
import { applyCorsHeaders } from 'web/lib/api/cors'
import { fetchBackend, forwardResponse } from 'web/lib/api/proxy'
export const config = { api: { bodyParser: true } }
export default async function route(req: NextApiRequest, res: NextApiResponse) {
await applyCorsHeaders(req, res, {
origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
methods: 'POST',
})
try {
const backendRes = await fetchBackend(req, 'savetwitchcredentials')
await forwardResponse(res, backendRes)
} catch (err) {
console.error('Error talking to cloud function: ', err)
res.status(500).json({ message: 'Error communicating with backend.' })
}
}

View File

@ -8,6 +8,7 @@ import {
usePersistentState, usePersistentState,
urlParamStore, urlParamStore,
} from 'web/hooks/use-persistent-state' } from 'web/hooks/use-persistent-state'
import { PAST_BETS } from 'common/user'
const MAX_CONTRACTS_RENDERED = 100 const MAX_CONTRACTS_RENDERED = 100
@ -101,7 +102,7 @@ export default function ContractSearchFirestore(props: {
> >
<option value="score">Trending</option> <option value="score">Trending</option>
<option value="newest">Newest</option> <option value="newest">Newest</option>
<option value="most-traded">Most traded</option> <option value="most-traded">Most ${PAST_BETS}</option>
<option value="24-hour-vol">24h volume</option> <option value="24-hour-vol">24h volume</option>
<option value="close-date">Closing soon</option> <option value="close-date">Closing soon</option>
</select> </select>

View File

@ -116,7 +116,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
tradingAllowed(contract) && tradingAllowed(contract) &&
!betPanelOpen && ( !betPanelOpen && (
<Button color="gradient" onClick={() => setBetPanelOpen(true)}> <Button color="gradient" onClick={() => setBetPanelOpen(true)}>
Bet Predict
</Button> </Button>
)} )}

View File

@ -50,6 +50,7 @@ import { usePost } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { SelectMarketsModal } from 'web/components/contract-select-modal' import { SelectMarketsModal } from 'web/components/contract-select-modal'
import { BETTORS } from 'common/user'
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[] } }) {
@ -155,7 +156,7 @@ export default function GroupPage(props: {
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
<GroupLeaderboard <GroupLeaderboard
topUsers={topTraders} topUsers={topTraders}
title="🏅 Top traders" title={`🏅 Top ${BETTORS}`}
header="Profit" header="Profit"
maxToShow={maxLeaderboardSize} maxToShow={maxLeaderboardSize}
/> />

View File

@ -14,6 +14,7 @@ import { Title } from 'web/components/title'
import { Tabs } from 'web/components/layout/tabs' import { Tabs } from 'web/components/layout/tabs'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { BETTORS } from 'common/user'
export async function getStaticProps() { export async function getStaticProps() {
const props = await fetchProps() const props = await fetchProps()
@ -79,7 +80,7 @@ export default function Leaderboards(_props: {
<> <>
<Col className="mx-4 items-center gap-10 lg:flex-row"> <Col className="mx-4 items-center gap-10 lg:flex-row">
<Leaderboard <Leaderboard
title="🏅 Top traders" title={`🏅 Top ${BETTORS}`}
users={topTraders} users={topTraders}
columns={[ columns={[
{ {
@ -126,7 +127,7 @@ export default function Leaderboards(_props: {
<Page> <Page>
<SEO <SEO
title="Leaderboards" title="Leaderboards"
description="Manifold's leaderboards show the top traders and market creators." description={`Manifold's leaderboards show the top ${BETTORS} and market creators.`}
url="/leaderboards" url="/leaderboards"
/> />
<Title text={'Leaderboards'} className={'hidden md:block'} /> <Title text={'Leaderboards'} className={'hidden md:block'} />

View File

@ -1,7 +1,12 @@
import { ControlledTabs } from 'web/components/layout/tabs' import { ControlledTabs } from 'web/components/layout/tabs'
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import Router, { useRouter } from 'next/router' import Router, { useRouter } from 'next/router'
import { Notification, notification_source_types } from 'common/notification' import {
BetFillData,
ContractResolutionData,
Notification,
notification_source_types,
} from 'common/notification'
import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Avatar, EmptyAvatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
@ -141,6 +146,7 @@ function RenderNotificationGroups(props: {
<NotificationItem <NotificationItem
notification={notification.notifications[0]} notification={notification.notifications[0]}
key={notification.notifications[0].id} key={notification.notifications[0].id}
justSummary={false}
/> />
) : ( ) : (
<NotificationGroupItem <NotificationGroupItem
@ -298,7 +304,7 @@ function IncomeNotificationGroupItem(props: {
...notificationsForSourceTitle[0], ...notificationsForSourceTitle[0],
sourceText: sum.toString(), sourceText: sum.toString(),
sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername, sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername,
data: JSON.stringify(uniqueUsers), data: { uniqueUsers },
} }
newNotifications.push(newNotification) newNotifications.push(newNotification)
} }
@ -415,7 +421,7 @@ function IncomeNotificationItem(props: {
const isTip = sourceType === 'tip' || sourceType === 'tip_and_like' const isTip = sourceType === 'tip' || sourceType === 'tip_and_like'
const isUniqueBettorBonus = sourceType === 'bonus' const isUniqueBettorBonus = sourceType === 'bonus'
const userLinks: MultiUserLinkInfo[] = const userLinks: MultiUserLinkInfo[] =
isTip || isUniqueBettorBonus ? JSON.parse(data ?? '{}') : [] isTip || isUniqueBettorBonus ? data?.uniqueUsers ?? [] : []
useEffect(() => { useEffect(() => {
setNotificationsAsSeen([notification]) setNotificationsAsSeen([notification])
@ -443,10 +449,11 @@ function IncomeNotificationItem(props: {
reasonText = !simple ? `liked` : `in likes on` reasonText = !simple ? `liked` : `in likes on`
} }
const streakInDays = const streakInDays = notification.data?.streak
Date.now() - notification.createdTime > 24 * 60 * 60 * 1000 ? notification.data?.streak
? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT : Date.now() - notification.createdTime > 24 * 60 * 60 * 1000
: user?.currentBettingStreak ?? 0 ? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT
: user?.currentBettingStreak ?? 0
const bettingStreakText = const bettingStreakText =
sourceType === 'betting_streak_bonus' && sourceType === 'betting_streak_bonus' &&
(sourceText (sourceText
@ -696,20 +703,11 @@ function NotificationGroupItem(props: {
function NotificationItem(props: { function NotificationItem(props: {
notification: Notification notification: Notification
justSummary?: boolean justSummary: boolean
isChildOfGroup?: boolean isChildOfGroup?: boolean
}) { }) {
const { notification, justSummary, isChildOfGroup } = props const { notification, justSummary, isChildOfGroup } = props
const { const { sourceType, reason, sourceUpdateType } = notification
sourceType,
sourceUserName,
sourceUserAvatarUrl,
sourceUpdateType,
reasonText,
reason,
sourceUserUsername,
sourceText,
} = notification
const [highlighted] = useState(!notification.isSeen) const [highlighted] = useState(!notification.isSeen)
@ -717,39 +715,113 @@ function NotificationItem(props: {
setNotificationsAsSeen([notification]) setNotificationsAsSeen([notification])
}, [notification]) }, [notification])
const questionNeedsResolution = sourceUpdateType == 'closed' // TODO Any new notification should be its own component
if (reason === 'bet_fill') {
return (
<BetFillNotification
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
justSummary={justSummary}
/>
)
} else if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
return (
<ContractResolvedNotification
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
justSummary={justSummary}
/>
)
}
// TODO Add new notification components here
if (justSummary) { if (justSummary) {
return ( return (
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}> <NotificationSummaryFrame
<div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> notification={notification}
<div className={'flex pl-1 sm:pl-0'}> subtitle={
<UserLink (sourceType &&
name={sourceUserName || ''} reason &&
username={sourceUserUsername || ''} getReasonForShowingNotification(notification, true)) ??
className={'mr-0 flex-shrink-0'} ''
short={true} }
/> >
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> <NotificationTextLabel
<span className={'flex-shrink-0'}> className={'line-clamp-1'}
{sourceType && notification={notification}
reason && justSummary={true}
getReasonForShowingNotification(notification, true)} />
</span> </NotificationSummaryFrame>
<div className={'ml-1 text-black'}>
<NotificationTextLabel
className={'line-clamp-1'}
notification={notification}
justSummary={true}
/>
</div>
</div>
</div>
</div>
</Row>
) )
} }
return (
<NotificationFrame
notification={notification}
subtitle={getReasonForShowingNotification(
notification,
isChildOfGroup ?? false
)}
highlighted={highlighted}
>
<div className={'mt-1 ml-1 md:text-base'}>
<NotificationTextLabel notification={notification} />
</div>
</NotificationFrame>
)
}
function NotificationSummaryFrame(props: {
notification: Notification
subtitle: string
children: React.ReactNode
}) {
const { notification, subtitle, children } = props
const { sourceUserName, sourceUserUsername } = notification
return (
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
<div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}>
<div className={'flex pl-1 sm:pl-0'}>
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'mr-0 flex-shrink-0'}
short={true}
/>
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
<span className={'flex-shrink-0'}>{subtitle}</span>
<div className={'line-clamp-1 ml-1 text-black'}>{children}</div>
</div>
</div>
</div>
</Row>
)
}
function NotificationFrame(props: {
notification: Notification
highlighted: boolean
subtitle: string
children: React.ReactNode
isChildOfGroup?: boolean
}) {
const { notification, isChildOfGroup, highlighted, subtitle, children } =
props
const {
sourceType,
sourceUserName,
sourceUserAvatarUrl,
sourceUpdateType,
reason,
reasonText,
sourceUserUsername,
sourceText,
} = notification
const questionNeedsResolution = sourceUpdateType == 'closed'
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 600
return ( return (
<div <div
className={clsx( className={clsx(
@ -795,18 +867,13 @@ function NotificationItem(props: {
} }
> >
<div> <div>
{!questionNeedsResolution && ( <UserLink
<UserLink name={sourceUserName || ''}
name={sourceUserName || ''} username={sourceUserUsername || ''}
username={sourceUserUsername || ''} className={'relative mr-1 flex-shrink-0'}
className={'relative mr-1 flex-shrink-0'} short={isMobile}
short={true} />
/> {subtitle}
)}
{getReasonForShowingNotification(
notification,
isChildOfGroup ?? false
)}
{isChildOfGroup ? ( {isChildOfGroup ? (
<RelativeTimestamp time={notification.createdTime} /> <RelativeTimestamp time={notification.createdTime} />
) : ( ) : (
@ -821,9 +888,7 @@ function NotificationItem(props: {
)} )}
</div> </div>
</Row> </Row>
<div className={'mt-1 ml-1 md:text-base'}> <div className={'mt-1 ml-1 md:text-base'}>{children}</div>
<NotificationTextLabel notification={notification} />
</div>
<div className={'mt-6 border-b border-gray-300'} /> <div className={'mt-6 border-b border-gray-300'} />
</div> </div>
@ -831,6 +896,143 @@ function NotificationItem(props: {
) )
} }
function BetFillNotification(props: {
notification: Notification
highlighted: boolean
justSummary: boolean
isChildOfGroup?: boolean
}) {
const { notification, isChildOfGroup, highlighted, justSummary } = props
const { sourceText, data } = notification
const { creatorOutcome, probability } = (data as BetFillData) ?? {}
const subtitle = 'bet against you'
const amount = formatMoney(parseInt(sourceText ?? '0'))
const description =
creatorOutcome && probability ? (
<span>
of your{' '}
<span
className={
creatorOutcome === 'YES'
? 'text-primary'
: creatorOutcome === 'NO'
? 'text-red-500'
: 'text-blue-500'
}
>
{creatorOutcome}{' '}
</span>
limit order at {Math.round(probability * 100)}% was filled
</span>
) : (
<span>of your limit order was filled</span>
)
if (justSummary) {
return (
<NotificationSummaryFrame notification={notification} subtitle={subtitle}>
<Row className={'line-clamp-1'}>
<span className={'text-primary mr-1'}>{amount}</span>
<span>{description}</span>
</Row>
</NotificationSummaryFrame>
)
}
return (
<NotificationFrame
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
subtitle={subtitle}
>
<Row>
<span>
<span className="text-primary mr-1">{amount}</span>
{description}
</span>
</Row>
</NotificationFrame>
)
}
function ContractResolvedNotification(props: {
notification: Notification
highlighted: boolean
justSummary: boolean
isChildOfGroup?: boolean
}) {
const { notification, isChildOfGroup, highlighted, justSummary } = props
const { sourceText, data } = notification
const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {}
const subtitle = 'resolved the market'
const resolutionDescription = () => {
if (!sourceText) return <div />
if (sourceText === 'YES' || sourceText == 'NO') {
return <BinaryOutcomeLabel outcome={sourceText as any} />
}
if (sourceText.includes('%'))
return <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} />
if (sourceText === 'CANCEL') return <CancelLabel />
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
// Numeric market
if (parseFloat(sourceText))
return <NumericValueLabel value={parseFloat(sourceText)} />
// Free response market
return (
<div className={'line-clamp-1 text-blue-400'}>
<Linkify text={sourceText} />
</div>
)
}
const description =
userInvestment && userPayout !== undefined ? (
<Row className={'gap-1 '}>
{resolutionDescription()}
Invested:
<span className={'text-primary'}>{formatMoney(userInvestment)} </span>
Payout:
<span
className={clsx(
userPayout > 0 ? 'text-primary' : 'text-red-500',
'truncate'
)}
>
{formatMoney(userPayout)}
{` (${userPayout > 0 ? '+' : ''}${Math.round(
((userPayout - userInvestment) / userInvestment) * 100
)}%)`}
</span>
</Row>
) : (
<span>{resolutionDescription()}</span>
)
if (justSummary) {
return (
<NotificationSummaryFrame notification={notification} subtitle={subtitle}>
<Row className={'line-clamp-1'}>{description}</Row>
</NotificationSummaryFrame>
)
}
return (
<NotificationFrame
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
subtitle={subtitle}
>
<Row>
<span>{description}</span>
</Row>
</NotificationFrame>
)
}
export const setNotificationsAsSeen = async (notifications: Notification[]) => { export const setNotificationsAsSeen = async (notifications: Notification[]) => {
const unseenNotifications = notifications.filter((n) => !n.isSeen) const unseenNotifications = notifications.filter((n) => !n.isSeen)
return await Promise.all( return await Promise.all(
@ -950,30 +1152,7 @@ function NotificationTextLabel(props: {
if (sourceType === 'contract') { if (sourceType === 'contract') {
if (justSummary || !sourceText) return <div /> if (justSummary || !sourceText) return <div />
// Resolved contracts // Resolved contracts
if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
{
if (sourceText === 'YES' || sourceText == 'NO') {
return <BinaryOutcomeLabel outcome={sourceText as any} />
}
if (sourceText.includes('%'))
return (
<ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} />
)
if (sourceText === 'CANCEL') return <CancelLabel />
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
// Numeric market
if (parseFloat(sourceText))
return <NumericValueLabel value={parseFloat(sourceText)} />
// Free response market
return (
<div className={className ? className : 'line-clamp-1 text-blue-400'}>
<Linkify text={sourceText} />
</div>
)
}
}
// Close date will be a number - it looks better without it // Close date will be a number - it looks better without it
if (sourceUpdateType === 'closed') { if (sourceUpdateType === 'closed') {
return <div /> return <div />
@ -1001,15 +1180,6 @@ function NotificationTextLabel(props: {
return ( return (
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
) )
} else if (sourceType === 'bet' && sourceText) {
return (
<>
<span className="text-primary">
{formatMoney(parseInt(sourceText))}
</span>{' '}
<span>of your limit order was filled</span>
</>
)
} else if (sourceType === 'challenge' && sourceText) { } else if (sourceType === 'challenge' && sourceText) {
return ( return (
<> <>
@ -1073,9 +1243,6 @@ function getReasonForShowingNotification(
else if (sourceSlug) reasonText = 'joined because you shared' else if (sourceSlug) reasonText = 'joined because you shared'
else reasonText = 'joined because of you' else reasonText = 'joined because of you'
break break
case 'bet':
reasonText = 'bet against you'
break
case 'challenge': case 'challenge':
reasonText = 'accepted your challenge' reasonText = 'accepted your challenge'
break break

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