Merge branch 'manifoldmarkets:main' into main
This commit is contained in:
		
						commit
						932003bff3
					
				|  | @ -1,10 +1,9 @@ | |||
| import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm' | ||||
| import { CPMMContract } from './contract' | ||||
| import { LiquidityProvision } from './liquidity-provision' | ||||
| import { User } from './user' | ||||
| 
 | ||||
| export const getNewLiquidityProvision = ( | ||||
|   user: User, | ||||
|   userId: string, | ||||
|   amount: number, | ||||
|   contract: CPMMContract, | ||||
|   newLiquidityProvisionId: string | ||||
|  | @ -18,7 +17,7 @@ export const getNewLiquidityProvision = ( | |||
| 
 | ||||
|   const newLiquidityProvision: LiquidityProvision = { | ||||
|     id: newLiquidityProvisionId, | ||||
|     userId: user.id, | ||||
|     userId: userId, | ||||
|     contractId: contract.id, | ||||
|     amount, | ||||
|     pool: newPool, | ||||
|  |  | |||
|  | @ -15,6 +15,12 @@ import { Answer } from './answer' | |||
| 
 | ||||
| export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
 | ||||
| export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id
 | ||||
| export const UNIQUE_BETTOR_LIQUIDITY_AMOUNT = 20 | ||||
| 
 | ||||
| type NormalizedBet<T extends Bet = Bet> = Omit< | ||||
|   T, | ||||
|   'userAvatarUrl' | 'userName' | 'userUsername' | ||||
| > | ||||
| 
 | ||||
| export function getCpmmInitialLiquidity( | ||||
|   providerId: string, | ||||
|  | @ -51,7 +57,7 @@ export function getAnteBets( | |||
| 
 | ||||
|   const { createdTime } = contract | ||||
| 
 | ||||
|   const yesBet: Bet = { | ||||
|   const yesBet: NormalizedBet = { | ||||
|     id: yesAnteId, | ||||
|     userId: creator.id, | ||||
|     contractId: contract.id, | ||||
|  | @ -65,7 +71,7 @@ export function getAnteBets( | |||
|     fees: noFees, | ||||
|   } | ||||
| 
 | ||||
|   const noBet: Bet = { | ||||
|   const noBet: NormalizedBet = { | ||||
|     id: noAnteId, | ||||
|     userId: creator.id, | ||||
|     contractId: contract.id, | ||||
|  | @ -93,7 +99,7 @@ export function getFreeAnswerAnte( | |||
| 
 | ||||
|   const { createdTime } = contract | ||||
| 
 | ||||
|   const anteBet: Bet = { | ||||
|   const anteBet: NormalizedBet = { | ||||
|     id: anteBetId, | ||||
|     userId: anteBettorId, | ||||
|     contractId: contract.id, | ||||
|  | @ -123,7 +129,7 @@ export function getMultipleChoiceAntes( | |||
| 
 | ||||
|   const { createdTime } = contract | ||||
| 
 | ||||
|   const bets: Bet[] = answers.map((answer, i) => ({ | ||||
|   const bets: NormalizedBet[] = answers.map((answer, i) => ({ | ||||
|     id: betDocIds[i], | ||||
|     userId: creator.id, | ||||
|     contractId: contract.id, | ||||
|  | @ -173,7 +179,7 @@ export function getNumericAnte( | |||
|     range(0, bucketCount).map((_, i) => [i, betAnte]) | ||||
|   ) | ||||
| 
 | ||||
|   const anteBet: NumericBet = { | ||||
|   const anteBet: NormalizedBet<NumericBet> = { | ||||
|     id: newBetId, | ||||
|     userId: anteBettorId, | ||||
|     contractId: contract.id, | ||||
|  |  | |||
|  | @ -3,6 +3,12 @@ import { Fees } from './fees' | |||
| export type Bet = { | ||||
|   id: string | ||||
|   userId: string | ||||
| 
 | ||||
|   // denormalized for bet lists
 | ||||
|   userAvatarUrl?: string | ||||
|   userUsername: string | ||||
|   userName: string | ||||
| 
 | ||||
|   contractId: string | ||||
|   createdTime: number | ||||
| 
 | ||||
|  |  | |||
|  | @ -13,5 +13,5 @@ export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10 | |||
| export const BETTING_STREAK_BONUS_AMOUNT = | ||||
|   econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10 | ||||
| export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 | ||||
| export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0 | ||||
| export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 | ||||
| export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 | ||||
|  |  | |||
|  | @ -21,7 +21,10 @@ export function isWhitelisted(email?: string) { | |||
| } | ||||
| 
 | ||||
| // 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) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -15,6 +15,9 @@ export type EnvConfig = { | |||
| 
 | ||||
|   // Branding
 | ||||
|   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
 | ||||
|   navbarLogoPath?: string | ||||
|   newQuestionPlaceholders: string[] | ||||
|  | @ -74,10 +77,14 @@ export const PROD_CONFIG: EnvConfig = { | |||
|     'iansphilips@gmail.com', // Ian
 | ||||
|     'd4vidchee@gmail.com', // D4vid
 | ||||
|     'federicoruizcassarino@gmail.com', // Fede
 | ||||
|     'ingawei@gmail.com', //Inga
 | ||||
|   ], | ||||
|   visibility: 'PUBLIC', | ||||
| 
 | ||||
|   moneyMoniker: 'M$', | ||||
|   bettor: 'predictor', | ||||
|   pastBet: 'prediction', | ||||
|   presentBet: 'predict', | ||||
|   navbarLogoPath: '', | ||||
|   faviconPath: '/favicon.ico', | ||||
|   newQuestionPlaceholders: [ | ||||
|  |  | |||
|  | @ -2,3 +2,8 @@ export type Follow = { | |||
|   userId: string | ||||
|   timestamp: number | ||||
| } | ||||
| 
 | ||||
| export type ContractFollow = { | ||||
|   id: string // user id
 | ||||
|   createdTime: number | ||||
| } | ||||
|  |  | |||
|  | @ -31,7 +31,10 @@ import { | |||
|   floatingLesserEqual, | ||||
| } from './util/math' | ||||
| 
 | ||||
| export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'> | ||||
| export type CandidateBet<T extends Bet = Bet> = Omit< | ||||
|   T, | ||||
|   'id' | 'userId' | 'userAvatarUrl' | 'userName' | 'userUsername' | ||||
| > | ||||
| export type BetInfo = { | ||||
|   newBet: CandidateBet | ||||
|   newPool?: { [outcome: string]: number } | ||||
|  | @ -322,7 +325,7 @@ export const getNewBinaryDpmBetInfo = ( | |||
| export const getNewMultiBetInfo = ( | ||||
|   outcome: string, | ||||
|   amount: number, | ||||
|   contract: FreeResponseContract | MultipleChoiceContract, | ||||
|   contract: FreeResponseContract | MultipleChoiceContract | ||||
| ) => { | ||||
|   const { pool, totalShares, totalBets } = contract | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,5 +1,4 @@ | |||
| import { notification_subscription_types, PrivateUser } from './user' | ||||
| import { DOMAIN } from './envs/constants' | ||||
| import { notification_preference } from './user-notification-preferences' | ||||
| 
 | ||||
| export type Notification = { | ||||
|   id: string | ||||
|  | @ -18,7 +17,7 @@ export type Notification = { | |||
|   sourceUserUsername?: string | ||||
|   sourceUserAvatarUrl?: string | ||||
|   sourceText?: string | ||||
|   data?: string | ||||
|   data?: { [key: string]: any } | ||||
| 
 | ||||
|   sourceContractTitle?: string | ||||
|   sourceContractCreatorUsername?: string | ||||
|  | @ -29,6 +28,7 @@ export type Notification = { | |||
| 
 | ||||
|   isSeenOnHref?: string | ||||
| } | ||||
| 
 | ||||
| export type notification_source_types = | ||||
|   | 'contract' | ||||
|   | 'comment' | ||||
|  | @ -54,7 +54,7 @@ export type notification_source_update_types = | |||
|   | 'deleted' | ||||
|   | 'closed' | ||||
| 
 | ||||
| /* Optional - if possible use a keyof notification_subscription_types */ | ||||
| /* Optional - if possible use a notification_preference */ | ||||
| export type notification_reason_types = | ||||
|   | 'tagged_user' | ||||
|   | 'on_new_follow' | ||||
|  | @ -92,68 +92,165 @@ export type notification_reason_types = | |||
|   | 'your_contract_closed' | ||||
|   | 'subsidized_your_market' | ||||
| 
 | ||||
| // 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
 | ||||
| export const notificationReasonToSubscriptionType: Partial< | ||||
|   Record<notification_reason_types, keyof notification_subscription_types> | ||||
| > = { | ||||
|   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', | ||||
| type notification_descriptions = { | ||||
|   [key in notification_preference]: { | ||||
|     simple: string | ||||
|     detailed: string | ||||
|   } | ||||
| } | ||||
| export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { | ||||
|   all_answers_on_my_markets: { | ||||
|     simple: 'Answers on your markets', | ||||
|     detailed: 'Answers on your own markets', | ||||
|   }, | ||||
|   all_comments_on_my_markets: { | ||||
|     simple: 'Comments on your markets', | ||||
|     detailed: 'Comments on your own markets', | ||||
|   }, | ||||
|   answers_by_followed_users_on_watched_markets: { | ||||
|     simple: 'Only answers by users you follow', | ||||
|     detailed: "Only answers by users you follow on markets you're watching", | ||||
|   }, | ||||
|   answers_by_market_creator_on_watched_markets: { | ||||
|     simple: 'Only answers by market creator', | ||||
|     detailed: "Only answers by market creator on markets you're watching", | ||||
|   }, | ||||
|   betting_streaks: { | ||||
|     simple: 'For predictions made over consecutive days', | ||||
|     detailed: 'Bonuses for predictions made over consecutive days', | ||||
|   }, | ||||
|   comments_by_followed_users_on_watched_markets: { | ||||
|     simple: 'Only comments by users you follow', | ||||
|     detailed: | ||||
|       'Only comments by users that you follow on markets that you watch', | ||||
|   }, | ||||
|   contract_from_followed_user: { | ||||
|     simple: 'New markets from users you follow', | ||||
|     detailed: 'New markets from users you follow', | ||||
|   }, | ||||
|   limit_order_fills: { | ||||
|     simple: 'Limit order fills', | ||||
|     detailed: 'When your limit order is filled by another user', | ||||
|   }, | ||||
|   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 ( | ||||
|   privateUser: PrivateUser, | ||||
|   reason: notification_reason_types | keyof notification_subscription_types | ||||
| ) => { | ||||
|   const notificationSettings = privateUser.notificationSubscriptionTypes | ||||
|   let destinations | ||||
|   let subscriptionType: keyof notification_subscription_types | undefined | ||||
|   if (Object.keys(notificationSettings).includes(reason)) { | ||||
|     subscriptionType = reason as keyof notification_subscription_types | ||||
|     destinations = notificationSettings[subscriptionType] | ||||
|   } else { | ||||
|     const key = reason as notification_reason_types | ||||
|     subscriptionType = notificationReasonToSubscriptionType[key] | ||||
|     destinations = subscriptionType | ||||
|       ? notificationSettings[subscriptionType] | ||||
|       : [] | ||||
|   } | ||||
|   return { | ||||
|     sendToEmail: destinations.includes('email'), | ||||
|     sendToBrowser: destinations.includes('browser'), | ||||
|     urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, | ||||
|   } | ||||
| export type BettingStreakData = { | ||||
|   streak: number | ||||
|   bonusAmount: number | ||||
| } | ||||
| 
 | ||||
| export type BetFillData = { | ||||
|   betOutcome: string | ||||
|   creatorOutcome: string | ||||
|   probability: number | ||||
|   fillAmount: number | ||||
| } | ||||
| 
 | ||||
| export type ContractResolutionData = { | ||||
|   outcome: string | ||||
|   userPayout: number | ||||
|   userInvestment: number | ||||
| } | ||||
|  |  | |||
|  | @ -9,7 +9,10 @@ import { CPMMContract, DPMContract } from './contract' | |||
| import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees' | ||||
| import { sumBy } from 'lodash' | ||||
| 
 | ||||
| export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'> | ||||
| export type CandidateBet<T extends Bet> = Omit< | ||||
|   T, | ||||
|   'id' | 'userId' | 'userAvatarUrl' | 'userName' | 'userUsername' | ||||
| > | ||||
| 
 | ||||
| export const getSellBetInfo = (bet: Bet, contract: DPMContract) => { | ||||
|   const { pool, totalShares, totalBets } = contract | ||||
|  |  | |||
|  | @ -1,6 +1,13 @@ | |||
| // A txn (pronounced "texan") respresents a payment between two ids on Manifold
 | ||||
| // 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' | ||||
| 
 | ||||
| export type Txn<T extends AnyTxnType = AnyTxnType> = { | ||||
|  | @ -23,6 +30,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = { | |||
|     | 'REFERRAL' | ||||
|     | 'UNIQUE_BETTOR_BONUS' | ||||
|     | 'BETTING_STREAK_BONUS' | ||||
|     | 'CANCEL_UNIQUE_BETTOR_BONUS' | ||||
| 
 | ||||
|   // Any extra data
 | ||||
|   data?: { [key: string]: any } | ||||
|  | @ -60,13 +68,40 @@ type Referral = { | |||
|   category: 'REFERRAL' | ||||
| } | ||||
| 
 | ||||
| type Bonus = { | ||||
| type UniqueBettorBonus = { | ||||
|   fromType: 'BANK' | ||||
|   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 TipTxn = Txn & Tip | ||||
| export type ManalinkTxn = Txn & Manalink | ||||
| export type ReferralTxn = Txn & Referral | ||||
| export type BettingStreakBonusTxn = Txn & BettingStreakBonus | ||||
| export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus | ||||
| export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus | ||||
|  |  | |||
							
								
								
									
										244
									
								
								common/user-notification-preferences.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										244
									
								
								common/user-notification-preferences.ts
									
									
									
									
									
										Normal 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§ion=${subscriptionType}`, | ||||
|   } | ||||
| } | ||||
							
								
								
									
										205
									
								
								common/user.ts
									
									
									
									
									
								
							
							
						
						
									
										205
									
								
								common/user.ts
									
									
									
									
									
								
							|  | @ -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 = { | ||||
|   id: string | ||||
|  | @ -65,62 +66,14 @@ export type PrivateUser = { | |||
|   initialDeviceToken?: string | ||||
|   initialIpAddress?: string | ||||
|   apiKey?: string | ||||
|   /** @deprecated - use notificationSubscriptionTypes */ | ||||
|   notificationPreferences?: notification_subscribe_types | ||||
|   notificationSubscriptionTypes: notification_subscription_types | ||||
|   notificationPreferences: notification_preferences | ||||
|   twitchInfo?: { | ||||
|     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 = { | ||||
|   investmentValue: number | ||||
|   balance: number | ||||
|  | @ -132,139 +85,9 @@ export type PortfolioMetrics = { | |||
| export const MANIFOLD_USERNAME = 'ManifoldMarkets' | ||||
| export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' | ||||
| 
 | ||||
| export const getDefaultNotificationSettings = ( | ||||
|   userId: string, | ||||
|   privateUser?: PrivateUser, | ||||
|   noEmails?: boolean | ||||
| ) => { | ||||
|   const prevPref = privateUser?.notificationPreferences ?? 'all' | ||||
|   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 | ||||
| } | ||||
| export const BETTOR = ENV_CONFIG.bettor ?? 'bettor' // aka predictor
 | ||||
| export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'bettors' | ||||
| export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'bet' // aka predict
 | ||||
| export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'bets' | ||||
| export const PAST_BET = ENV_CONFIG.pastBet ?? 'bet' // aka prediction
 | ||||
| export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'bets' // aka predictions
 | ||||
|  |  | |||
|  | @ -14,7 +14,8 @@ service cloud.firestore { | |||
|         'manticmarkets@gmail.com', | ||||
|         'iansphilips@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 update: if (userId == request.auth.uid || isAdmin()) | ||||
|                        && 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} { | ||||
|  | @ -161,7 +162,7 @@ service cloud.firestore { | |||
|                        && request.resource.data.diff(resource.data).affectedKeys() | ||||
|                                                                     .hasOnly(['isSeen', 'viewTime']); | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     match /{somePath=**}/groupMembers/{memberId} { | ||||
|       allow read; | ||||
|     } | ||||
|  |  | |||
|  | @ -1,11 +1,16 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| import { z } from 'zod' | ||||
| 
 | ||||
| import { Contract } from '../../common/contract' | ||||
| import { Contract, CPMMContract } from '../../common/contract' | ||||
| import { User } from '../../common/user' | ||||
| import { removeUndefinedProps } from '../../common/util/object' | ||||
| import { getNewLiquidityProvision } from '../../common/add-liquidity' | ||||
| import { APIError, newEndpoint, validate } from './api' | ||||
| import { | ||||
|   DEV_HOUSE_LIQUIDITY_PROVIDER_ID, | ||||
|   HOUSE_LIQUIDITY_PROVIDER_ID, | ||||
| } from '../../common/antes' | ||||
| import { isProd } from './utils' | ||||
| 
 | ||||
| const bodySchema = z.object({ | ||||
|   contractId: z.string(), | ||||
|  | @ -47,7 +52,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => { | |||
| 
 | ||||
|     const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = | ||||
|       getNewLiquidityProvision( | ||||
|         user, | ||||
|         user.id, | ||||
|         amount, | ||||
|         contract, | ||||
|         newLiquidityProvisionDoc.id | ||||
|  | @ -88,3 +93,41 @@ export const addliquidity = newEndpoint({}, async (req, auth) => { | |||
| }) | ||||
| 
 | ||||
| const firestore = admin.firestore() | ||||
| 
 | ||||
| export const addHouseLiquidity = (contract: CPMMContract, amount: number) => { | ||||
|   return firestore.runTransaction(async (transaction) => { | ||||
|     const newLiquidityProvisionDoc = firestore | ||||
|       .collection(`contracts/${contract.id}/liquidity`) | ||||
|       .doc() | ||||
| 
 | ||||
|     const providerId = isProd() | ||||
|       ? HOUSE_LIQUIDITY_PROVIDER_ID | ||||
|       : DEV_HOUSE_LIQUIDITY_PROVIDER_ID | ||||
| 
 | ||||
|     const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = | ||||
|       getNewLiquidityProvision( | ||||
|         providerId, | ||||
|         amount, | ||||
|         contract, | ||||
|         newLiquidityProvisionDoc.id | ||||
|       ) | ||||
| 
 | ||||
|     if (newP !== undefined && !isFinite(newP)) { | ||||
|       throw new APIError( | ||||
|         500, | ||||
|         'Liquidity injection rejected due to overflow error.' | ||||
|       ) | ||||
|     } | ||||
| 
 | ||||
|     transaction.update( | ||||
|       firestore.doc(`contracts/${contract.id}`), | ||||
|       removeUndefinedProps({ | ||||
|         pool: newPool, | ||||
|         p: newP, | ||||
|         totalLiquidity: newTotalLiquidity, | ||||
|       }) | ||||
|     ) | ||||
| 
 | ||||
|     transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) | ||||
|   }) | ||||
| } | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import * as admin from 'firebase-admin' | |||
| import { z } from 'zod' | ||||
| 
 | ||||
| import { getUser } from './utils' | ||||
| import { Bet } from '../../common/bet' | ||||
| import { Contract } from '../../common/contract' | ||||
| import { Comment } from '../../common/comment' | ||||
| import { User } from '../../common/user' | ||||
|  | @ -68,10 +69,21 @@ export const changeUser = async ( | |||
|     .get() | ||||
|   const answerUpdate: Partial<Answer> = removeUndefinedProps(update) | ||||
| 
 | ||||
|   const betsSnap = await firestore | ||||
|     .collectionGroup('bets') | ||||
|     .where('userId', '==', user.id) | ||||
|     .get() | ||||
|   const betsUpdate: Partial<Bet> = removeUndefinedProps({ | ||||
|     userName: update.name, | ||||
|     userUsername: update.username, | ||||
|     userAvatarUrl: update.avatarUrl, | ||||
|   }) | ||||
| 
 | ||||
|   const bulkWriter = firestore.bulkWriter() | ||||
|   commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate)) | ||||
|   answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate)) | ||||
|   contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate)) | ||||
|   betsSnap.docs.forEach((d) => bulkWriter.update(d.ref, betsUpdate)) | ||||
|   await bulkWriter.flush() | ||||
|   console.log('Done writing!') | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,6 +1,8 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| import { | ||||
|   getDestinationsForUser, | ||||
|   BetFillData, | ||||
|   BettingStreakData, | ||||
|   ContractResolutionData, | ||||
|   Notification, | ||||
|   notification_reason_types, | ||||
| } from '../../common/notification' | ||||
|  | @ -26,6 +28,8 @@ import { | |||
|   sendNewUniqueBettorsEmail, | ||||
| } from './emails' | ||||
| import { filterDefined } from '../../common/util/array' | ||||
| import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' | ||||
| import { ContractFollow } from '../../common/follow' | ||||
| const firestore = admin.firestore() | ||||
| 
 | ||||
| type recipients_to_reason_texts = { | ||||
|  | @ -65,7 +69,7 @@ export const createNotification = async ( | |||
|       const { reason } = userToReasonTexts[userId] | ||||
|       const privateUser = await getPrivateUser(userId) | ||||
|       if (!privateUser) continue | ||||
|       const { sendToBrowser, sendToEmail } = await getDestinationsForUser( | ||||
|       const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( | ||||
|         privateUser, | ||||
|         reason | ||||
|       ) | ||||
|  | @ -157,7 +161,7 @@ export type replied_users_info = { | |||
| export const createCommentOrAnswerOrUpdatedContractNotification = async ( | ||||
|   sourceId: string, | ||||
|   sourceType: 'comment' | 'answer' | 'contract', | ||||
|   sourceUpdateType: 'created' | 'updated' | 'resolved', | ||||
|   sourceUpdateType: 'created' | 'updated', | ||||
|   sourceUser: User, | ||||
|   idempotencyKey: string, | ||||
|   sourceText: string, | ||||
|  | @ -165,17 +169,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( | |||
|   miscData?: { | ||||
|     repliedUsersInfo: replied_users_info | ||||
|     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 ?? {} | ||||
|  | @ -228,14 +221,10 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( | |||
|     userId: string, | ||||
|     reason: notification_reason_types | ||||
|   ) => { | ||||
|     if ( | ||||
|       !stillFollowingContract(sourceContract.creatorId) || | ||||
|       sourceUser.id == userId | ||||
|     ) | ||||
|       return | ||||
|     if (!stillFollowingContract(userId) || sourceUser.id == userId) return | ||||
|     const privateUser = await getPrivateUser(userId) | ||||
|     if (!privateUser) return | ||||
|     const { sendToBrowser, sendToEmail } = await getDestinationsForUser( | ||||
|     const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( | ||||
|       privateUser, | ||||
|       reason | ||||
|     ) | ||||
|  | @ -274,24 +263,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( | |||
|         sourceUser.avatarUrl | ||||
|       ) | ||||
|       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 notifyTaggedUsers() | ||||
|   await notifyContractCreator() | ||||
|  | @ -467,7 +440,7 @@ export const createTipNotification = async ( | |||
| ) => { | ||||
|   const privateUser = await getPrivateUser(toUser.id) | ||||
|   if (!privateUser) return | ||||
|   const { sendToBrowser } = await getDestinationsForUser( | ||||
|   const { sendToBrowser } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     'tip_received' | ||||
|   ) | ||||
|  | @ -512,7 +485,7 @@ export const createBetFillNotification = async ( | |||
| ) => { | ||||
|   const privateUser = await getPrivateUser(toUser.id) | ||||
|   if (!privateUser) return | ||||
|   const { sendToBrowser } = await getDestinationsForUser( | ||||
|   const { sendToBrowser } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     'bet_fill' | ||||
|   ) | ||||
|  | @ -541,6 +514,12 @@ export const createBetFillNotification = async ( | |||
|     sourceContractTitle: contract.question, | ||||
|     sourceContractSlug: contract.slug, | ||||
|     sourceContractId: contract.id, | ||||
|     data: { | ||||
|       betOutcome: bet.outcome, | ||||
|       creatorOutcome: userBet.outcome, | ||||
|       fillAmount, | ||||
|       probability: userBet.limitProb, | ||||
|     } as BetFillData, | ||||
|   } | ||||
|   return await notificationRef.set(removeUndefinedProps(notification)) | ||||
| 
 | ||||
|  | @ -557,7 +536,7 @@ export const createReferralNotification = async ( | |||
| ) => { | ||||
|   const privateUser = await getPrivateUser(toUser.id) | ||||
|   if (!privateUser) return | ||||
|   const { sendToBrowser } = await getDestinationsForUser( | ||||
|   const { sendToBrowser } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     'you_referred_user' | ||||
|   ) | ||||
|  | @ -611,7 +590,7 @@ export const createLoanIncomeNotification = async ( | |||
| ) => { | ||||
|   const privateUser = await getPrivateUser(toUser.id) | ||||
|   if (!privateUser) return | ||||
|   const { sendToBrowser } = await getDestinationsForUser( | ||||
|   const { sendToBrowser } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     'loan_income' | ||||
|   ) | ||||
|  | @ -649,7 +628,7 @@ export const createChallengeAcceptedNotification = async ( | |||
| ) => { | ||||
|   const privateUser = await getPrivateUser(challengeCreator.id) | ||||
|   if (!privateUser) return | ||||
|   const { sendToBrowser } = await getDestinationsForUser( | ||||
|   const { sendToBrowser } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     'challenge_accepted' | ||||
|   ) | ||||
|  | @ -686,11 +665,12 @@ export const createBettingStreakBonusNotification = async ( | |||
|   bet: Bet, | ||||
|   contract: Contract, | ||||
|   amount: number, | ||||
|   streak: number, | ||||
|   idempotencyKey: string | ||||
| ) => { | ||||
|   const privateUser = await getPrivateUser(user.id) | ||||
|   if (!privateUser) return | ||||
|   const { sendToBrowser } = await getDestinationsForUser( | ||||
|   const { sendToBrowser } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     'betting_streak_incremented' | ||||
|   ) | ||||
|  | @ -719,6 +699,10 @@ export const createBettingStreakBonusNotification = async ( | |||
|     sourceContractId: contract.id, | ||||
|     sourceContractTitle: contract.question, | ||||
|     sourceContractCreatorUsername: contract.creatorUsername, | ||||
|     data: { | ||||
|       streak: streak, | ||||
|       bonusAmount: amount, | ||||
|     } as BettingStreakData, | ||||
|   } | ||||
|   return await notificationRef.set(removeUndefinedProps(notification)) | ||||
| } | ||||
|  | @ -733,7 +717,7 @@ export const createLikeNotification = async ( | |||
| ) => { | ||||
|   const privateUser = await getPrivateUser(toUser.id) | ||||
|   if (!privateUser) return | ||||
|   const { sendToBrowser } = await getDestinationsForUser( | ||||
|   const { sendToBrowser } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     'liked_and_tipped_your_contract' | ||||
|   ) | ||||
|  | @ -780,7 +764,7 @@ export const createUniqueBettorBonusNotification = async ( | |||
| ) => { | ||||
|   const privateUser = await getPrivateUser(contractCreatorId) | ||||
|   if (!privateUser) return | ||||
|   const { sendToBrowser, sendToEmail } = await getDestinationsForUser( | ||||
|   const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     'unique_bettors_on_your_contract' | ||||
|   ) | ||||
|  | @ -870,7 +854,7 @@ export const createNewContractNotification = async ( | |||
|   ) => { | ||||
|     const privateUser = await getPrivateUser(userId) | ||||
|     if (!privateUser) return | ||||
|     const { sendToBrowser, sendToEmail } = await getDestinationsForUser( | ||||
|     const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( | ||||
|       privateUser, | ||||
|       reason | ||||
|     ) | ||||
|  | @ -930,3 +914,130 @@ export const createNewContractNotification = async ( | |||
|     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' | ||||
|       ) | ||||
|     ) | ||||
|   ) | ||||
| } | ||||
|  |  | |||
|  | @ -1,11 +1,7 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| import { z } from 'zod' | ||||
| 
 | ||||
| import { | ||||
|   getDefaultNotificationSettings, | ||||
|   PrivateUser, | ||||
|   User, | ||||
| } from '../../common/user' | ||||
| import { PrivateUser, User } from '../../common/user' | ||||
| import { getUser, getUserByUsername, getValues } from './utils' | ||||
| import { randomString } from '../../common/util/random' | ||||
| import { | ||||
|  | @ -22,6 +18,7 @@ import { track } from './analytics' | |||
| import { APIError, newEndpoint, validate } from './api' | ||||
| import { Group } from '../../common/group' | ||||
| import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy' | ||||
| import { getDefaultNotificationPreferences } from '../../common/user-notification-preferences' | ||||
| 
 | ||||
| const bodySchema = z.object({ | ||||
|   deviceToken: z.string().optional(), | ||||
|  | @ -83,7 +80,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { | |||
|     email, | ||||
|     initialIpAddress: req.ip, | ||||
|     initialDeviceToken: deviceToken, | ||||
|     notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid), | ||||
|     notificationPreferences: getDefaultNotificationPreferences(auth.uid), | ||||
|   } | ||||
| 
 | ||||
|   await firestore.collection('private-users').doc(auth.uid).create(privateUser) | ||||
|  |  | |||
|  | @ -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;"> </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;"> </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;"> </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;"> </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> | ||||
|  | @ -494,7 +494,7 @@ | |||
|                                               <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|                                           </div> | ||||
|                                         </td> | ||||
|  |  | |||
|  | @ -443,7 +443,7 @@ | |||
|                                                                                         <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|                                                                                 </div> | ||||
|                                                                             </td> | ||||
|  |  | |||
|  | @ -529,7 +529,7 @@ | |||
|                     <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|                 </tr> | ||||
|               </table> | ||||
|  |  | |||
|  | @ -369,7 +369,7 @@ | |||
|                   <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|               </tr> | ||||
|             </table> | ||||
|  |  | |||
|  | @ -487,7 +487,7 @@ | |||
|                   <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|               </tr> | ||||
|             </table> | ||||
|  |  | |||
|  | @ -369,7 +369,7 @@ | |||
|                   <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|               </tr> | ||||
|             </table> | ||||
|  |  | |||
|  | @ -470,7 +470,7 @@ | |||
|                   <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|               </tr> | ||||
|             </table> | ||||
|  |  | |||
|  | @ -502,7 +502,7 @@ | |||
|                   <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|               </tr> | ||||
|             </table> | ||||
|  |  | |||
|  | @ -318,7 +318,7 @@ | |||
|                                                                                         <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|                                                                                 </div> | ||||
|                                                                             </td> | ||||
|  |  | |||
|  | @ -376,7 +376,7 @@ | |||
|                   <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|               </tr> | ||||
|             </table> | ||||
|  |  | |||
|  | @ -480,7 +480,7 @@ | |||
|                   <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|               </tr> | ||||
|             </table> | ||||
|  |  | |||
|  | @ -283,7 +283,7 @@ | |||
|                                     <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|                                 </div> | ||||
|                               </td> | ||||
|  |  | |||
|  | @ -218,7 +218,7 @@ | |||
|                                                                         <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|                                                                 </div> | ||||
|                                                             </td> | ||||
|  |  | |||
|  | @ -290,7 +290,7 @@ | |||
|                                               <a href="{{unsubscribeUrl}}" style=" | ||||
|                                           color: inherit; | ||||
|                                           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> | ||||
|                                           </div> | ||||
|                                         </td> | ||||
|  |  | |||
|  | @ -2,11 +2,7 @@ import { DOMAIN } from '../../common/envs/constants' | |||
| import { Bet } from '../../common/bet' | ||||
| import { getProbability } from '../../common/calculate' | ||||
| import { Contract } from '../../common/contract' | ||||
| import { | ||||
|   notification_subscription_types, | ||||
|   PrivateUser, | ||||
|   User, | ||||
| } from '../../common/user' | ||||
| import { PrivateUser, User } from '../../common/user' | ||||
| import { | ||||
|   formatLargeNumber, | ||||
|   formatMoney, | ||||
|  | @ -18,11 +14,12 @@ import { formatNumericProbability } from '../../common/pseudo-numeric' | |||
| import { sendTemplateEmail, sendTextEmail } from './send-email' | ||||
| import { getUser } from './utils' | ||||
| import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' | ||||
| import { | ||||
|   notification_reason_types, | ||||
|   getDestinationsForUser, | ||||
| } from '../../common/notification' | ||||
| import { notification_reason_types } from '../../common/notification' | ||||
| import { Dictionary } from 'lodash' | ||||
| import { | ||||
|   getNotificationDestinationsForUser, | ||||
|   notification_preference, | ||||
| } from '../../common/user-notification-preferences' | ||||
| 
 | ||||
| export const sendMarketResolutionEmail = async ( | ||||
|   reason: notification_reason_types, | ||||
|  | @ -36,8 +33,10 @@ export const sendMarketResolutionEmail = async ( | |||
|   resolutionProbability?: number, | ||||
|   resolutions?: { [outcome: string]: number } | ||||
| ) => { | ||||
|   const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = | ||||
|     await getDestinationsForUser(privateUser, reason) | ||||
|   const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     reason | ||||
|   ) | ||||
|   if (!privateUser || !privateUser.email || !sendToEmail) return | ||||
| 
 | ||||
|   const user = await getUser(privateUser.id) | ||||
|  | @ -154,7 +153,7 @@ export const sendWelcomeEmail = async ( | |||
|   const firstName = name.split(' ')[0] | ||||
| 
 | ||||
|   const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ | ||||
|     'onboarding_flow' as keyof notification_subscription_types | ||||
|     'onboarding_flow' as notification_preference | ||||
|   }` | ||||
| 
 | ||||
|   return await sendTemplateEmail( | ||||
|  | @ -214,7 +213,7 @@ export const sendOneWeekBonusEmail = async ( | |||
|   if ( | ||||
|     !privateUser || | ||||
|     !privateUser.email || | ||||
|     !privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') | ||||
|     !privateUser.notificationPreferences.onboarding_flow.includes('email') | ||||
|   ) | ||||
|     return | ||||
| 
 | ||||
|  | @ -222,7 +221,7 @@ export const sendOneWeekBonusEmail = async ( | |||
|   const firstName = name.split(' ')[0] | ||||
| 
 | ||||
|   const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ | ||||
|     'onboarding_flow' as keyof notification_subscription_types | ||||
|     'onboarding_flow' as notification_preference | ||||
|   }` | ||||
|   return await sendTemplateEmail( | ||||
|     privateUser.email, | ||||
|  | @ -247,7 +246,7 @@ export const sendCreatorGuideEmail = async ( | |||
|   if ( | ||||
|     !privateUser || | ||||
|     !privateUser.email || | ||||
|     !privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') | ||||
|     !privateUser.notificationPreferences.onboarding_flow.includes('email') | ||||
|   ) | ||||
|     return | ||||
| 
 | ||||
|  | @ -255,7 +254,7 @@ export const sendCreatorGuideEmail = async ( | |||
|   const firstName = name.split(' ')[0] | ||||
| 
 | ||||
|   const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ | ||||
|     'onboarding_flow' as keyof notification_subscription_types | ||||
|     'onboarding_flow' as notification_preference | ||||
|   }` | ||||
|   return await sendTemplateEmail( | ||||
|     privateUser.email, | ||||
|  | @ -279,7 +278,7 @@ export const sendThankYouEmail = async ( | |||
|   if ( | ||||
|     !privateUser || | ||||
|     !privateUser.email || | ||||
|     !privateUser.notificationSubscriptionTypes.thank_you_for_purchases.includes( | ||||
|     !privateUser.notificationPreferences.thank_you_for_purchases.includes( | ||||
|       'email' | ||||
|     ) | ||||
|   ) | ||||
|  | @ -289,7 +288,7 @@ export const sendThankYouEmail = async ( | |||
|   const firstName = name.split(' ')[0] | ||||
| 
 | ||||
|   const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ | ||||
|     'thank_you_for_purchases' as keyof notification_subscription_types | ||||
|     'thank_you_for_purchases' as notification_preference | ||||
|   }` | ||||
| 
 | ||||
|   return await sendTemplateEmail( | ||||
|  | @ -312,8 +311,10 @@ export const sendMarketCloseEmail = async ( | |||
|   privateUser: PrivateUser, | ||||
|   contract: Contract | ||||
| ) => { | ||||
|   const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = | ||||
|     await getDestinationsForUser(privateUser, reason) | ||||
|   const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     reason | ||||
|   ) | ||||
| 
 | ||||
|   if (!privateUser.email || !sendToEmail) return | ||||
| 
 | ||||
|  | @ -350,8 +351,10 @@ export const sendNewCommentEmail = async ( | |||
|   answerText?: string, | ||||
|   answerId?: string | ||||
| ) => { | ||||
|   const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = | ||||
|     await getDestinationsForUser(privateUser, reason) | ||||
|   const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     reason | ||||
|   ) | ||||
|   if (!privateUser || !privateUser.email || !sendToEmail) return | ||||
| 
 | ||||
|   const { question } = contract | ||||
|  | @ -425,8 +428,10 @@ export const sendNewAnswerEmail = async ( | |||
|   // Don't send the creator's own answers.
 | ||||
|   if (privateUser.id === creatorId) return | ||||
| 
 | ||||
|   const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = | ||||
|     await getDestinationsForUser(privateUser, reason) | ||||
|   const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     reason | ||||
|   ) | ||||
|   if (!privateUser.email || !sendToEmail) return | ||||
| 
 | ||||
|   const { question, creatorUsername, slug } = contract | ||||
|  | @ -460,14 +465,12 @@ export const sendInterestingMarketsEmail = async ( | |||
|   if ( | ||||
|     !privateUser || | ||||
|     !privateUser.email || | ||||
|     !privateUser.notificationSubscriptionTypes.trending_markets.includes( | ||||
|       'email' | ||||
|     ) | ||||
|     !privateUser.notificationPreferences.trending_markets.includes('email') | ||||
|   ) | ||||
|     return | ||||
| 
 | ||||
|   const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ | ||||
|     'trending_markets' as keyof notification_subscription_types | ||||
|     'trending_markets' as notification_preference | ||||
|   }` | ||||
| 
 | ||||
|   const { name } = user | ||||
|  | @ -518,8 +521,10 @@ export const sendNewFollowedMarketEmail = async ( | |||
|   privateUser: PrivateUser, | ||||
|   contract: Contract | ||||
| ) => { | ||||
|   const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = | ||||
|     await getDestinationsForUser(privateUser, reason) | ||||
|   const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     reason | ||||
|   ) | ||||
|   if (!privateUser.email || !sendToEmail) return | ||||
|   const user = await getUser(privateUser.id) | ||||
|   if (!user) return | ||||
|  | @ -555,8 +560,10 @@ export const sendNewUniqueBettorsEmail = async ( | |||
|   userBets: Dictionary<[Bet, ...Bet[]]>, | ||||
|   bonusAmount: number | ||||
| ) => { | ||||
|   const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = | ||||
|     await getDestinationsForUser(privateUser, reason) | ||||
|   const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( | ||||
|     privateUser, | ||||
|     reason | ||||
|   ) | ||||
|   if (!privateUser.email || !sendToEmail) return | ||||
|   const user = await getUser(privateUser.id) | ||||
|   if (!user) return | ||||
|  |  | |||
|  | @ -71,6 +71,7 @@ import { stripewebhook, createcheckoutsession } from './stripe' | |||
| import { getcurrentuser } from './get-current-user' | ||||
| import { acceptchallenge } from './accept-challenge' | ||||
| import { createpost } from './create-post' | ||||
| import { savetwitchcredentials } from './save-twitch-credentials' | ||||
| 
 | ||||
| const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { | ||||
|   return onRequest(opts, handler as any) | ||||
|  | @ -96,6 +97,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) | |||
| const getCurrentUserFunction = toCloudFunction(getcurrentuser) | ||||
| const acceptChallenge = toCloudFunction(acceptchallenge) | ||||
| const createPostFunction = toCloudFunction(createpost) | ||||
| const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) | ||||
| 
 | ||||
| export { | ||||
|   healthFunction as health, | ||||
|  | @ -119,4 +121,5 @@ export { | |||
|   getCurrentUserFunction as getcurrentuser, | ||||
|   acceptChallenge as acceptchallenge, | ||||
|   createPostFunction as createpost, | ||||
|   saveTwitchCredentials as savetwitchcredentials | ||||
| } | ||||
|  |  | |||
|  | @ -24,6 +24,10 @@ import { | |||
| } from '../../common/antes' | ||||
| import { APIError } from '../../common/api' | ||||
| import { User } from '../../common/user' | ||||
| import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes' | ||||
| import { addHouseLiquidity } from './add-liquidity' | ||||
| import { DAY_MS } from '../../common/util/time' | ||||
| import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' | ||||
| 
 | ||||
| const firestore = admin.firestore() | ||||
| 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) | ||||
|     if (!bettor) return | ||||
| 
 | ||||
|     await change.ref.update({ | ||||
|       userAvatarUrl: bettor.avatarUrl, | ||||
|       userName: bettor.name, | ||||
|       userUsername: bettor.username, | ||||
|     }) | ||||
| 
 | ||||
|     await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor) | ||||
|     await notifyFills(bet, contract, eventId, bettor) | ||||
|     await updateBettingStreak(bettor, bet, contract, eventId) | ||||
|  | @ -72,12 +82,16 @@ const updateBettingStreak = async ( | |||
|   contract: Contract, | ||||
|   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 | ||||
| 
 | ||||
|   // If they've already bet after the reset time, or if we haven't hit the reset time yet
 | ||||
|   if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime) | ||||
|     return | ||||
|   // If they've already bet after the reset time
 | ||||
|   if (lastBetTime > betStreakResetTime) return | ||||
| 
 | ||||
|   const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1 | ||||
|   // Otherwise, add 1 to their betting streak
 | ||||
|  | @ -96,6 +110,7 @@ const updateBettingStreak = async ( | |||
|   const bonusTxnDetails = { | ||||
|     currentBettingStreak: newBettingStreak, | ||||
|   } | ||||
|   // TODO: set the id of the txn to the eventId to prevent duplicates
 | ||||
|   const result = await firestore.runTransaction(async (trans) => { | ||||
|     const bonusTxn: TxnData = { | ||||
|       fromId: fromUserId, | ||||
|  | @ -106,11 +121,14 @@ const updateBettingStreak = async ( | |||
|       token: 'M$', | ||||
|       category: 'BETTING_STREAK_BONUS', | ||||
|       description: JSON.stringify(bonusTxnDetails), | ||||
|     } | ||||
|       data: bonusTxnDetails, | ||||
|     } as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'> | ||||
|     return await runTxn(trans, bonusTxn) | ||||
|   }) | ||||
|   if (!result.txn) { | ||||
|     log("betting streak bonus txn couldn't be made") | ||||
|     log('status:', result.status) | ||||
|     log('message:', result.message) | ||||
|     return | ||||
|   } | ||||
| 
 | ||||
|  | @ -120,6 +138,7 @@ const updateBettingStreak = async ( | |||
|     bet, | ||||
|     contract, | ||||
|     bonusAmount, | ||||
|     newBettingStreak, | ||||
|     eventId | ||||
|   ) | ||||
| } | ||||
|  | @ -149,12 +168,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( | |||
|   } | ||||
| 
 | ||||
|   const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id) | ||||
| 
 | ||||
|   const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id]) | ||||
| 
 | ||||
|   // Update contract unique bettors
 | ||||
|   if (!contract.uniqueBettorIds || isNewUniqueBettor) { | ||||
|     log(`Got ${previousUniqueBettorIds} unique bettors`) | ||||
|     isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`) | ||||
| 
 | ||||
|     await firestore.collection(`contracts`).doc(contract.id).update({ | ||||
|       uniqueBettorIds: newUniqueBettorIds, | ||||
|       uniqueBettorCount: newUniqueBettorIds.length, | ||||
|  | @ -164,10 +184,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( | |||
|   // No need to give a bonus for the creator's bet
 | ||||
|   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
 | ||||
|   const bonusTxnDetails = { | ||||
|     contractId: contract.id, | ||||
|     uniqueBettorIds: newUniqueBettorIds, | ||||
|     uniqueNewBettorId: bettor.id, | ||||
|   } | ||||
|   const fromUserId = isProd() | ||||
|     ? HOUSE_LIQUIDITY_PROVIDER_ID | ||||
|  | @ -175,6 +199,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( | |||
|   const fromSnap = await firestore.doc(`users/${fromUserId}`).get() | ||||
|   if (!fromSnap.exists) throw new APIError(400, 'From user not found.') | ||||
|   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 bonusTxn: TxnData = { | ||||
|       fromId: fromUser.id, | ||||
|  | @ -185,12 +210,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( | |||
|       token: 'M$', | ||||
|       category: 'UNIQUE_BETTOR_BONUS', | ||||
|       description: JSON.stringify(bonusTxnDetails), | ||||
|     } | ||||
|       data: bonusTxnDetails, | ||||
|     } as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'> | ||||
|     return await runTxn(trans, bonusTxn) | ||||
|   }) | ||||
| 
 | ||||
|   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 { | ||||
|     log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) | ||||
|     await createUniqueBettorBonusNotification( | ||||
|  | @ -246,6 +273,6 @@ const notifyFills = async ( | |||
|   ) | ||||
| } | ||||
| 
 | ||||
| const getTodaysBettingStreakResetTime = () => { | ||||
| const currentDateBettingStreakResetTime = () => { | ||||
|   return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) | ||||
| } | ||||
|  |  | |||
|  | @ -7,6 +7,7 @@ import { FIXED_ANTE } from '../../common/economy' | |||
| import { | ||||
|   DEV_HOUSE_LIQUIDITY_PROVIDER_ID, | ||||
|   HOUSE_LIQUIDITY_PROVIDER_ID, | ||||
|   UNIQUE_BETTOR_LIQUIDITY_AMOUNT, | ||||
| } from '../../common/antes' | ||||
| 
 | ||||
| export const onCreateLiquidityProvision = functions.firestore | ||||
|  | @ -17,9 +18,11 @@ export const onCreateLiquidityProvision = functions.firestore | |||
| 
 | ||||
|     // Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
 | ||||
|     if ( | ||||
|       (liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID || | ||||
|       liquidity.isAnte || | ||||
|       ((liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID || | ||||
|         liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) && | ||||
|       liquidity.amount === FIXED_ANTE | ||||
|         (liquidity.amount === FIXED_ANTE || | ||||
|           liquidity.amount === UNIQUE_BETTOR_LIQUIDITY_AMOUNT)) | ||||
|     ) | ||||
|       return | ||||
| 
 | ||||
|  |  | |||
|  | @ -139,7 +139,14 @@ export const placebet = newEndpoint({}, async (req, auth) => { | |||
|     } | ||||
| 
 | ||||
|     const betDoc = contractDoc.collection('bets').doc() | ||||
|     trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet }) | ||||
|     trans.create(betDoc, { | ||||
|       id: betDoc.id, | ||||
|       userId: user.id, | ||||
|       userAvatarUrl: user.avatarUrl, | ||||
|       userUsername: user.username, | ||||
|       userName: user.name, | ||||
|       ...newBet, | ||||
|     }) | ||||
|     log('Created new bet document.') | ||||
| 
 | ||||
|     if (makers) { | ||||
|  |  | |||
|  | @ -9,19 +9,25 @@ import { | |||
|   RESOLUTIONS, | ||||
| } from '../../common/contract' | ||||
| import { Bet } from '../../common/bet' | ||||
| import { getUser, isProd, payUser } from './utils' | ||||
| import { getUser, getValues, isProd, log, payUser } from './utils' | ||||
| import { | ||||
|   getLoanPayouts, | ||||
|   getPayouts, | ||||
|   groupPayoutsByUser, | ||||
|   Payout, | ||||
| } from '../../common/payouts' | ||||
| import { isManifoldId } from '../../common/envs/constants' | ||||
| import { isAdmin, isManifoldId } from '../../common/envs/constants' | ||||
| import { removeUndefinedProps } from '../../common/util/object' | ||||
| import { LiquidityProvision } from '../../common/liquidity-provision' | ||||
| import { APIError, newEndpoint, validate } from './api' | ||||
| 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({ | ||||
|   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') | ||||
|   const contract = contractSnap.data() as Contract | ||||
|   const { creatorId, closeTime } = contract | ||||
|   const firebaseUser = await admin.auth().getUser(auth.uid) | ||||
| 
 | ||||
|   const { value, resolutions, probabilityInt, outcome } = getResolutionParams( | ||||
|     contract, | ||||
|     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') | ||||
| 
 | ||||
|   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([...payouts, ...loanPayouts]) | ||||
|   await undoUniqueBettorRewardsIfCancelResolution(contract, outcome) | ||||
| 
 | ||||
|   const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) | ||||
| 
 | ||||
|  | @ -165,33 +177,13 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { | |||
|     groupBy(bets, (bet) => bet.userId), | ||||
|     (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 createCommentOrAnswerOrUpdatedContractNotification( | ||||
|     contract.id, | ||||
|     'contract', | ||||
|     'resolved', | ||||
|     creator, | ||||
|     contract.id + '-resolution', | ||||
|     resolutionText, | ||||
|   await createContractResolvedNotifications( | ||||
|     contract, | ||||
|     undefined, | ||||
|     creator, | ||||
|     outcome, | ||||
|     probabilityInt, | ||||
|     value, | ||||
|     { | ||||
|       bets, | ||||
|       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() | ||||
|  |  | |||
							
								
								
									
										22
									
								
								functions/src/save-twitch-credentials.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								functions/src/save-twitch-credentials.ts
									
									
									
									
									
										Normal 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() | ||||
|  | @ -4,14 +4,14 @@ import { initAdmin } from './script-init' | |||
| initAdmin() | ||||
| 
 | ||||
| import { getValues } from '../utils' | ||||
| import { Contract } from 'common/lib/contract' | ||||
| import { Comment } from 'common/lib/comment' | ||||
| import { Contract } from 'common/contract' | ||||
| import { Comment } from 'common/comment' | ||||
| import { uniq } from 'lodash' | ||||
| import { Bet } from 'common/lib/bet' | ||||
| import { Bet } from 'common/bet' | ||||
| import { | ||||
|   DEV_HOUSE_LIQUIDITY_PROVIDER_ID, | ||||
|   HOUSE_LIQUIDITY_PROVIDER_ID, | ||||
| } from 'common/lib/antes' | ||||
| } from 'common/antes' | ||||
| 
 | ||||
| const firestore = admin.firestore() | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,8 +1,8 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| 
 | ||||
| import { initAdmin } from './script-init' | ||||
| import { getDefaultNotificationSettings } from 'common/user' | ||||
| import { getAllPrivateUsers, isProd } from 'functions/src/utils' | ||||
| import { getDefaultNotificationPreferences } from 'common/user-notification-preferences' | ||||
| initAdmin() | ||||
| 
 | ||||
| const firestore = admin.firestore() | ||||
|  | @ -17,7 +17,7 @@ async function main() { | |||
|         .collection('private-users') | ||||
|         .doc(privateUser.id) | ||||
|         .update({ | ||||
|           notificationSubscriptionTypes: getDefaultNotificationSettings( | ||||
|           notificationPreferences: getDefaultNotificationPreferences( | ||||
|             privateUser.id, | ||||
|             privateUser, | ||||
|             disableEmails | ||||
|  |  | |||
|  | @ -3,8 +3,9 @@ import * as admin from 'firebase-admin' | |||
| import { initAdmin } from './script-init' | ||||
| initAdmin() | ||||
| 
 | ||||
| import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user' | ||||
| import { PrivateUser, User } from 'common/user' | ||||
| import { STARTING_BALANCE } from 'common/economy' | ||||
| import { getDefaultNotificationPreferences } from 'common/user-notification-preferences' | ||||
| 
 | ||||
| const firestore = admin.firestore() | ||||
| 
 | ||||
|  | @ -21,7 +22,7 @@ async function main() { | |||
|       id: user.id, | ||||
|       email, | ||||
|       username, | ||||
|       notificationSubscriptionTypes: getDefaultNotificationSettings(user.id), | ||||
|       notificationPreferences: getDefaultNotificationPreferences(user.id), | ||||
|     } | ||||
| 
 | ||||
|     if (user.totalDeposits === undefined) { | ||||
|  |  | |||
|  | @ -3,12 +3,7 @@ | |||
| 
 | ||||
| import * as admin from 'firebase-admin' | ||||
| import { initAdmin } from './script-init' | ||||
| import { | ||||
|   DocumentCorrespondence, | ||||
|   findDiffs, | ||||
|   describeDiff, | ||||
|   applyDiff, | ||||
| } from './denormalize' | ||||
| import { findDiffs, describeDiff, applyDiff } from './denormalize' | ||||
| import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' | ||||
| 
 | ||||
| initAdmin() | ||||
|  | @ -79,43 +74,36 @@ if (require.main === module) { | |||
|         getAnswersByUserId(transaction), | ||||
|       ]) | ||||
| 
 | ||||
|     const usersContracts = Array.from( | ||||
|       usersById.entries(), | ||||
|       ([id, doc]): DocumentCorrespondence => { | ||||
|         return [doc, contractsByUserId.get(id) || []] | ||||
|       } | ||||
|     ) | ||||
|     const contractDiffs = findDiffs( | ||||
|       usersContracts, | ||||
|     const usersContracts = Array.from(usersById.entries(), ([id, doc]) => { | ||||
|       return [doc, contractsByUserId.get(id) || []] as const | ||||
|     }) | ||||
|     const contractDiffs = findDiffs(usersContracts, [ | ||||
|       'avatarUrl', | ||||
|       'creatorAvatarUrl' | ||||
|     ) | ||||
|       'creatorAvatarUrl', | ||||
|     ]) | ||||
|     console.log(`Found ${contractDiffs.length} contracts with mismatches.`) | ||||
|     contractDiffs.forEach((d) => { | ||||
|       console.log(describeDiff(d)) | ||||
|       applyDiff(transaction, d) | ||||
|     }) | ||||
| 
 | ||||
|     const usersComments = Array.from( | ||||
|       usersById.entries(), | ||||
|       ([id, doc]): DocumentCorrespondence => { | ||||
|         return [doc, commentsByUserId.get(id) || []] | ||||
|       } | ||||
|     ) | ||||
|     const commentDiffs = findDiffs(usersComments, 'avatarUrl', 'userAvatarUrl') | ||||
|     const usersComments = Array.from(usersById.entries(), ([id, doc]) => { | ||||
|       return [doc, commentsByUserId.get(id) || []] as const | ||||
|     }) | ||||
|     const commentDiffs = findDiffs(usersComments, [ | ||||
|       'avatarUrl', | ||||
|       'userAvatarUrl', | ||||
|     ]) | ||||
|     console.log(`Found ${commentDiffs.length} comments with mismatches.`) | ||||
|     commentDiffs.forEach((d) => { | ||||
|       console.log(describeDiff(d)) | ||||
|       applyDiff(transaction, d) | ||||
|     }) | ||||
| 
 | ||||
|     const usersAnswers = Array.from( | ||||
|       usersById.entries(), | ||||
|       ([id, doc]): DocumentCorrespondence => { | ||||
|         return [doc, answersByUserId.get(id) || []] | ||||
|       } | ||||
|     ) | ||||
|     const answerDiffs = findDiffs(usersAnswers, 'avatarUrl', 'avatarUrl') | ||||
|     const usersAnswers = Array.from(usersById.entries(), ([id, doc]) => { | ||||
|       return [doc, answersByUserId.get(id) || []] as const | ||||
|     }) | ||||
|     const answerDiffs = findDiffs(usersAnswers, ['avatarUrl', 'avatarUrl']) | ||||
|     console.log(`Found ${answerDiffs.length} answers with mismatches.`) | ||||
|     answerDiffs.forEach((d) => { | ||||
|       console.log(describeDiff(d)) | ||||
|  |  | |||
							
								
								
									
										38
									
								
								functions/src/scripts/denormalize-bet-user-data.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								functions/src/scripts/denormalize-bet-user-data.ts
									
									
									
									
									
										Normal 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)) | ||||
| } | ||||
|  | @ -3,12 +3,7 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| import { zip } from 'lodash' | ||||
| import { initAdmin } from './script-init' | ||||
| import { | ||||
|   DocumentCorrespondence, | ||||
|   findDiffs, | ||||
|   describeDiff, | ||||
|   applyDiff, | ||||
| } from './denormalize' | ||||
| import { findDiffs, describeDiff, applyDiff } from './denormalize' | ||||
| import { log } from '../utils' | ||||
| import { Transaction } from 'firebase-admin/firestore' | ||||
| 
 | ||||
|  | @ -41,17 +36,20 @@ async function denormalize() { | |||
|         ) | ||||
|       ) | ||||
|       log(`Found ${bets.length} bets associated with comments.`) | ||||
|       const mapping = zip(bets, betComments) | ||||
|         .map(([bet, comment]): DocumentCorrespondence => { | ||||
|           return [bet!, [comment!]] // eslint-disable-line
 | ||||
|         }) | ||||
|         .filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs
 | ||||
| 
 | ||||
|       const amountDiffs = findDiffs(mapping, 'amount', 'betAmount') | ||||
|       const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome') | ||||
|       log(`Found ${amountDiffs.length} comments with mismatched amounts.`) | ||||
|       log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`) | ||||
|       const diffs = amountDiffs.concat(outcomeDiffs) | ||||
|       // dev DB has some invalid bet IDs
 | ||||
|       const mapping = zip(bets, betComments) | ||||
|         .filter(([bet, _]) => bet!.exists) // eslint-disable-line
 | ||||
|         .map(([bet, comment]) => { | ||||
|           return [bet!, [comment!]] as const // eslint-disable-line
 | ||||
|         }) | ||||
| 
 | ||||
|       const diffs = findDiffs( | ||||
|         mapping, | ||||
|         ['amount', 'betAmount'], | ||||
|         ['outcome', 'betOutcome'] | ||||
|       ) | ||||
|       log(`Found ${diffs.length} comments with mismatched data.`) | ||||
|       diffs.slice(0, 500).forEach((d) => { | ||||
|         log(describeDiff(d)) | ||||
|         applyDiff(trans, d) | ||||
|  |  | |||
|  | @ -2,12 +2,7 @@ | |||
| 
 | ||||
| import * as admin from 'firebase-admin' | ||||
| import { initAdmin } from './script-init' | ||||
| import { | ||||
|   DocumentCorrespondence, | ||||
|   findDiffs, | ||||
|   describeDiff, | ||||
|   applyDiff, | ||||
| } from './denormalize' | ||||
| import { findDiffs, describeDiff, applyDiff } from './denormalize' | ||||
| import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' | ||||
| 
 | ||||
| initAdmin() | ||||
|  | @ -43,16 +38,15 @@ async function denormalize() { | |||
|         getContractsById(transaction), | ||||
|         getCommentsByContractId(transaction), | ||||
|       ]) | ||||
|       const mapping = Object.entries(contractsById).map( | ||||
|         ([id, doc]): DocumentCorrespondence => { | ||||
|           return [doc, commentsByContractId.get(id) || []] | ||||
|         } | ||||
|       const mapping = Object.entries(contractsById).map(([id, doc]) => { | ||||
|         return [doc, commentsByContractId.get(id) || []] as const | ||||
|       }) | ||||
|       const diffs = findDiffs( | ||||
|         mapping, | ||||
|         ['slug', 'contractSlug'], | ||||
|         ['question', 'contractQuestion'] | ||||
|       ) | ||||
|       const slugDiffs = findDiffs(mapping, 'slug', 'contractSlug') | ||||
|       const qDiffs = findDiffs(mapping, 'question', 'contractQuestion') | ||||
|       console.log(`Found ${slugDiffs.length} comments with mismatched slugs.`) | ||||
|       console.log(`Found ${qDiffs.length} comments with mismatched questions.`) | ||||
|       const diffs = slugDiffs.concat(qDiffs) | ||||
|       console.log(`Found ${diffs.length} comments with mismatched data.`) | ||||
|       diffs.slice(0, 500).forEach((d) => { | ||||
|         console.log(describeDiff(d)) | ||||
|         applyDiff(transaction, d) | ||||
|  |  | |||
|  | @ -2,32 +2,40 @@ | |||
| // another set of documents.
 | ||||
| 
 | ||||
| import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' | ||||
| import { isEqual, zip } from 'lodash' | ||||
| import { UpdateSpec } from '../utils' | ||||
| 
 | ||||
| export type DocumentValue = { | ||||
|   doc: DocumentSnapshot | ||||
|   field: string | ||||
|   val: unknown | ||||
|   fields: string[] | ||||
|   vals: unknown[] | ||||
| } | ||||
| export type DocumentCorrespondence = [DocumentSnapshot, DocumentSnapshot[]] | ||||
| export type DocumentMapping = readonly [ | ||||
|   DocumentSnapshot, | ||||
|   readonly DocumentSnapshot[] | ||||
| ] | ||||
| export type DocumentDiff = { | ||||
|   src: DocumentValue | ||||
|   dest: DocumentValue | ||||
| } | ||||
| 
 | ||||
| type PathPair = readonly [string, string] | ||||
| 
 | ||||
| export function findDiffs( | ||||
|   docs: DocumentCorrespondence[], | ||||
|   srcPath: string, | ||||
|   destPath: string | ||||
|   docs: readonly DocumentMapping[], | ||||
|   ...paths: PathPair[] | ||||
| ) { | ||||
|   const diffs: DocumentDiff[] = [] | ||||
|   const srcPaths = paths.map((p) => p[0]) | ||||
|   const destPaths = paths.map((p) => p[1]) | ||||
|   for (const [srcDoc, destDocs] of docs) { | ||||
|     const srcVal = srcDoc.get(srcPath) | ||||
|     const srcVals = srcPaths.map((p) => srcDoc.get(p)) | ||||
|     for (const destDoc of destDocs) { | ||||
|       const destVal = destDoc.get(destPath) | ||||
|       if (destVal !== srcVal) { | ||||
|       const destVals = destPaths.map((p) => destDoc.get(p)) | ||||
|       if (!isEqual(srcVals, destVals)) { | ||||
|         diffs.push({ | ||||
|           src: { doc: srcDoc, field: srcPath, val: srcVal }, | ||||
|           dest: { doc: destDoc, field: destPath, val: destVal }, | ||||
|           src: { doc: srcDoc, fields: srcPaths, vals: srcVals }, | ||||
|           dest: { doc: destDoc, fields: destPaths, vals: destVals }, | ||||
|         }) | ||||
|       } | ||||
|     } | ||||
|  | @ -37,12 +45,19 @@ export function findDiffs( | |||
| 
 | ||||
| export function describeDiff(diff: DocumentDiff) { | ||||
|   function describeDocVal(x: DocumentValue): string { | ||||
|     return `${x.doc.ref.path}.${x.field}: ${x.val}` | ||||
|     return `${x.doc.ref.path}.[${x.fields.join('|')}]: [${x.vals.join('|')}]` | ||||
|   } | ||||
|   return `${describeDocVal(diff.src)} -> ${describeDocVal(diff.dest)}` | ||||
| } | ||||
| 
 | ||||
| export function applyDiff(transaction: Transaction, diff: DocumentDiff) { | ||||
|   const { src, dest } = diff | ||||
|   transaction.update(dest.doc.ref, dest.field, src.val) | ||||
| export function getDiffUpdate(diff: DocumentDiff) { | ||||
|   return { | ||||
|     doc: diff.dest.doc.ref, | ||||
|     fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)), | ||||
|   } as UpdateSpec | ||||
| } | ||||
| 
 | ||||
| export function applyDiff(transaction: Transaction, diff: DocumentDiff) { | ||||
|   const update = getDiffUpdate(diff) | ||||
|   transaction.update(update.doc, update.fields) | ||||
| } | ||||
|  |  | |||
							
								
								
									
										34
									
								
								functions/src/scripts/update-bonus-txn-data-fields.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								functions/src/scripts/update-bonus-txn-data-fields.ts
									
									
									
									
									
										Normal 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()) | ||||
							
								
								
									
										25
									
								
								functions/src/scripts/update-notification-preferences.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								functions/src/scripts/update-notification-preferences.ts
									
									
									
									
									
										Normal 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()) | ||||
|  | @ -112,6 +112,9 @@ export const sellshares = newEndpoint({}, async (req, auth) => { | |||
|     transaction.create(newBetDoc, { | ||||
|       id: newBetDoc.id, | ||||
|       userId: user.id, | ||||
|       userAvatarUrl: user.avatarUrl, | ||||
|       userUsername: user.username, | ||||
|       userName: user.name, | ||||
|       ...newBet, | ||||
|     }) | ||||
|     transaction.update( | ||||
|  |  | |||
|  | @ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe' | |||
| import { stripewebhook, createcheckoutsession } from './stripe' | ||||
| import { getcurrentuser } from './get-current-user' | ||||
| import { createpost } from './create-post' | ||||
| import { savetwitchcredentials } from './save-twitch-credentials' | ||||
| 
 | ||||
| type Middleware = (req: Request, res: Response, next: NextFunction) => void | ||||
| const app = express() | ||||
|  | @ -65,6 +66,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket) | |||
| addJsonEndpointRoute('/unsubscribe', unsubscribe) | ||||
| addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) | ||||
| addJsonEndpointRoute('/getcurrentuser', getcurrentuser) | ||||
| addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials) | ||||
| addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) | ||||
| addEndpointRoute('/createpost', createpost) | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,79 +1,227 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| import { EndpointDefinition } from './api' | ||||
| import { getUser } from './utils' | ||||
| import { getPrivateUser } from './utils' | ||||
| import { PrivateUser } from '../../common/user' | ||||
| import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification' | ||||
| import { notification_preference } from '../../common/user-notification-preferences' | ||||
| 
 | ||||
| export const unsubscribe: EndpointDefinition = { | ||||
|   opts: { method: 'GET', minInstances: 1 }, | ||||
|   handler: async (req, res) => { | ||||
|     const id = req.query.id as string | ||||
|     let type = req.query.type as string | ||||
|     const type = req.query.type as string | ||||
|     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 | ||||
|     } | ||||
| 
 | ||||
|     if (type === 'market-resolved') type = 'market-resolve' | ||||
| 
 | ||||
|     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) | ||||
|     const user = await getPrivateUser(id) | ||||
| 
 | ||||
|     if (!user) { | ||||
|       res.send('This user is not currently subscribed or does not exist.') | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     const { name } = user | ||||
|     const previousDestinations = | ||||
|       user.notificationPreferences[notificationSubscriptionType] | ||||
| 
 | ||||
|     console.log(previousDestinations) | ||||
|     const { email } = user | ||||
| 
 | ||||
|     const update: Partial<PrivateUser> = { | ||||
|       ...(type === 'market-resolve' && { | ||||
|         unsubscribedFromResolutionEmails: true, | ||||
|       }), | ||||
|       ...(type === 'market-comment' && { | ||||
|         unsubscribedFromCommentEmails: true, | ||||
|       }), | ||||
|       ...(type === 'market-answer' && { | ||||
|         unsubscribedFromAnswerEmails: true, | ||||
|       }), | ||||
|       ...(type === 'generic' && { | ||||
|         unsubscribedFromGenericEmails: true, | ||||
|       }), | ||||
|       ...(type === 'weekly-trending' && { | ||||
|         unsubscribedFromWeeklyTrendingEmails: true, | ||||
|       }), | ||||
|       notificationPreferences: { | ||||
|         ...user.notificationPreferences, | ||||
|         [notificationSubscriptionType]: previousDestinations.filter( | ||||
|           (destination) => destination !== 'email' | ||||
|         ), | ||||
|       }, | ||||
|     } | ||||
| 
 | ||||
|     await firestore.collection('private-users').doc(id).update(update) | ||||
| 
 | ||||
|     if (type === 'market-resolve') | ||||
|       res.send( | ||||
|         `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.` | ||||
|       ) | ||||
|     else if (type === 'market-comment') | ||||
|       res.send( | ||||
|         `${name}, you have been unsubscribed from market comment emails on Manifold Markets.` | ||||
|       ) | ||||
|     else if (type === 'market-answer') | ||||
|       res.send( | ||||
|         `${name}, you have been unsubscribed from market answer emails on Manifold Markets.` | ||||
|       ) | ||||
|     else if (type === 'weekly-trending') | ||||
|       res.send( | ||||
|         `${name}, you have been unsubscribed from weekly trending emails on Manifold Markets.` | ||||
|       ) | ||||
|     else res.send(`${name}, you have been unsubscribed.`) | ||||
|     res.send( | ||||
|       ` | ||||
| <!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 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> | ||||
| ` | ||||
|     ) | ||||
|   }, | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -4,7 +4,7 @@ import { chunk } from 'lodash' | |||
| import { Contract } from '../../common/contract' | ||||
| import { PrivateUser, User } from '../../common/user' | ||||
| import { Group } from '../../common/group' | ||||
| import { Post } from 'common/post' | ||||
| import { Post } from '../../common/post' | ||||
| 
 | ||||
| export const log = (...args: unknown[]) => { | ||||
|   console.log(`[${new Date().toISOString()}]`, ...args) | ||||
|  |  | |||
|  | @ -11,6 +11,8 @@ import { ResolveConfirmationButton } from '../confirmation-button' | |||
| import { removeUndefinedProps } from 'common/util/object' | ||||
| 
 | ||||
| export function AnswerResolvePanel(props: { | ||||
|   isAdmin: boolean | ||||
|   isCreator: boolean | ||||
|   contract: FreeResponseContract | MultipleChoiceContract | ||||
|   resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined | ||||
|   setResolveOption: ( | ||||
|  | @ -18,7 +20,14 @@ export function AnswerResolvePanel(props: { | |||
|   ) => void | ||||
|   chosenAnswers: { [answerId: string]: number } | ||||
| }) { | ||||
|   const { contract, resolveOption, setResolveOption, chosenAnswers } = props | ||||
|   const { | ||||
|     contract, | ||||
|     resolveOption, | ||||
|     setResolveOption, | ||||
|     chosenAnswers, | ||||
|     isAdmin, | ||||
|     isCreator, | ||||
|   } = props | ||||
|   const answers = Object.keys(chosenAnswers) | ||||
| 
 | ||||
|   const [isSubmitting, setIsSubmitting] = useState(false) | ||||
|  | @ -76,7 +85,14 @@ export function AnswerResolvePanel(props: { | |||
| 
 | ||||
|   return ( | ||||
|     <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"> | ||||
|         <ChooseCancelSelector | ||||
|           className="sm:!flex-row sm:items-center" | ||||
|  |  | |||
|  | @ -24,10 +24,13 @@ import { Linkify } from 'web/components/linkify' | |||
| import { BuyButton } from 'web/components/yes-no-selector' | ||||
| import { UserLink } from 'web/components/user-link' | ||||
| import { Button } from 'web/components/button' | ||||
| import { useAdmin } from 'web/hooks/use-admin' | ||||
| import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]' | ||||
| 
 | ||||
| export function AnswersPanel(props: { | ||||
|   contract: FreeResponseContract | MultipleChoiceContract | ||||
| }) { | ||||
|   const isAdmin = useAdmin() | ||||
|   const { contract } = props | ||||
|   const { creatorId, resolution, resolutions, totalBets, outcomeType } = | ||||
|     contract | ||||
|  | @ -154,17 +157,20 @@ export function AnswersPanel(props: { | |||
|           <CreateAnswerPanel contract={contract} /> | ||||
|         )} | ||||
| 
 | ||||
|       {user?.id === creatorId && !resolution && ( | ||||
|         <> | ||||
|           <Spacer h={2} /> | ||||
|           <AnswerResolvePanel | ||||
|             contract={contract} | ||||
|             resolveOption={resolveOption} | ||||
|             setResolveOption={setResolveOption} | ||||
|             chosenAnswers={chosenAnswers} | ||||
|           /> | ||||
|         </> | ||||
|       )} | ||||
|       {(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) && | ||||
|         !resolution && ( | ||||
|           <> | ||||
|             <Spacer h={2} /> | ||||
|             <AnswerResolvePanel | ||||
|               isAdmin={isAdmin} | ||||
|               isCreator={user?.id === creatorId} | ||||
|               contract={contract} | ||||
|               resolveOption={resolveOption} | ||||
|               setResolveOption={setResolveOption} | ||||
|               chosenAnswers={chosenAnswers} | ||||
|             /> | ||||
|           </> | ||||
|         )} | ||||
|     </Col> | ||||
|   ) | ||||
| } | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares' | |||
| import { Col } from './layout/col' | ||||
| import { Button } from 'web/components/button' | ||||
| import { BetSignUpPrompt } from './sign-up-prompt' | ||||
| import { PRESENT_BET } from 'common/user' | ||||
| 
 | ||||
| /** Button that opens BetPanel in a new modal */ | ||||
| export default function BetButton(props: { | ||||
|  | @ -36,12 +37,12 @@ export default function BetButton(props: { | |||
|           <Button | ||||
|             size="lg" | ||||
|             className={clsx( | ||||
|               'my-auto inline-flex min-w-[75px] whitespace-nowrap', | ||||
|               'my-auto inline-flex min-w-[75px] whitespace-nowrap capitalize', | ||||
|               btnClassName | ||||
|             )} | ||||
|             onClick={() => setOpen(true)} | ||||
|           > | ||||
|             Predict | ||||
|             {PRESENT_BET} | ||||
|           </Button> | ||||
|         ) : ( | ||||
|           <BetSignUpPrompt /> | ||||
|  |  | |||
|  | @ -79,7 +79,7 @@ export function BetInline(props: { | |||
|   return ( | ||||
|     <Col className={clsx('items-center', className)}> | ||||
|       <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 | ||||
|           className="space-x-0" | ||||
|           btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl" | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ import algoliasearch from 'algoliasearch/lite' | |||
| import { SearchOptions } from '@algolia/client-search' | ||||
| import { useRouter } from 'next/router' | ||||
| import { Contract } from 'common/contract' | ||||
| import { User } from 'common/user' | ||||
| import { PAST_BETS, User } from 'common/user' | ||||
| import { | ||||
|   ContractHighlightOptions, | ||||
|   ContractsGrid, | ||||
|  | @ -41,7 +41,7 @@ const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' | |||
| export const SORTS = [ | ||||
|   { label: 'Newest', value: 'newest' }, | ||||
|   { 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 change', value: 'prob-change-day' }, | ||||
|   { label: 'Last updated', value: 'last-updated' }, | ||||
|  | @ -80,9 +80,10 @@ export function ContractSearch(props: { | |||
|   highlightOptions?: ContractHighlightOptions | ||||
|   onContractClick?: (contract: Contract) => void | ||||
|   hideOrderSelector?: boolean | ||||
|   cardHideOptions?: { | ||||
|   cardUIOptions?: { | ||||
|     hideGroupLink?: boolean | ||||
|     hideQuickBet?: boolean | ||||
|     noLinkAvatar?: boolean | ||||
|   } | ||||
|   headerClassName?: string | ||||
|   persistPrefix?: string | ||||
|  | @ -102,7 +103,7 @@ export function ContractSearch(props: { | |||
|     additionalFilter, | ||||
|     onContractClick, | ||||
|     hideOrderSelector, | ||||
|     cardHideOptions, | ||||
|     cardUIOptions, | ||||
|     highlightOptions, | ||||
|     headerClassName, | ||||
|     persistPrefix, | ||||
|  | @ -164,6 +165,7 @@ export function ContractSearch(props: { | |||
|         numericFilters, | ||||
|         page: requestedPage, | ||||
|         hitsPerPage: 20, | ||||
|         advancedSyntax: true, | ||||
|       }) | ||||
|       // if there's a more recent request, forget about this one
 | ||||
|       if (id === requestId.current) { | ||||
|  | @ -222,7 +224,7 @@ export function ContractSearch(props: { | |||
|           showTime={state.showTime ?? undefined} | ||||
|           onContractClick={onContractClick} | ||||
|           highlightOptions={highlightOptions} | ||||
|           cardHideOptions={cardHideOptions} | ||||
|           cardUIOptions={cardUIOptions} | ||||
|         /> | ||||
|       )} | ||||
|     </Col> | ||||
|  | @ -449,7 +451,7 @@ function ContractSearchControls(props: { | |||
|               selected={state.pillFilter === 'your-bets'} | ||||
|               onSelect={selectPill('your-bets')} | ||||
|             > | ||||
|               Your trades | ||||
|               Your {PAST_BETS} | ||||
|             </PillButton> | ||||
|           )} | ||||
| 
 | ||||
|  |  | |||
|  | @ -85,7 +85,11 @@ export function SelectMarketsModal(props: { | |||
|           <ContractSearch | ||||
|             hideOrderSelector | ||||
|             onContractClick={addContract} | ||||
|             cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} | ||||
|             cardUIOptions={{ | ||||
|               hideGroupLink: true, | ||||
|               hideQuickBet: true, | ||||
|               noLinkAvatar: true, | ||||
|             }} | ||||
|             highlightOptions={{ | ||||
|               contractIds: contracts.map((c) => c.id), | ||||
|               highlightClassName: | ||||
|  |  | |||
|  | @ -42,6 +42,7 @@ export function ContractCard(props: { | |||
|   hideQuickBet?: boolean | ||||
|   hideGroupLink?: boolean | ||||
|   trackingPostfix?: string | ||||
|   noLinkAvatar?: boolean | ||||
| }) { | ||||
|   const { | ||||
|     showTime, | ||||
|  | @ -51,6 +52,7 @@ export function ContractCard(props: { | |||
|     hideQuickBet, | ||||
|     hideGroupLink, | ||||
|     trackingPostfix, | ||||
|     noLinkAvatar, | ||||
|   } = props | ||||
|   const contract = useContractWithPreload(props.contract) ?? props.contract | ||||
|   const { question, outcomeType } = contract | ||||
|  | @ -78,6 +80,7 @@ export function ContractCard(props: { | |||
|         <AvatarDetails | ||||
|           contract={contract} | ||||
|           className={'hidden md:inline-flex'} | ||||
|           noLink={noLinkAvatar} | ||||
|         /> | ||||
|         <p | ||||
|           className={clsx( | ||||
|  | @ -142,7 +145,12 @@ export function ContractCard(props: { | |||
|           showQuickBet ? 'w-[85%]' : 'w-full' | ||||
|         )} | ||||
|       > | ||||
|         <AvatarDetails contract={contract} short={true} className="md:hidden" /> | ||||
|         <AvatarDetails | ||||
|           contract={contract} | ||||
|           short={true} | ||||
|           className="md:hidden" | ||||
|           noLink={noLinkAvatar} | ||||
|         /> | ||||
|         <MiscDetails | ||||
|           contract={contract} | ||||
|           showTime={showTime} | ||||
|  |  | |||
|  | @ -86,8 +86,9 @@ export function AvatarDetails(props: { | |||
|   contract: Contract | ||||
|   className?: string | ||||
|   short?: boolean | ||||
|   noLink?: boolean | ||||
| }) { | ||||
|   const { contract, short, className } = props | ||||
|   const { contract, short, className, noLink } = props | ||||
|   const { creatorName, creatorUsername, creatorAvatarUrl } = contract | ||||
| 
 | ||||
|   return ( | ||||
|  | @ -98,8 +99,14 @@ export function AvatarDetails(props: { | |||
|         username={creatorUsername} | ||||
|         avatarUrl={creatorAvatarUrl} | ||||
|         size={6} | ||||
|         noLink={noLink} | ||||
|       /> | ||||
|       <UserLink | ||||
|         name={creatorName} | ||||
|         username={creatorUsername} | ||||
|         short={short} | ||||
|         noLink={noLink} | ||||
|       /> | ||||
|       <UserLink name={creatorName} username={creatorUsername} short={short} /> | ||||
|     </Row> | ||||
|   ) | ||||
| } | ||||
|  |  | |||
|  | @ -18,6 +18,8 @@ import { deleteField } from 'firebase/firestore' | |||
| import ShortToggle from '../widgets/short-toggle' | ||||
| import { DuplicateContractButton } from '../copy-contract-button' | ||||
| import { Row } from '../layout/row' | ||||
| import { BETTORS } from 'common/user' | ||||
| import { capitalize } from 'lodash' | ||||
| 
 | ||||
| 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' | ||||
|  | @ -135,7 +137,7 @@ export function ContractInfoDialog(props: { | |||
|               </tr> */} | ||||
| 
 | ||||
|               <tr> | ||||
|                 <td>Traders</td> | ||||
|                 <td>{capitalize(BETTORS)}</td> | ||||
|                 <td>{bettorsCount}</td> | ||||
|               </tr> | ||||
| 
 | ||||
|  |  | |||
|  | @ -6,13 +6,13 @@ import { formatMoney } from 'common/util/format' | |||
| import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash' | ||||
| import { useState, useMemo, useEffect } from 'react' | ||||
| import { CommentTipMap } from 'web/hooks/use-tip-txns' | ||||
| import { useUserById } from 'web/hooks/use-user' | ||||
| import { listUsers, User } from 'web/lib/firebase/users' | ||||
| import { FeedBet } from '../feed/feed-bets' | ||||
| import { FeedComment } from '../feed/feed-comments' | ||||
| import { Spacer } from '../layout/spacer' | ||||
| import { Leaderboard } from '../leaderboard' | ||||
| import { Title } from '../title' | ||||
| import { BETTORS } from 'common/user' | ||||
| 
 | ||||
| export function ContractLeaderboard(props: { | ||||
|   contract: Contract | ||||
|  | @ -49,7 +49,7 @@ export function ContractLeaderboard(props: { | |||
| 
 | ||||
|   return users && users.length > 0 ? ( | ||||
|     <Leaderboard | ||||
|       title="🏅 Top traders" | ||||
|       title={`🏅 Top ${BETTORS}`} | ||||
|       users={users || []} | ||||
|       columns={[ | ||||
|         { | ||||
|  | @ -88,7 +88,7 @@ export function ContractTopTrades(props: { | |||
| 
 | ||||
|   // Now find the betId with the highest profit
 | ||||
|   const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id | ||||
|   const topBettor = useUserById(betsById[topBetId]?.userId) | ||||
|   const topBettor = betsById[topBetId]?.userName | ||||
| 
 | ||||
|   // And also the commentId of the comment with the highest profit
 | ||||
|   const topCommentId = sortBy( | ||||
|  | @ -121,7 +121,7 @@ export function ContractTopTrades(props: { | |||
|             <FeedBet contract={contract} bet={betsById[topBetId]} /> | ||||
|           </div> | ||||
|           <div className="mt-2 ml-2 text-sm text-gray-500"> | ||||
|             {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! | ||||
|             {topBettor} made {formatMoney(profitById[topBetId] || 0)}! | ||||
|           </div> | ||||
|         </> | ||||
|       )} | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| import { Bet } from 'common/bet' | ||||
| import { Contract, CPMMBinaryContract } from 'common/contract' | ||||
| import { ContractComment } from 'common/comment' | ||||
| import { User } from 'common/user' | ||||
| import { PAST_BETS, User } from 'common/user' | ||||
| import { | ||||
|   ContractCommentsActivity, | ||||
|   ContractBetsActivity, | ||||
|  | @ -18,6 +18,11 @@ import { useLiquidity } from 'web/hooks/use-liquidity' | |||
| import { BetSignUpPrompt } from '../sign-up-prompt' | ||||
| import { PlayMoneyDisclaimer } from '../play-money-disclaimer' | ||||
| 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: { | ||||
|   contract: Contract | ||||
|  | @ -36,13 +41,19 @@ export function ContractTabs(props: { | |||
|   const visibleBets = bets.filter( | ||||
|     (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
 | ||||
|   const updatedComments = useComments(contract.id) | ||||
|   const comments = updatedComments ?? props.comments | ||||
| 
 | ||||
|   const betActivity = visibleLps && ( | ||||
|   const betActivity = lps != null && ( | ||||
|     <ContractBetsActivity | ||||
|       contract={contract} | ||||
|       bets={visibleBets} | ||||
|  | @ -114,13 +125,13 @@ export function ContractTabs(props: { | |||
|             badge: `${comments.length}`, | ||||
|           }, | ||||
|           { | ||||
|             title: 'Trades', | ||||
|             title: capitalize(PAST_BETS), | ||||
|             content: betActivity, | ||||
|             badge: `${visibleBets.length}`, | ||||
|             badge: `${visibleBets.length + visibleLps.length}`, | ||||
|           }, | ||||
|           ...(!user || !userBets?.length | ||||
|             ? [] | ||||
|             : [{ title: 'Your trades', content: yourTrades }]), | ||||
|             : [{ title: `Your ${PAST_BETS}`, content: yourTrades }]), | ||||
|         ]} | ||||
|       /> | ||||
|       {!user ? ( | ||||
|  |  | |||
|  | @ -21,9 +21,10 @@ export function ContractsGrid(props: { | |||
|   loadMore?: () => void | ||||
|   showTime?: ShowTime | ||||
|   onContractClick?: (contract: Contract) => void | ||||
|   cardHideOptions?: { | ||||
|   cardUIOptions?: { | ||||
|     hideQuickBet?: boolean | ||||
|     hideGroupLink?: boolean | ||||
|     noLinkAvatar?: boolean | ||||
|   } | ||||
|   highlightOptions?: ContractHighlightOptions | ||||
|   trackingPostfix?: string | ||||
|  | @ -34,11 +35,11 @@ export function ContractsGrid(props: { | |||
|     showTime, | ||||
|     loadMore, | ||||
|     onContractClick, | ||||
|     cardHideOptions, | ||||
|     cardUIOptions, | ||||
|     highlightOptions, | ||||
|     trackingPostfix, | ||||
|   } = props | ||||
|   const { hideQuickBet, hideGroupLink } = cardHideOptions || {} | ||||
|   const { hideQuickBet, hideGroupLink, noLinkAvatar } = cardUIOptions || {} | ||||
|   const { contractIds, highlightClassName } = highlightOptions || {} | ||||
|   const onVisibilityUpdated = useCallback( | ||||
|     (visible) => { | ||||
|  | @ -80,6 +81,7 @@ export function ContractsGrid(props: { | |||
|             onClick={ | ||||
|               onContractClick ? () => onContractClick(contract) : undefined | ||||
|             } | ||||
|             noLinkAvatar={noLinkAvatar} | ||||
|             hideQuickBet={hideQuickBet} | ||||
|             hideGroupLink={hideGroupLink} | ||||
|             trackingPostfix={trackingPostfix} | ||||
|  |  | |||
|  | @ -18,21 +18,22 @@ export const WatchMarketModal = (props: { | |||
|         <Col className={'gap-2'}> | ||||
|           <span className={'text-indigo-700'}>• What is watching?</span> | ||||
|           <span className={'ml-2'}> | ||||
|             You'll receive notifications on markets by betting, commenting, or | ||||
|             clicking the | ||||
|             Watching a market means you'll receive notifications from activity | ||||
|             on it. You automatically start watching a market if you comment on | ||||
|             it, bet on it, or click the | ||||
|             <EyeIcon | ||||
|               className={clsx('ml-1 inline h-6 w-6 align-top')} | ||||
|               aria-hidden="true" | ||||
|             /> | ||||
|             ️ button on them. | ||||
|             ️ button. | ||||
|           </span> | ||||
|           <span className={'text-indigo-700'}> | ||||
|             • What types of notifications will I receive? | ||||
|           </span> | ||||
|           <span className={'ml-2'}> | ||||
|             You'll receive notifications for new comments, answers, and updates | ||||
|             to the question. See the notifications settings pages to customize | ||||
|             which types of notifications you receive on watched markets. | ||||
|             New comments, answers, and updates to the question. See the | ||||
|             notifications settings pages to customize which types of | ||||
|             notifications you receive on watched markets. | ||||
|           </span> | ||||
|         </Col> | ||||
|       </Col> | ||||
|  |  | |||
|  | @ -21,6 +21,8 @@ import { FileUploadButton } from './file-upload-button' | |||
| import { linkClass } from './site-link' | ||||
| import { mentionSuggestion } from './editor/mention-suggestion' | ||||
| 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 TiptapTweet from './editor/tiptap-tweet' | ||||
| import { EmbedModal } from './editor/embed-modal' | ||||
|  | @ -97,7 +99,12 @@ export function useTextEditor(props: { | |||
|       CharacterCount.configure({ limit: max }), | ||||
|       simple ? DisplayImage : Image, | ||||
|       DisplayLink, | ||||
|       DisplayMention.configure({ suggestion: mentionSuggestion }), | ||||
|       DisplayMention.configure({ | ||||
|         suggestion: mentionSuggestion, | ||||
|       }), | ||||
|       DisplayContractMention.configure({ | ||||
|         suggestion: contractMentionSuggestion, | ||||
|       }), | ||||
|       Iframe, | ||||
|       TiptapTweet, | ||||
|     ], | ||||
|  | @ -316,13 +323,21 @@ export function RichContent(props: { | |||
|       smallImage ? DisplayImage : Image, | ||||
|       DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
 | ||||
|       DisplayMention, | ||||
|       DisplayContractMention.configure({ | ||||
|         // Needed to set a different PluginKey for Prosemirror
 | ||||
|         suggestion: contractMentionSuggestion, | ||||
|       }), | ||||
|       Iframe, | ||||
|       TiptapTweet, | ||||
|     ], | ||||
|     content, | ||||
|     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} /> | ||||
| } | ||||
|  |  | |||
							
								
								
									
										68
									
								
								web/components/editor/contract-mention-list.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								web/components/editor/contract-mention-list.tsx
									
									
									
									
									
										Normal 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 } | ||||
							
								
								
									
										76
									
								
								web/components/editor/contract-mention-suggestion.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								web/components/editor/contract-mention-suggestion.ts
									
									
									
									
									
										Normal 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() | ||||
|       }, | ||||
|     } | ||||
|   }, | ||||
| } | ||||
							
								
								
									
										41
									
								
								web/components/editor/contract-mention.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								web/components/editor/contract-mention.tsx
									
									
									
									
									
										Normal 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', | ||||
|     }), | ||||
| }) | ||||
|  | @ -12,7 +12,7 @@ export default function WrappedTwitterTweetEmbed(props: { | |||
|   const tweetId = props.node.attrs.tweetId.slice(1) | ||||
| 
 | ||||
|   return ( | ||||
|     <NodeViewWrapper className="tiptap-tweet"> | ||||
|     <NodeViewWrapper className="tiptap-tweet [&_.twitter-tweet]:mx-auto"> | ||||
|       <TwitterTweetEmbed tweetId={tweetId} /> | ||||
|     </NodeViewWrapper> | ||||
|   ) | ||||
|  |  | |||
|  | @ -1,8 +1,10 @@ | |||
| import { useState } from 'react' | ||||
| import { Contract, FreeResponseContract } from 'common/contract' | ||||
| import { ContractComment } from 'common/comment' | ||||
| import { Answer } from 'common/answer' | ||||
| import { Bet } from 'common/bet' | ||||
| import { getOutcomeProbability } from 'common/calculate' | ||||
| import { Pagination } from 'web/components/pagination' | ||||
| import { FeedBet } from './feed-bets' | ||||
| import { FeedLiquidity } from './feed-liquidity' | ||||
| import { FeedAnswerCommentGroup } from './feed-answer-comment-group' | ||||
|  | @ -19,6 +21,10 @@ export function ContractBetsActivity(props: { | |||
|   lps: LiquidityProvision[] | ||||
| }) { | ||||
|   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 = [ | ||||
|     ...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.bet.createdTime | ||||
|       : item.type === 'liquidity' | ||||
|       ? -item.lp.createdTime | ||||
|       : undefined | ||||
|   ) | ||||
|   ).slice(start, end) | ||||
| 
 | ||||
|   return ( | ||||
|     <Col className="gap-4"> | ||||
|       {sortedItems.map((item) => | ||||
|         item.type === 'bet' ? ( | ||||
|           <FeedBet key={item.id} contract={contract} bet={item.bet} /> | ||||
|         ) : ( | ||||
|           <FeedLiquidity key={item.id} liquidity={item.lp} /> | ||||
|         ) | ||||
|       )} | ||||
|     </Col> | ||||
|     <> | ||||
|       <Col className="mb-4 gap-4"> | ||||
|         {pageItems.map((item) => | ||||
|           item.type === 'bet' ? ( | ||||
|             <FeedBet key={item.id} contract={contract} bet={item.bet} /> | ||||
|           ) : ( | ||||
|             <FeedLiquidity key={item.id} liquidity={item.lp} /> | ||||
|           ) | ||||
|         )} | ||||
|       </Col> | ||||
|       <Pagination | ||||
|         page={page} | ||||
|         itemsPerPage={50} | ||||
|         totalItems={items.length} | ||||
|         setPage={setPage} | ||||
|         scrollToTop | ||||
|         nextTitle={'Older'} | ||||
|         prevTitle={'Newer'} | ||||
|       /> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,8 +1,7 @@ | |||
| import dayjs from 'dayjs' | ||||
| import { Contract } from 'common/contract' | ||||
| import { Bet } from 'common/bet' | ||||
| import { User } from 'common/user' | ||||
| import { useUser, useUserById } from 'web/hooks/use-user' | ||||
| import { useUser } from 'web/hooks/use-user' | ||||
| import { Row } from 'web/components/layout/row' | ||||
| import { Avatar, EmptyAvatar } from 'web/components/avatar' | ||||
| import clsx from 'clsx' | ||||
|  | @ -15,32 +14,24 @@ import { SiteLink } from 'web/components/site-link' | |||
| import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' | ||||
| import { Challenge } from 'common/challenge' | ||||
| import { UserLink } from 'web/components/user-link' | ||||
| import { BETTOR } from 'common/user' | ||||
| 
 | ||||
| export function FeedBet(props: { contract: Contract; bet: Bet }) { | ||||
|   const { contract, bet } = props | ||||
|   const { userId, createdTime } = bet | ||||
| 
 | ||||
|   const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01') | ||||
|   // eslint-disable-next-line react-hooks/rules-of-hooks
 | ||||
|   const bettor = isBeforeJune2022 ? undefined : useUserById(userId) | ||||
| 
 | ||||
|   const user = useUser() | ||||
|   const isSelf = user?.id === userId | ||||
|   const { userAvatarUrl, userUsername, createdTime } = bet | ||||
|   const showUser = dayjs(createdTime).isAfter('2022-06-01') | ||||
| 
 | ||||
|   return ( | ||||
|     <Row className="items-center gap-2 pt-3"> | ||||
|       {isSelf ? ( | ||||
|         <Avatar avatarUrl={user.avatarUrl} username={user.username} /> | ||||
|       ) : bettor ? ( | ||||
|         <Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} /> | ||||
|       {showUser ? ( | ||||
|         <Avatar avatarUrl={userAvatarUrl} username={userUsername} /> | ||||
|       ) : ( | ||||
|         <EmptyAvatar className="mx-1" /> | ||||
|       )} | ||||
|       <BetStatusText | ||||
|         bet={bet} | ||||
|         contract={contract} | ||||
|         isSelf={isSelf} | ||||
|         bettor={bettor} | ||||
|         hideUser={!showUser} | ||||
|         className="flex-1" | ||||
|       /> | ||||
|     </Row> | ||||
|  | @ -50,13 +41,13 @@ export function FeedBet(props: { contract: Contract; bet: Bet }) { | |||
| export function BetStatusText(props: { | ||||
|   contract: Contract | ||||
|   bet: Bet | ||||
|   isSelf: boolean | ||||
|   bettor?: User | ||||
|   hideUser?: boolean | ||||
|   hideOutcome?: boolean | ||||
|   className?: string | ||||
| }) { | ||||
|   const { bet, contract, bettor, isSelf, hideOutcome, className } = props | ||||
|   const { bet, contract, hideUser, hideOutcome, className } = props | ||||
|   const { outcomeType } = contract | ||||
|   const self = useUser() | ||||
|   const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' | ||||
|   const isFreeResponse = outcomeType === 'FREE_RESPONSE' | ||||
|   const { amount, outcome, createdTime, challengeSlug } = bet | ||||
|  | @ -101,10 +92,10 @@ export function BetStatusText(props: { | |||
| 
 | ||||
|   return ( | ||||
|     <div className={clsx('text-sm text-gray-500', className)}> | ||||
|       {bettor ? ( | ||||
|         <UserLink name={bettor.name} username={bettor.username} /> | ||||
|       {!hideUser ? ( | ||||
|         <UserLink name={bet.userName} username={bet.userUsername} /> | ||||
|       ) : ( | ||||
|         <span>{isSelf ? 'You' : 'A trader'}</span> | ||||
|         <span>{self?.id === bet.userId ? 'You' : `A ${BETTOR}`}</span> | ||||
|       )}{' '} | ||||
|       {bought} {money} | ||||
|       {outOfTotalAmount} | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import { Bet } from 'common/bet' | ||||
| import { ContractComment } from 'common/comment' | ||||
| import { User } from 'common/user' | ||||
| import { PRESENT_BET, User } from 'common/user' | ||||
| import { Contract } from 'common/contract' | ||||
| import React, { useEffect, useState } from 'react' | ||||
| import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash' | ||||
|  | @ -255,7 +255,7 @@ function CommentStatus(props: { | |||
|   const { contract, outcome, prob } = props | ||||
|   return ( | ||||
|     <> | ||||
|       {' betting '} | ||||
|       {` ${PRESENT_BET}ing `} | ||||
|       <OutcomeLabel outcome={outcome} contract={contract} truncate="short" /> | ||||
|       {prob && ' at ' + Math.round(prob * 100) + '%'} | ||||
|     </> | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import clsx from 'clsx' | ||||
| import dayjs from 'dayjs' | ||||
| import { User } from 'common/user' | ||||
| import { BETTOR, User } from 'common/user' | ||||
| import { useUser, useUserById } from 'web/hooks/use-user' | ||||
| import { Row } from 'web/components/layout/row' | ||||
| import { Avatar, EmptyAvatar } from 'web/components/avatar' | ||||
|  | @ -25,7 +25,7 @@ export function FeedLiquidity(props: { | |||
|   const isSelf = user?.id === userId | ||||
| 
 | ||||
|   return ( | ||||
|     <Row className="flex w-full gap-2 pt-3"> | ||||
|     <Row className="items-center gap-2 pt-3"> | ||||
|       {isSelf ? ( | ||||
|         <Avatar avatarUrl={user.avatarUrl} username={user.username} /> | ||||
|       ) : bettor ? ( | ||||
|  | @ -63,7 +63,7 @@ export function LiquidityStatusText(props: { | |||
|       {bettor ? ( | ||||
|         <UserLink name={bettor.name} username={bettor.username} /> | ||||
|       ) : ( | ||||
|         <span>{isSelf ? 'You' : 'A trader'}</span> | ||||
|         <span>{isSelf ? 'You' : `A ${BETTOR}`}</span> | ||||
|       )}{' '} | ||||
|       {bought} a subsidy of {money} | ||||
|       <RelativeTimestamp time={createdTime} /> | ||||
|  |  | |||
|  | @ -4,7 +4,7 @@ import { getFormattedMappedValue } from 'common/pseudo-numeric' | |||
| import { formatMoney, formatPercent } from 'common/util/format' | ||||
| import { sortBy } from 'lodash' | ||||
| import { useState } from 'react' | ||||
| import { useUser, useUserById } from 'web/hooks/use-user' | ||||
| import { useUser } from 'web/hooks/use-user' | ||||
| import { cancelBet } from 'web/lib/firebase/api' | ||||
| import { Avatar } from './avatar' | ||||
| import { Button } from './button' | ||||
|  | @ -109,16 +109,14 @@ function LimitBet(props: { | |||
|     setIsCancelling(true) | ||||
|   } | ||||
| 
 | ||||
|   const user = useUserById(bet.userId) | ||||
| 
 | ||||
|   return ( | ||||
|     <tr> | ||||
|       {!isYou && ( | ||||
|         <td> | ||||
|           <Avatar | ||||
|             size={'sm'} | ||||
|             avatarUrl={user?.avatarUrl} | ||||
|             username={user?.username} | ||||
|             avatarUrl={bet.userAvatarUrl} | ||||
|             username={bet.userUsername} | ||||
|           /> | ||||
|         </td> | ||||
|       )} | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ import { NoLabel, YesLabel } from './outcome-label' | |||
| import { Col } from './layout/col' | ||||
| import { track } from 'web/lib/service/analytics' | ||||
| import { InfoTooltip } from './info-tooltip' | ||||
| import { BETTORS, PRESENT_BET } from 'common/user' | ||||
| 
 | ||||
| export function LiquidityPanel(props: { contract: CPMMContract }) { | ||||
|   const { contract } = props | ||||
|  | @ -104,7 +105,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { | |||
|     <> | ||||
|       <div className="mb-4 text-gray-500"> | ||||
|         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> | ||||
| 
 | ||||
|       <Row> | ||||
|  |  | |||
|  | @ -9,10 +9,11 @@ export function MarketIntroPanel() { | |||
|       <div className="text-xl">Play-money predictions</div> | ||||
| 
 | ||||
|       <Image | ||||
|         height={150} | ||||
|         width={150} | ||||
|         className="self-center" | ||||
|         src="/flappy-logo.gif" | ||||
|         height={125} | ||||
|         width={125} | ||||
|         className="my-4 self-center" | ||||
|         src="/welcome/manipurple.png" | ||||
|         alt="Manifold Markets gradient logo" | ||||
|       /> | ||||
| 
 | ||||
|       <div className="mb-4 text-sm"> | ||||
|  |  | |||
|  | @ -17,6 +17,7 @@ import { useRouter } from 'next/router' | |||
| import NotificationsIcon from 'web/components/notifications-icon' | ||||
| import { useIsIframe } from 'web/hooks/use-is-iframe' | ||||
| import { trackCallback } from 'web/lib/service/analytics' | ||||
| import { PAST_BETS } from 'common/user' | ||||
| 
 | ||||
| function getNavigation() { | ||||
|   return [ | ||||
|  | @ -64,7 +65,7 @@ export function BottomNavBar() { | |||
|           item={{ | ||||
|             name: formatMoney(user.balance), | ||||
|             trackingEventName: 'profile', | ||||
|             href: `/${user.username}?tab=trades`, | ||||
|             href: `/${user.username}?tab=${PAST_BETS}`, | ||||
|             icon: () => ( | ||||
|               <Avatar | ||||
|                 className="mx-auto my-1" | ||||
|  |  | |||
|  | @ -4,11 +4,12 @@ import { User } from 'web/lib/firebase/users' | |||
| import { formatMoney } from 'common/util/format' | ||||
| import { Avatar } from '../avatar' | ||||
| import { trackCallback } from 'web/lib/service/analytics' | ||||
| import { PAST_BETS } from 'common/user' | ||||
| 
 | ||||
| export function ProfileSummary(props: { user: User }) { | ||||
|   const { user } = props | ||||
|   return ( | ||||
|     <Link href={`/${user.username}?tab=trades`}> | ||||
|     <Link href={`/${user.username}?tab=${PAST_BETS}`}> | ||||
|       <a | ||||
|         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" | ||||
|  |  | |||
|  | @ -1,11 +1,7 @@ | |||
| import React, { memo, ReactNode, useEffect, useState } from 'react' | ||||
| import { Row } from 'web/components/layout/row' | ||||
| import clsx from 'clsx' | ||||
| import { | ||||
|   notification_subscription_types, | ||||
|   notification_destination_types, | ||||
|   PrivateUser, | ||||
| } from 'common/user' | ||||
| import { PrivateUser } from 'common/user' | ||||
| import { updatePrivateUser } from 'web/lib/firebase/users' | ||||
| import { Col } from 'web/components/layout/col' | ||||
| import { | ||||
|  | @ -30,6 +26,11 @@ import { | |||
|   usePersistentState, | ||||
| } from 'web/hooks/use-persistent-state' | ||||
| 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: { | ||||
|   navigateToSection: string | undefined | ||||
|  | @ -38,7 +39,7 @@ export function NotificationSettings(props: { | |||
|   const { navigateToSection, privateUser } = props | ||||
|   const [showWatchModal, setShowWatchModal] = useState(false) | ||||
| 
 | ||||
|   const emailsEnabled: Array<keyof notification_subscription_types> = [ | ||||
|   const emailsEnabled: Array<notification_preference> = [ | ||||
|     'all_comments_on_watched_markets', | ||||
|     'all_replies_to_my_comments_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',
 | ||||
|     // 'limit_order_fills',
 | ||||
|   ] | ||||
|   const browserDisabled: Array<keyof notification_subscription_types> = [ | ||||
|   const browserDisabled: Array<notification_preference> = [ | ||||
|     'trending_markets', | ||||
|     'profit_loss_updates', | ||||
|     'onboarding_flow', | ||||
|  | @ -83,91 +84,82 @@ export function NotificationSettings(props: { | |||
| 
 | ||||
|   type SectionData = { | ||||
|     label: string | ||||
|     subscriptionTypeToDescription: { | ||||
|       [key in keyof Partial<notification_subscription_types>]: string | ||||
|     } | ||||
|     subscriptionTypes: Partial<notification_preference>[] | ||||
|   } | ||||
| 
 | ||||
|   const comments: SectionData = { | ||||
|     label: 'New Comments', | ||||
|     subscriptionTypeToDescription: { | ||||
|       all_comments_on_watched_markets: 'All new comments', | ||||
|       all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, | ||||
|     subscriptionTypes: [ | ||||
|       'all_comments_on_watched_markets', | ||||
|       'all_comments_on_contracts_with_shares_in_on_watched_markets', | ||||
|       // TODO: combine these two
 | ||||
|       all_replies_to_my_comments_on_watched_markets: | ||||
|         'Only replies to your comments', | ||||
|       all_replies_to_my_answers_on_watched_markets: | ||||
|         'Only replies to your answers', | ||||
|       // comments_by_followed_users_on_watched_markets: 'By followed users',
 | ||||
|     }, | ||||
|       'all_replies_to_my_comments_on_watched_markets', | ||||
|       'all_replies_to_my_answers_on_watched_markets', | ||||
|     ], | ||||
|   } | ||||
| 
 | ||||
|   const answers: SectionData = { | ||||
|     label: 'New Answers', | ||||
|     subscriptionTypeToDescription: { | ||||
|       all_answers_on_watched_markets: 'All new answers', | ||||
|       all_answers_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, | ||||
|       // answers_by_followed_users_on_watched_markets: 'By followed users',
 | ||||
|       // answers_by_market_creator_on_watched_markets: 'By market creator',
 | ||||
|     }, | ||||
|     subscriptionTypes: [ | ||||
|       'all_answers_on_watched_markets', | ||||
|       'all_answers_on_contracts_with_shares_in_on_watched_markets', | ||||
|     ], | ||||
|   } | ||||
|   const updates: SectionData = { | ||||
|     label: 'Updates & Resolutions', | ||||
|     subscriptionTypeToDescription: { | ||||
|       market_updates_on_watched_markets: 'All creator updates', | ||||
|       market_updates_on_watched_markets_with_shares_in: `Only creator updates on markets you're invested in`, | ||||
|       resolutions_on_watched_markets: 'All market resolutions', | ||||
|       resolutions_on_watched_markets_with_shares_in: `Only market resolutions you're invested in`, | ||||
|       // probability_updates_on_watched_markets: 'Probability updates',
 | ||||
|     }, | ||||
|     subscriptionTypes: [ | ||||
|       'market_updates_on_watched_markets', | ||||
|       'market_updates_on_watched_markets_with_shares_in', | ||||
|       'resolutions_on_watched_markets', | ||||
|       'resolutions_on_watched_markets_with_shares_in', | ||||
|     ], | ||||
|   } | ||||
|   const yourMarkets: SectionData = { | ||||
|     label: 'Markets You Created', | ||||
|     subscriptionTypeToDescription: { | ||||
|       your_contract_closed: 'Your market has closed (and needs resolution)', | ||||
|       all_comments_on_my_markets: 'Comments on your markets', | ||||
|       all_answers_on_my_markets: 'Answers on your markets', | ||||
|       subsidized_your_market: 'Your market was subsidized', | ||||
|       tips_on_your_markets: 'Likes on your markets', | ||||
|     }, | ||||
|     subscriptionTypes: [ | ||||
|       'your_contract_closed', | ||||
|       'all_comments_on_my_markets', | ||||
|       'all_answers_on_my_markets', | ||||
|       'subsidized_your_market', | ||||
|       'tips_on_your_markets', | ||||
|     ], | ||||
|   } | ||||
|   const bonuses: SectionData = { | ||||
|     label: 'Bonuses', | ||||
|     subscriptionTypeToDescription: { | ||||
|       betting_streaks: 'Prediction streak bonuses', | ||||
|       referral_bonuses: 'Referral bonuses from referring users', | ||||
|       unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets', | ||||
|     }, | ||||
|     subscriptionTypes: [ | ||||
|       'betting_streaks', | ||||
|       'referral_bonuses', | ||||
|       'unique_bettors_on_your_contract', | ||||
|     ], | ||||
|   } | ||||
|   const otherBalances: SectionData = { | ||||
|     label: 'Other', | ||||
|     subscriptionTypeToDescription: { | ||||
|       loan_income: 'Automatic loans from your profitable bets', | ||||
|       limit_order_fills: 'Limit order fills', | ||||
|       tips_on_your_comments: 'Tips on your comments', | ||||
|     }, | ||||
|     subscriptionTypes: [ | ||||
|       'loan_income', | ||||
|       'limit_order_fills', | ||||
|       'tips_on_your_comments', | ||||
|     ], | ||||
|   } | ||||
|   const userInteractions: SectionData = { | ||||
|     label: 'Users', | ||||
|     subscriptionTypeToDescription: { | ||||
|       tagged_user: 'A user tagged you', | ||||
|       on_new_follow: 'Someone followed you', | ||||
|       contract_from_followed_user: 'New markets created by users you follow', | ||||
|     }, | ||||
|     subscriptionTypes: [ | ||||
|       'tagged_user', | ||||
|       'on_new_follow', | ||||
|       'contract_from_followed_user', | ||||
|     ], | ||||
|   } | ||||
|   const generalOther: SectionData = { | ||||
|     label: 'Other', | ||||
|     subscriptionTypeToDescription: { | ||||
|       trending_markets: 'Weekly interesting markets', | ||||
|       thank_you_for_purchases: 'Thank you notes for your purchases', | ||||
|       onboarding_flow: 'Explanatory emails to help you get started', | ||||
|       // profit_loss_updates: 'Weekly profit/loss updates',
 | ||||
|     }, | ||||
|     subscriptionTypes: [ | ||||
|       'trending_markets', | ||||
|       'thank_you_for_purchases', | ||||
|       'onboarding_flow', | ||||
|     ], | ||||
|   } | ||||
| 
 | ||||
|   function NotificationSettingLine(props: { | ||||
|     description: string | ||||
|     subscriptionTypeKey: keyof notification_subscription_types | ||||
|     subscriptionTypeKey: notification_preference | ||||
|     destinations: notification_destination_types[] | ||||
|   }) { | ||||
|     const { description, subscriptionTypeKey, destinations } = props | ||||
|  | @ -183,8 +175,8 @@ export function NotificationSettings(props: { | |||
|       toast | ||||
|         .promise( | ||||
|           updatePrivateUser(privateUser.id, { | ||||
|             notificationSubscriptionTypes: { | ||||
|               ...privateUser.notificationSubscriptionTypes, | ||||
|             notificationPreferences: { | ||||
|               ...privateUser.notificationPreferences, | ||||
|               [subscriptionTypeKey]: destinations.includes(setting) | ||||
|                 ? destinations.filter((d) => d !== setting) | ||||
|                 : uniq([...destinations, setting]), | ||||
|  | @ -237,10 +229,8 @@ export function NotificationSettings(props: { | |||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   const getUsersSavedPreference = ( | ||||
|     key: keyof notification_subscription_types | ||||
|   ) => { | ||||
|     return privateUser.notificationSubscriptionTypes[key] ?? [] | ||||
|   const getUsersSavedPreference = (key: notification_preference) => { | ||||
|     return privateUser.notificationPreferences[key] ?? [] | ||||
|   } | ||||
| 
 | ||||
|   const Section = memo(function Section(props: { | ||||
|  | @ -248,17 +238,15 @@ export function NotificationSettings(props: { | |||
|     data: SectionData | ||||
|   }) { | ||||
|     const { icon, data } = props | ||||
|     const { label, subscriptionTypeToDescription } = data | ||||
|     const { label, subscriptionTypes } = data | ||||
|     const expand = | ||||
|       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)
 | ||||
|     // due to a private user settings change. Just going to persist expanded state here
 | ||||
|     const [expanded, setExpanded] = usePersistentState(expand ?? false, { | ||||
|       key: | ||||
|         'NotificationsSettingsSection-' + | ||||
|         Object.keys(subscriptionTypeToDescription).join('-'), | ||||
|       key: 'NotificationsSettingsSection-' + subscriptionTypes.join('-'), | ||||
|       store: storageStore(safeLocalStorage()), | ||||
|     }) | ||||
| 
 | ||||
|  | @ -287,13 +275,13 @@ export function NotificationSettings(props: { | |||
|           )} | ||||
|         </Row> | ||||
|         <Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}> | ||||
|           {Object.entries(subscriptionTypeToDescription).map(([key, value]) => ( | ||||
|           {subscriptionTypes.map((subType) => ( | ||||
|             <NotificationSettingLine | ||||
|               subscriptionTypeKey={key as keyof notification_subscription_types} | ||||
|               subscriptionTypeKey={subType as notification_preference} | ||||
|               destinations={getUsersSavedPreference( | ||||
|                 key as keyof notification_subscription_types | ||||
|                 subType as notification_preference | ||||
|               )} | ||||
|               description={value} | ||||
|               description={NOTIFICATION_DESCRIPTIONS[subType].simple} | ||||
|             /> | ||||
|           ))} | ||||
|         </Col> | ||||
|  |  | |||
|  | @ -10,13 +10,16 @@ import { NumericContract, PseudoNumericContract } from 'common/contract' | |||
| import { APIError, resolveMarket } from 'web/lib/firebase/api' | ||||
| import { BucketInput } from './bucket-input' | ||||
| import { getPseudoProbability } from 'common/pseudo-numeric' | ||||
| import { BETTOR, BETTORS, PAST_BETS } from 'common/user' | ||||
| 
 | ||||
| export function NumericResolutionPanel(props: { | ||||
|   isAdmin: boolean | ||||
|   isCreator: boolean | ||||
|   creator: User | ||||
|   contract: NumericContract | PseudoNumericContract | ||||
|   className?: string | ||||
| }) { | ||||
|   const { contract, className } = props | ||||
|   const { contract, className, isAdmin, isCreator } = props | ||||
|   const { min, max, outcomeType } = contract | ||||
| 
 | ||||
|   const [outcomeMode, setOutcomeMode] = useState< | ||||
|  | @ -78,10 +81,20 @@ export function NumericResolutionPanel(props: { | |||
|       : 'btn-disabled' | ||||
| 
 | ||||
|   return ( | ||||
|     <Col className={clsx('rounded-md bg-white px-8 py-6', className)}> | ||||
|       <div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div> | ||||
|     <Col | ||||
|       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} /> | ||||
| 
 | ||||
|  | @ -99,9 +112,12 @@ export function NumericResolutionPanel(props: { | |||
| 
 | ||||
|       <div> | ||||
|         {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> | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,19 +3,44 @@ import { Col } from 'web/components/layout/col' | |||
| import { | ||||
|   BETTING_STREAK_BONUS_AMOUNT, | ||||
|   BETTING_STREAK_BONUS_MAX, | ||||
|   BETTING_STREAK_RESET_HOUR, | ||||
| } from 'common/economy' | ||||
| import { formatMoney } from 'common/util/format' | ||||
| import { User } from 'common/user' | ||||
| import dayjs from 'dayjs' | ||||
| import clsx from 'clsx' | ||||
| 
 | ||||
| export function BettingStreakModal(props: { | ||||
|   isOpen: boolean | ||||
|   setOpen: (open: boolean) => void | ||||
|   currentUser?: User | null | ||||
| }) { | ||||
|   const { isOpen, setOpen } = props | ||||
|   const { isOpen, setOpen, currentUser } = props | ||||
|   const missingStreak = currentUser && !hasCompletedStreakToday(currentUser) | ||||
| 
 | ||||
|   return ( | ||||
|     <Modal open={isOpen} setOpen={setOpen}> | ||||
|       <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> | ||||
|         <span className={'text-8xl'}>🔥</span> | ||||
|         <span | ||||
|           className={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> | ||||
|         <Col className={'gap-2'}> | ||||
|           <span className={'text-indigo-700'}>• What are they?</span> | ||||
|  | @ -37,3 +62,17 @@ export function BettingStreakModal(props: { | |||
|     </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 | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| import { Modal } from 'web/components/layout/modal' | ||||
| import { Col } from 'web/components/layout/col' | ||||
| import { PAST_BETS } from 'common/user' | ||||
| 
 | ||||
| export function LoansModal(props: { | ||||
|   isOpen: boolean | ||||
|  | @ -11,7 +12,7 @@ export function LoansModal(props: { | |||
|     <Modal open={isOpen} setOpen={setOpen}> | ||||
|       <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> | ||||
|         <span className={'text-8xl'}>🏦</span> | ||||
|         <span className="text-xl">Daily loans on your trades</span> | ||||
|         <span className="text-xl">Daily loans on your {PAST_BETS}</span> | ||||
|         <Col className={'gap-2'}> | ||||
|           <span className={'text-indigo-700'}>• What are daily loans?</span> | ||||
|           <span className={'ml-2'}> | ||||
|  |  | |||
							
								
								
									
										133
									
								
								web/components/profile/twitch-panel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								web/components/profile/twitch-panel.tsx
									
									
									
									
									
										Normal 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> | ||||
|       )} | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
|  | @ -10,13 +10,16 @@ import { APIError, resolveMarket } from 'web/lib/firebase/api' | |||
| import { ProbabilitySelector } from './probability-selector' | ||||
| import { getProbability } from 'common/calculate' | ||||
| import { BinaryContract, resolution } from 'common/contract' | ||||
| import { BETTOR, BETTORS, PAST_BETS } from 'common/user' | ||||
| 
 | ||||
| export function ResolutionPanel(props: { | ||||
|   isAdmin: boolean | ||||
|   isCreator: boolean | ||||
|   creator: User | ||||
|   contract: BinaryContract | ||||
|   className?: string | ||||
| }) { | ||||
|   const { contract, className } = props | ||||
|   const { contract, className, isAdmin, isCreator } = props | ||||
| 
 | ||||
|   // const earnedFees =
 | ||||
|   //   contract.mechanism === 'dpm-2'
 | ||||
|  | @ -66,7 +69,12 @@ export function ResolutionPanel(props: { | |||
|       : 'btn-disabled' | ||||
| 
 | ||||
|   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-3 text-sm text-gray-500">Outcome</div> | ||||
|  | @ -83,23 +91,28 @@ export function ResolutionPanel(props: { | |||
|       <div> | ||||
|         {outcome === 'YES' ? ( | ||||
|           <> | ||||
|             Winnings will be paid out to traders who bought YES. | ||||
|             Winnings will be paid out to {BETTORS} who bought YES. | ||||
|             {/* <br /> | ||||
|             <br /> | ||||
|             You will earn {earnedFees}. */} | ||||
|           </> | ||||
|         ) : outcome === 'NO' ? ( | ||||
|           <> | ||||
|             Winnings will be paid out to traders who bought NO. | ||||
|             Winnings will be paid out to {BETTORS} who bought NO. | ||||
|             {/* <br /> | ||||
|             <br /> | ||||
|             You will earn {earnedFees}. */} | ||||
|           </> | ||||
|         ) : 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' ? ( | ||||
|           <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 | ||||
|               probabilityInt={Math.round(prob)} | ||||
|               setProbabilityInt={setProb} | ||||
|  | @ -107,7 +120,7 @@ export function ResolutionPanel(props: { | |||
|             {/* You will earn {earnedFees}. */} | ||||
|           </Col> | ||||
|         ) : ( | ||||
|           <>Resolving this market will immediately pay out traders.</> | ||||
|           <>Resolving this market will immediately pay out {BETTORS}.</> | ||||
|         )} | ||||
|       </div> | ||||
| 
 | ||||
|  |  | |||
|  | @ -20,13 +20,18 @@ export function UserLink(props: { | |||
|   username: string | ||||
|   className?: string | ||||
|   short?: boolean | ||||
|   noLink?: boolean | ||||
| }) { | ||||
|   const { name, username, className, short } = props | ||||
|   const { name, username, className, short, noLink } = props | ||||
|   const shortName = short ? shortenName(name) : name | ||||
|   return ( | ||||
|     <SiteLink | ||||
|       href={`/${username}`} | ||||
|       className={clsx('z-10 truncate', className)} | ||||
|       className={clsx( | ||||
|         'z-10 truncate', | ||||
|         className, | ||||
|         noLink ? 'pointer-events-none' : '' | ||||
|       )} | ||||
|     > | ||||
|       {shortName} | ||||
|     </SiteLink> | ||||
|  |  | |||
|  | @ -28,10 +28,15 @@ import { ReferralsButton } from 'web/components/referrals-button' | |||
| import { formatMoney } from 'common/util/format' | ||||
| import { ShareIconButton } from 'web/components/share-icon-button' | ||||
| import { ENV_CONFIG } from 'common/envs/constants' | ||||
| import { BettingStreakModal } from 'web/components/profile/betting-streak-modal' | ||||
| import { | ||||
|   BettingStreakModal, | ||||
|   hasCompletedStreakToday, | ||||
| } from 'web/components/profile/betting-streak-modal' | ||||
| import { REFERRAL_AMOUNT } from 'common/economy' | ||||
| import { LoansModal } from './profile/loans-modal' | ||||
| 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 }) { | ||||
|   const { user } = props | ||||
|  | @ -83,6 +88,7 @@ export function UserPage(props: { user: User }) { | |||
|       <BettingStreakModal | ||||
|         isOpen={showBettingStreakModal} | ||||
|         setOpen={setShowBettingStreakModal} | ||||
|         currentUser={currentUser} | ||||
|       /> | ||||
|       {showLoansModal && ( | ||||
|         <LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} /> | ||||
|  | @ -139,7 +145,12 @@ export function UserPage(props: { user: User }) { | |||
|                 <span>profit</span> | ||||
|               </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)} | ||||
|               > | ||||
|                 <span>🔥 {user.currentBettingStreak ?? 0}</span> | ||||
|  | @ -231,7 +242,8 @@ export function UserPage(props: { user: User }) { | |||
|               <SiteLink href="/referrals"> | ||||
|                 Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend! | ||||
|               </SiteLink>{' '} | ||||
|               You have <ReferralsButton user={user} currentUser={currentUser} /> | ||||
|               You've gotten{' '} | ||||
|               <ReferralsButton user={user} currentUser={currentUser} /> | ||||
|             </span> | ||||
|             <ShareIconButton | ||||
|               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: ( | ||||
|                 <> | ||||
|                   <BetsList user={user} /> | ||||
|  |  | |||
|  | @ -9,8 +9,9 @@ import { | |||
|   listenForNewContracts, | ||||
|   getUserBetContracts, | ||||
|   getUserBetContractsQuery, | ||||
|   listAllContracts, | ||||
| } from 'web/lib/firebase/contracts' | ||||
| import { useQueryClient } from 'react-query' | ||||
| import { QueryClient, useQueryClient } from 'react-query' | ||||
| import { MINUTE_MS } from 'common/util/time' | ||||
| 
 | ||||
| export const useContracts = () => { | ||||
|  | @ -23,6 +24,12 @@ export const useContracts = () => { | |||
|   return contracts | ||||
| } | ||||
| 
 | ||||
| const q = new QueryClient() | ||||
| export const getCachedContracts = async () => | ||||
|   q.fetchQuery(['contracts'], () => listAllContracts(1000), { | ||||
|     staleTime: Infinity, | ||||
|   }) | ||||
| 
 | ||||
| export const useActiveContracts = () => { | ||||
|   const [activeContracts, setActiveContracts] = useState< | ||||
|     Contract[] | undefined | ||||
|  |  | |||
							
								
								
									
										9
									
								
								web/lib/api/api-key.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								web/lib/api/api-key.ts
									
									
									
									
									
										Normal 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) | ||||
| } | ||||
							
								
								
									
										41
									
								
								web/lib/twitch/link-twitch-account.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								web/lib/twitch/link-twitch-account.ts
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
|  | @ -48,6 +48,7 @@ | |||
|     "nanoid": "^3.3.4", | ||||
|     "next": "12.2.5", | ||||
|     "node-fetch": "3.2.4", | ||||
|     "prosemirror-state": "1.4.1", | ||||
|     "react": "17.0.2", | ||||
|     "react-beautiful-dnd": "13.1.1", | ||||
|     "react-confetti": "6.0.1", | ||||
|  |  | |||
|  | @ -45,6 +45,8 @@ import { | |||
| import { ContractsGrid } from 'web/components/contract/contracts-grid' | ||||
| import { Title } from 'web/components/title' | ||||
| import { usePrefetch } from 'web/hooks/use-prefetch' | ||||
| import { useAdmin } from 'web/hooks/use-admin' | ||||
| import dayjs from 'dayjs' | ||||
| 
 | ||||
| export const getStaticProps = fromPropz(getStaticPropz) | ||||
| 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: { | ||||
|   user: User | null | undefined | ||||
|   contract: Contract | ||||
| }) { | ||||
|   const { contract, user } = props | ||||
|   const { creatorId, isResolved, outcomeType } = contract | ||||
| 
 | ||||
|   const isCreator = user?.id === creatorId | ||||
|   const isBinary = outcomeType === 'BINARY' | ||||
|   const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' | ||||
|   const isNumeric = outcomeType === 'NUMERIC' | ||||
|   const allowTrade = tradingAllowed(contract) | ||||
|   const allowResolve = !isResolved && isCreator && !!user | ||||
|   const isAdmin = useAdmin() | ||||
|   const allowResolve = | ||||
|     !isResolved && | ||||
|     (isCreator || (needsAdminToResolve(contract) && isAdmin)) && | ||||
|     !!user | ||||
| 
 | ||||
|   const hasSidePanel = | ||||
|     (isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve) | ||||
| 
 | ||||
|  | @ -139,9 +150,19 @@ export function ContractPageSidebar(props: { | |||
|         ))} | ||||
|       {allowResolve && | ||||
|         (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> | ||||
|   ) : null | ||||
|  | @ -154,10 +175,8 @@ export function ContractPageContent( | |||
|   } | ||||
| ) { | ||||
|   const { backToHome, comments, user } = props | ||||
| 
 | ||||
|   const contract = useContractWithPreload(props.contract) ?? props.contract | ||||
|   usePrefetch(user?.id) | ||||
| 
 | ||||
|   useTracking( | ||||
|     'view market', | ||||
|     { | ||||
|  |  | |||
							
								
								
									
										23
									
								
								web/pages/api/v0/twitch/save.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								web/pages/api/v0/twitch/save.ts
									
									
									
									
									
										Normal 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.' }) | ||||
|   } | ||||
| } | ||||
|  | @ -8,6 +8,7 @@ import { | |||
|   usePersistentState, | ||||
|   urlParamStore, | ||||
| } from 'web/hooks/use-persistent-state' | ||||
| import { PAST_BETS } from 'common/user' | ||||
| 
 | ||||
| const MAX_CONTRACTS_RENDERED = 100 | ||||
| 
 | ||||
|  | @ -101,7 +102,7 @@ export default function ContractSearchFirestore(props: { | |||
|         > | ||||
|           <option value="score">Trending</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="close-date">Closing soon</option> | ||||
|         </select> | ||||
|  |  | |||
|  | @ -116,7 +116,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { | |||
|             tradingAllowed(contract) && | ||||
|             !betPanelOpen && ( | ||||
|               <Button color="gradient" onClick={() => setBetPanelOpen(true)}> | ||||
|                 Bet | ||||
|                 Predict | ||||
|               </Button> | ||||
|             )} | ||||
| 
 | ||||
|  |  | |||
|  | @ -50,6 +50,7 @@ import { usePost } from 'web/hooks/use-post' | |||
| import { useAdmin } from 'web/hooks/use-admin' | ||||
| import { track } from '@amplitude/analytics-browser' | ||||
| import { SelectMarketsModal } from 'web/components/contract-select-modal' | ||||
| import { BETTORS } from 'common/user' | ||||
| 
 | ||||
| export const getStaticProps = fromPropz(getStaticPropz) | ||||
| 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"> | ||||
|         <GroupLeaderboard | ||||
|           topUsers={topTraders} | ||||
|           title="🏅 Top traders" | ||||
|           title={`🏅 Top ${BETTORS}`} | ||||
|           header="Profit" | ||||
|           maxToShow={maxLeaderboardSize} | ||||
|         /> | ||||
|  |  | |||
|  | @ -14,6 +14,7 @@ import { Title } from 'web/components/title' | |||
| import { Tabs } from 'web/components/layout/tabs' | ||||
| import { useTracking } from 'web/hooks/use-tracking' | ||||
| import { SEO } from 'web/components/SEO' | ||||
| import { BETTORS } from 'common/user' | ||||
| 
 | ||||
| export async function getStaticProps() { | ||||
|   const props = await fetchProps() | ||||
|  | @ -79,7 +80,7 @@ export default function Leaderboards(_props: { | |||
|       <> | ||||
|         <Col className="mx-4 items-center gap-10 lg:flex-row"> | ||||
|           <Leaderboard | ||||
|             title="🏅 Top traders" | ||||
|             title={`🏅 Top ${BETTORS}`} | ||||
|             users={topTraders} | ||||
|             columns={[ | ||||
|               { | ||||
|  | @ -126,7 +127,7 @@ export default function Leaderboards(_props: { | |||
|     <Page> | ||||
|       <SEO | ||||
|         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" | ||||
|       /> | ||||
|       <Title text={'Leaderboards'} className={'hidden md:block'} /> | ||||
|  |  | |||
|  | @ -1,7 +1,12 @@ | |||
| import { ControlledTabs } from 'web/components/layout/tabs' | ||||
| import React, { useEffect, useMemo, useState } from 'react' | ||||
| 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 { Row } from 'web/components/layout/row' | ||||
| import { Page } from 'web/components/page' | ||||
|  | @ -141,6 +146,7 @@ function RenderNotificationGroups(props: { | |||
|           <NotificationItem | ||||
|             notification={notification.notifications[0]} | ||||
|             key={notification.notifications[0].id} | ||||
|             justSummary={false} | ||||
|           /> | ||||
|         ) : ( | ||||
|           <NotificationGroupItem | ||||
|  | @ -298,7 +304,7 @@ function IncomeNotificationGroupItem(props: { | |||
|           ...notificationsForSourceTitle[0], | ||||
|           sourceText: sum.toString(), | ||||
|           sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername, | ||||
|           data: JSON.stringify(uniqueUsers), | ||||
|           data: { uniqueUsers }, | ||||
|         } | ||||
|         newNotifications.push(newNotification) | ||||
|       } | ||||
|  | @ -415,7 +421,7 @@ function IncomeNotificationItem(props: { | |||
|   const isTip = sourceType === 'tip' || sourceType === 'tip_and_like' | ||||
|   const isUniqueBettorBonus = sourceType === 'bonus' | ||||
|   const userLinks: MultiUserLinkInfo[] = | ||||
|     isTip || isUniqueBettorBonus ? JSON.parse(data ?? '{}') : [] | ||||
|     isTip || isUniqueBettorBonus ? data?.uniqueUsers ?? [] : [] | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     setNotificationsAsSeen([notification]) | ||||
|  | @ -443,10 +449,11 @@ function IncomeNotificationItem(props: { | |||
|       reasonText = !simple ? `liked` : `in likes on` | ||||
|     } | ||||
| 
 | ||||
|     const streakInDays = | ||||
|       Date.now() - notification.createdTime > 24 * 60 * 60 * 1000 | ||||
|         ? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT | ||||
|         : user?.currentBettingStreak ?? 0 | ||||
|     const streakInDays = notification.data?.streak | ||||
|       ? notification.data?.streak | ||||
|       : Date.now() - notification.createdTime > 24 * 60 * 60 * 1000 | ||||
|       ? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT | ||||
|       : user?.currentBettingStreak ?? 0 | ||||
|     const bettingStreakText = | ||||
|       sourceType === 'betting_streak_bonus' && | ||||
|       (sourceText | ||||
|  | @ -696,20 +703,11 @@ function NotificationGroupItem(props: { | |||
| 
 | ||||
| function NotificationItem(props: { | ||||
|   notification: Notification | ||||
|   justSummary?: boolean | ||||
|   justSummary: boolean | ||||
|   isChildOfGroup?: boolean | ||||
| }) { | ||||
|   const { notification, justSummary, isChildOfGroup } = props | ||||
|   const { | ||||
|     sourceType, | ||||
|     sourceUserName, | ||||
|     sourceUserAvatarUrl, | ||||
|     sourceUpdateType, | ||||
|     reasonText, | ||||
|     reason, | ||||
|     sourceUserUsername, | ||||
|     sourceText, | ||||
|   } = notification | ||||
|   const { sourceType, reason, sourceUpdateType } = notification | ||||
| 
 | ||||
|   const [highlighted] = useState(!notification.isSeen) | ||||
| 
 | ||||
|  | @ -717,39 +715,113 @@ function NotificationItem(props: { | |||
|     setNotificationsAsSeen([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) { | ||||
|     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'}> | ||||
|                 {sourceType && | ||||
|                   reason && | ||||
|                   getReasonForShowingNotification(notification, true)} | ||||
|               </span> | ||||
|               <div className={'ml-1 text-black'}> | ||||
|                 <NotificationTextLabel | ||||
|                   className={'line-clamp-1'} | ||||
|                   notification={notification} | ||||
|                   justSummary={true} | ||||
|                 /> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </Row> | ||||
|       <NotificationSummaryFrame | ||||
|         notification={notification} | ||||
|         subtitle={ | ||||
|           (sourceType && | ||||
|             reason && | ||||
|             getReasonForShowingNotification(notification, true)) ?? | ||||
|           '' | ||||
|         } | ||||
|       > | ||||
|         <NotificationTextLabel | ||||
|           className={'line-clamp-1'} | ||||
|           notification={notification} | ||||
|           justSummary={true} | ||||
|         /> | ||||
|       </NotificationSummaryFrame> | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   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 ( | ||||
|     <div | ||||
|       className={clsx( | ||||
|  | @ -795,18 +867,13 @@ function NotificationItem(props: { | |||
|               } | ||||
|             > | ||||
|               <div> | ||||
|                 {!questionNeedsResolution && ( | ||||
|                   <UserLink | ||||
|                     name={sourceUserName || ''} | ||||
|                     username={sourceUserUsername || ''} | ||||
|                     className={'relative mr-1 flex-shrink-0'} | ||||
|                     short={true} | ||||
|                   /> | ||||
|                 )} | ||||
|                 {getReasonForShowingNotification( | ||||
|                   notification, | ||||
|                   isChildOfGroup ?? false | ||||
|                 )} | ||||
|                 <UserLink | ||||
|                   name={sourceUserName || ''} | ||||
|                   username={sourceUserUsername || ''} | ||||
|                   className={'relative mr-1 flex-shrink-0'} | ||||
|                   short={isMobile} | ||||
|                 /> | ||||
|                 {subtitle} | ||||
|                 {isChildOfGroup ? ( | ||||
|                   <RelativeTimestamp time={notification.createdTime} /> | ||||
|                 ) : ( | ||||
|  | @ -821,9 +888,7 @@ function NotificationItem(props: { | |||
|             )} | ||||
|           </div> | ||||
|         </Row> | ||||
|         <div className={'mt-1 ml-1 md:text-base'}> | ||||
|           <NotificationTextLabel notification={notification} /> | ||||
|         </div> | ||||
|         <div className={'mt-1 ml-1 md:text-base'}>{children}</div> | ||||
| 
 | ||||
|         <div className={'mt-6 border-b border-gray-300'} /> | ||||
|       </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[]) => { | ||||
|   const unseenNotifications = notifications.filter((n) => !n.isSeen) | ||||
|   return await Promise.all( | ||||
|  | @ -950,30 +1152,7 @@ function NotificationTextLabel(props: { | |||
|   if (sourceType === 'contract') { | ||||
|     if (justSummary || !sourceText) return <div /> | ||||
|     // 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
 | ||||
|     if (sourceUpdateType === 'closed') { | ||||
|       return <div /> | ||||
|  | @ -1001,15 +1180,6 @@ function NotificationTextLabel(props: { | |||
|     return ( | ||||
|       <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) { | ||||
|     return ( | ||||
|       <> | ||||
|  | @ -1073,9 +1243,6 @@ function getReasonForShowingNotification( | |||
|         else if (sourceSlug) reasonText = 'joined because you shared' | ||||
|         else reasonText = 'joined because of you' | ||||
|         break | ||||
|       case 'bet': | ||||
|         reasonText = 'bet against you' | ||||
|         break | ||||
|       case 'challenge': | ||||
|         reasonText = 'accepted your challenge' | ||||
|         break | ||||
|  |  | |||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue
	
	Block a user